Select#

A single-selection dropdown field with optional search filtering.

Select — light theme Select — dark theme

Usage#

A select is a single-choice dropdown — .value is the chosen option’s value, and typing filters the list.

Basic#

bs.Select(["Red", "Green", "Blue"])
bs.Select(["Red", "Green", "Blue"], value="Green")

Option values#

An option’s displayed label can differ from its stored value. Each option is a plain string, a (text, value) tuple, or a {"text": ..., "value": ...} dict. value=, .value, and the change event all work in value-space — the value, not the label. The label currently shown is available as .text.

theme = bs.Select(
    [("Light theme", "light"), ("Dark theme", "dark"), {"text": "Follow system", "value": "auto"}],
    value="dark",
)
theme.value            # -> "dark"          (the value)
theme.text             # -> "Dark theme"    (the displayed label)
theme.value = "auto"   # selects "Follow system"
theme.on_change(lambda e: apply_theme(e.value))

theme.options          # -> [{"text": "Light theme", "value": "light"}, ...]

The option list controls what a user can pick — it is not a schema for the data you supply. Setting .value to something outside the list displays it without adding it to the list, so a stored record whose option has since been retired still opens in an editor, and reads back as the value you set (an int stays an int). Once the user picks something else the old value is gone, and it was never in the list to pick again. One edge: if the retired value renders as the same text as a live option’s label, that option wins — the two are indistinguishable on screen. Use a validation rule to report a retired value:

country.add_validation_rule(
    "custom",
    func=lambda v: not v or v in current_codes,
    message="That option is no longer available.",
)

Rules run against the value, not the label shown for it, so on a decoupled option list a rule sees "US" rather than "United States". A 'custom' rule runs on validate() and on form submit; pass trigger="always" to have it report as soon as the value changes. See Validation.

Validity is reactive state. select.valid is a Signal[bool] and select.error a Signal[str] (the current message, "" when valid) — bind the error straight to a label and it keeps itself in sync:

bs.Label(textsignal=country.error, accent="danger")   # shows and clears itself

With allow_custom_values=True the user may also type a value of their own.

Carrying extra data (the data bag)#

The dict form is a data bag — alongside the recognized keys (text, value, and the per-option icon/disabled), any other key you add rides along as carried data. Read the whole selected record, indexed by key, via .selection:

country = bs.Select(options=[
    {"text": "Canada", "value": "CA", "phone": "+1"},
    {"text": "Japan",  "value": "JP", "phone": "+81"},
], value="JP")

country.value             # -> "JP"
country.selection         # -> {"text": "Japan", "value": "JP", "phone": "+81"}
country.selection["phone"]  # -> "+81"

country.on_change(lambda e: dial(country.selection["phone"]))

.selection is the selected option’s full dict (the same shape you’d get from a ListView row), or None when nothing is selected. Unrecognized keys are accepted, not validated — the dict route is opt-in, so a mistyped key rides along silently rather than raising.

Two of the recognized keys change how an option is presented: icon renders a glyph beside the option’s label in the popup, and disabled greys the row out and makes it non-selectable (keyboard navigation and search auto-select skip it too). Per-option disabled is independent of the widget-level disabled= that locks the whole control:

bs.Select(options=[
    {"text": "Free",       "value": "free",  "icon": "gift"},
    {"text": "Pro",        "value": "pro",   "icon": "star"},
    {"text": "Enterprise", "value": "ent",   "icon": "buildings", "disabled": True},
], value="free")

Setting a disabled option’s value programmatically still works — disabled only blocks user selection.

Grouping#

Pass group_by="field" to cluster the popup under section headers, where field is any key your options carry — often a category that already lives in your data. Grouping is purely presentational: value, .selection, and .options are unaffected, and the grouping field rides along in the data bag.

bs.Select(
    options=[
        {"text": "Apple",    "value": "apple",    "category": "Fruit"},
        {"text": "Banana",   "value": "banana",   "category": "Fruit"},
        {"text": "Cherry",   "value": "cherry",   "category": "Fruit"},
        {"text": "Carrot",   "value": "carrot",   "category": "Vegetable"},
        {"text": "Broccoli", "value": "broccoli", "category": "Vegetable"},
        {"text": "Basil",    "value": "basil",    "category": "Herb"},
        {"text": "Mint",     "value": "mint",     "category": "Herb"},
    ],
    group_by="category",
    label="Ingredient",
    value="banana",
)

Groups appear in first-appearance order; an option that lacks the field renders without a header. Header text is shown verbatim — it is never re-cased or otherwise transformed, so .selection still returns the original value ({..., "category": "Fruit"}).

Select grouping — light theme Select grouping — dark theme

Limiting the popup height#

The popup grows to fit its options up to a built-in cap, then scrolls. Set max_visible_items= to cap it at roughly that many option rows — handy for long lists. Group headers and separators count toward the height, so the number is approximate.

countries = ["Canada", "France", "Germany", "Japan", "United States", "..."]
bs.Select(countries, label="Country", max_visible_items=8)

Label and message#

bs.Select(
    ["Option A", "Option B", "Option C"],
    label="Choose an option",
    message="Select the option that best applies.",
)

Required#

bs.Select(["Red", "Green", "Blue"], label="Color", required=True)

Searchable#

Set searchable=True to filter options as the user types.

countries = ["Canada", "France", "Germany", "Japan", "United States"]
bs.Select(countries, label="Country", searchable=True)

Custom values#

Set allow_custom_values=True to accept typed values not in the list.

bs.Select(["Red", "Green", "Blue"], allow_custom_values=True)

States#

bs.Select(["A", "B", "C"], value="A", label="Normal")
bs.Select(["A", "B", "C"], value="A", label="Read only",  read_only=True)
bs.Select(["A", "B", "C"], value="A", label="Disabled",   disabled=True)
Select states — light theme Select states — dark theme

Reactive binding#

Bind a Signal with signal=. The field and signal stay in sync.

color = bs.Signal("Red")
bs.Select(["Red", "Green", "Blue"], signal=color)
color.subscribe(lambda v: apply_color(v))

The signal carries the option’s value, not the label shown for it. When the two differ, seed the signal with a value and the field displays the matching label:

size = bs.Signal("m")
bs.Select([("Small", "s"), ("Medium", "m")], signal=size)
# the field shows "Medium"

size.set("s")     # the field shows "Small"
size()            # -> 's'

Setting the signal selects the matching option and raises a change event, and choosing an option writes that option’s value back.

Updating options at runtime#

Assign to .options to replace the list dynamically.

sel = bs.Select(["A", "B", "C"])
sel.options = ["X", "Y", "Z"]

Widget sizing#

All widgets accept self-placement kwargs via **kwargs. The parent container determines which options apply — Column / Row parents use the layout kwargs below, grid-based parents use grid kwargs.

Column (vertical layout)

Used inside a Column, App, or any other container with a column layout. Children are arranged top-to-bottom, so horizontal aligns each child across the width and grow shares the vertical space. (vertical does not apply — the order of the children sets their top-to-bottom position.)

horizontal

Cross-axis placement of the widget: 'left', 'center', 'right', or 'stretch' to fill the available width.

grow

Claim and fill a share of the leftover vertical space (the layout direction). True or False.

margin

External spacing in pixels. Accepts an integer (equal on all sides), a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing (left and right). Accepts an integer or a 2-tuple (left, right) for asymmetric spacing. Overrides the horizontal component of margin=.

margin_y

Vertical external spacing (top and bottom). Accepts an integer or a 2-tuple (top, bottom) for asymmetric spacing. Overrides the vertical component of margin=.

Row (horizontal layout)

Used inside a Row or any other container with a row layout. Children are arranged left-to-right, so vertical aligns each child across the height and grow shares the horizontal space. (horizontal does not apply — the order of the children sets their left-to-right position.)

vertical

Cross-axis placement of the widget: 'top', 'center', 'bottom', or 'stretch' to fill the available height.

grow

Claim and fill a share of the leftover horizontal space (the layout direction). True or False.

margin

External spacing in pixels. Accepts an integer (equal on all sides), a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing (left and right). Accepts an integer or a 2-tuple (left, right) for asymmetric spacing. Overrides the horizontal component of margin=.

margin_y

Vertical external spacing (top and bottom). Accepts an integer or a 2-tuple (top, bottom) for asymmetric spacing. Overrides the vertical component of margin=.

Grid

Used inside a Grid container.

row / column

Zero-based row and column indices.

rowspan / columnspan

Number of rows or columns to span.

horizontal

Horizontal placement within the grid cell: 'left', 'center', 'right', or 'stretch' to fill the cell width.

vertical

Vertical placement within the grid cell: 'top', 'center', 'bottom', or 'stretch' to fill the cell height.

margin

External spacing in pixels. Accepts an integer, a 2-tuple (horizontal, vertical), or a 4-tuple (left, top, right, bottom).

margin_x

Horizontal external spacing. Accepts an integer or (left, right).

margin_y

Vertical external spacing. Accepts an integer or (top, bottom).

API#

The complete reference for Select lives on the Widgets API page. At a glance:

Select

A single-selection dropdown field.

Full Example#

 1
 2COUNTRIES = [
 3    "Canada", "France", "Germany", "Italy", "Japan",
 4    "Mexico", "Spain", "United Kingdom", "United States",
 5]
 6
 7with bs.App(title="Select Demo", padding=20, gap=16) as app:
 8
 9    # Basic
10    bs.Label("Basic", font="heading-sm")
11    bs.Select(["Red", "Green", "Blue"], horizontal="stretch")
12    bs.Select(["Red", "Green", "Blue"], value="Green", horizontal="stretch")
13
14    # Label and message
15    bs.Label("Label and Message", font="heading-sm")
16    bs.Select(
17        ["Option A", "Option B", "Option C"],
18        label="Choose an option",
19        message="Select the option that best applies.",
20        horizontal="stretch",
21    )
22
23    # Required
24    bs.Label("Required", font="heading-sm")
25    bs.Select(["Red", "Green", "Blue"], label="Color", required=True, horizontal="stretch")
26
27    # Searchable
28    bs.Label("Searchable", font="heading-sm")
29    bs.Select(COUNTRIES, label="Country", searchable=True, horizontal="stretch")
30
31    # Custom values
32    bs.Label("Custom Values", font="heading-sm")
33    bs.Select(
34        ["Red", "Green", "Blue"],
35        label="Color (custom allowed)",
36        allow_custom_values=True,
37        horizontal="stretch",
38    )
39
40    # Grouping — cluster the popup under headers by an option field
41    bs.Label("Grouping", font="heading-sm")
42    bs.Select(
43        options=[
44            {"text": "Apple",    "value": "apple",    "category": "Fruit"},
45            {"text": "Banana",   "value": "banana",   "category": "Fruit"},
46            {"text": "Carrot",   "value": "carrot",   "category": "Vegetable"},
47            {"text": "Broccoli", "value": "broccoli", "category": "Vegetable"},
48            {"text": "Basil",    "value": "basil",    "category": "Herb"},
49        ],
50        group_by="category",
51        label="Ingredient",
52        horizontal="stretch",
53    )
54
55    # Capped popup height — long list scrolls after ~6 rows
56    bs.Label("Limited Popup Height", font="heading-sm")
57    bs.Select(COUNTRIES, label="Country (max 6 visible)", max_visible_items=6, horizontal="stretch")
58
59    # States
60    bs.Label("States", font="heading-sm")
61    with bs.Row(gap=8, horizontal="stretch", grow_items=True):
62        bs.Select(["A", "B", "C"], value="A", label="Normal")
63        bs.Select(["A", "B", "C"], value="A", label="Read only",  read_only=True)
64        bs.Select(["A", "B", "C"], value="A", label="Disabled",   disabled=True)
65
66    # Reactive binding
67    bs.Label("Reactive Binding", font="heading-sm")
68    with bs.Column(gap=6, horizontal="stretch", horizontal_items="stretch"):
69        color = bs.Signal("Red")
70        bs.Select(["Red", "Green", "Blue"], label="Color", signal=color)
71        color_lbl = bs.Label("Selected: Red", accent="secondary", font="caption")
72        def _update_color(v):
73            color_lbl.text = f"Selected: {v}"
74
75        color.subscribe(_update_color)
76
77    # Runtime option updates
78    bs.Label("Runtime Updates", font="heading-sm")
79    with bs.Column(gap=6, horizontal="stretch", horizontal_items="stretch"):
80        sel = bs.Select(["Alpha", "Beta", "Gamma"])
81        with bs.Row(gap=8):
82            def _set_abc():
83                sel.options = ["A", "B", "C"]
84
85            def _set_123():
86                sel.options = ["1", "2", "3"]
87
88            bs.Button("Set ABC", variant="outline", on_click=_set_abc)
89            bs.Button("Set 1-2-3", variant="outline", on_click=_set_123)
90
91app.run()