Select
A dropdown for choosing from a list of options, wired for forms.
Playground
<script lang="ts">
import { Select } from '@urbicon-ui/blocks';
const options = [
{ label: 'Svelte', value: 'svelte' },
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Angular', value: 'angular' }
];
</script>
<Select
{options}
clearable
label="Framework"
placeholder="Choose a framework"
/>01 Purpose
Select shows the chosen value and hides the full list until the user opens it.
| Reach for | When |
|---|---|
Select (this) | One choice from a moderate list, where a closed control keeps the form compact. |
| RadioGroup | A short set of two to five options, all worth showing at once. |
| Combobox | A long list the user needs to filter by typing. |
| Menu | Firing actions or commands, not binding a form value. |
02 Examples
Bound value
bind:value holds the value of the picked option and is null until something is picked, so country is a $state<string | null>(null) in the parent. clearable swaps the chevron for a reset control once there is something to reset.Selected: null
<Select
label="Country"
bind:value={country}
clearable
placeholder="Choose a country"
options={[
{ label: 'Germany', value: 'de' },
{ label: 'France', value: 'fr' },
{ label: 'Spain', value: 'es' },
{ label: 'Italy', value: 'it' },
{ label: 'United Kingdom', value: 'uk' }
]}
/>
<p class="text-text-tertiary text-xs">
Selected: <code class="text-text-primary">{country ?? 'null'}</code>
</p>Grouped options
groups takes the place of options and puts each list under a section label. The arrow keys walk the options and pass over the headers. An option with disabled: true stays visible, and the arrows pass over it too.<Select
label="Timezone"
placeholder="Select timezone"
groups={[
{
label: 'Americas',
options: [
{ label: 'New York (EST)', value: 'est' },
{ label: 'Chicago (CST)', value: 'cst' },
{ label: 'Los Angeles (PST)', value: 'pst', disabled: true }
]
},
{
label: 'Europe',
options: [
{ label: 'London (GMT)', value: 'gmt' },
{ label: 'Berlin (CET)', value: 'cet' },
{ label: 'Moscow (MSK)', value: 'msk' }
]
}
]}
/>Several at once, with counts
multiple binds value to an array. Every row gains a checkbox, the trigger lists the picked labels, and the listbox stays open so the user can tick a few in one go. An option's hint is trailing secondary text — the facet count that says whether the filter is worth applying. It is a string, so the formatting stays yours, and it is part of the row's text, so the option is announced as “Bug 24”.Picked: none
<Select
label="Tags"
multiple
bind:value={tags}
placeholder="Select tags"
options={[
{ label: 'Bug', value: 'bug', hint: '24' },
{ label: 'Documentation', value: 'docs', hint: '8' },
{ label: 'Enhancement', value: 'enhancement', hint: '113' },
{ label: 'Good first issue', value: 'good-first-issue', hint: '3' }
]}
/>
<p class="text-text-tertiary text-xs">
Picked: <code class="text-text-primary">{tags.join(', ') || 'none'}</code>
</p>In a form
name renders a hidden input carrying the selected value, so a plain form submits the choice without any JavaScript. required adds the asterisk to the label and nothing else: the hidden input carries no native constraint, so check the value on submit and hand the message back as error, which takes the place of the helper text and switches the field to its danger styling.Submitted: nothing yet
<form class="flex flex-col gap-3" onsubmit={handleSubmit}>
<Select
label="Currency"
name="currency"
required
helper="Used for every invoice on this account"
error={currencyError}
placeholder="Select..."
options={[
{ label: 'Euro (EUR)', value: 'eur' },
{ label: 'US Dollar (USD)', value: 'usd' },
{ label: 'British Pound (GBP)', value: 'gbp' }
]}
/>
<Button type="submit" size="sm" class="self-start">Save</Button>
</form>
<p class="text-text-tertiary text-xs">
Submitted: <code class="text-text-primary">{submitted ?? 'nothing yet'}</code>
</p>03 Customization
Primary-tinted filter
class lands on the wrapper around the whole field, so a trigger restyle goes through slotClasses instead, and the open list is a second slot of its own. Whatever you pass is merged into the tv() defaults, which is why the radius tier, the focus ring and the keyboard behaviour survive it.<Select
label="Sort by"
value="popular"
options={[
{ label: 'Most popular', value: 'popular' },
{ label: 'Newest first', value: 'newest' },
{ label: 'Price: low to high', value: 'price-asc' },
{ label: 'Price: high to low', value: 'price-desc' }
]}
slotClasses={{
trigger: 'bg-primary-subtle border-primary text-primary-text hover:border-primary',
chevron: 'text-primary',
listbox: 'border-primary'
}}
/>If every Select in the app should share this treatment, set it once as a defaults entry for Select on a BlocksProvider. A preset is the opt-in variant of the same thing: it
reaches only the controls that name it through their preset prop.
A field that should read as the text it sits in is the bare variant, not a stack of reset classes: no frame,
no fill, no padding, no fixed height, and size keeps
only its type step. It needs context that says it is a field — a placeholder, a rule under the
line, a label before it — and it keeps the one thing such a reset usually loses, a focus
outline, whose colour is the --blocks-focus-ring-color custom property. Worked through on the Input page.
This is one of five ways to restyle a block. See Customization for class, slotClasses, unstyled, preset and provider-level overrides.
04 Accessibility
ARIA roles
The trigger is a role="combobox" with aria-expanded and aria-controls pointing at the panel, a role="listbox" of role="option" rows carrying aria-selected. Focus stays on the trigger the whole
time and the highlighted row travels with aria-activedescendant. A label is linked through aria-labelledby, and where there is none the trigger
falls back to the aria-label you pass it. Helper and
error text reach the trigger through aria-describedby, and an error sets aria-invalid.
Keyboard
Enter, Space and both arrow keys open the listbox. ↑ / ↓ move through the selectable options, wrapping at the ends
and passing over disabled rows. Home / End highlight the first and
last of them, and Enter / Space select whatever is highlighted.
Focus stays on the trigger while the list is open, so Escape only closes it. Tab closes it and moves on to the next tab stop, which is the clear control whenever clearable has something to clear.
05 API Reference
39 propsProp | Type | Default | Description | |
|---|---|---|---|---|
class | string | — | Extra classes merged onto the root wrapper element. | |
clearable | boolean | false | Show a clear button when a value is selected. | |
closeOnClickOutside | boolean | — | Whether the listbox closes on outside click. Default true.
Set to false to pin the listbox open while the consumer manages
dismissal explicitly. | |
closeOnEscape | boolean | — | Whether the listbox closes on Escape key. Default true.
Set to false for inline contexts where Escape should be intercepted
by an outer widget (e.g. a row editor that wants to revert on Escape). | |
closeOnSelect | boolean | — | Whether the listbox closes after a selection is made.
Defaults to true in single-select and false in multi-select — the
multi-select default lets users tick multiple options without re-opening,
matching the established multi-select pattern. | |
customItem | Snippet<[SelectOption<T>, boolean, () => void]> | — | Replace the default per-option rendering. Receives the option, its
selection state, and a toggle callback (call to select/deselect).
The outer <div role="option"> container is still owned by Select for
ARIA correctness — the snippet renders the option's visible contents.
Positional args: (option, isSelected, toggle). | |
customTrigger | Snippet<[SelectOption<T>[], boolean, () => void]> | — | Replace the entire trigger button (chevron, label, clear control) with a
custom element. Receives the selected options, current open state, and a
clear callback. Use this for icon-only triggers, badge-counter triggers,
or any non-standard chrome — the Select still owns open/close + selection
state via the returned callbacks.
Positional args: (selected, open, clear). | |
customTriggerContent | Snippet<[SelectOption<T>[]]> | — | Replace only the trigger's text/label area (chevron and clear button stay
intact). Receives the selected options. Useful for showing icons next to
the label, badges with counts, or formatted multi-select summaries while
keeping the standard outlined-Select chrome.
It receives the *selected* options, which is [] for a null value even
where nullOption is set — the null option is a row, not a selection — so
a custom trigger renders nothing exactly where the default one says "All
strands"; render your own nullOption label for the empty case.
Positional args: (selected). | |
disabled | boolean | false | disabled property | |
error | string | — | Error message below the select. Overrides helper and forces danger styling. | |
groups | SelectGroup<T>[] | — | Grouped options with section labels. Takes precedence over options. | |
helper | string | — | Helper text below the select. Hidden when error is set. | |
id | string | — | Explicit id for the component. Auto-generated if omitted.
The id lands on the component's wrapper, which is not a labelable element.
The focusable trigger is derived from it as ${id}-trigger — that is
the id an external <label for> has to address, since a for pointing at
the wrapper focuses nothing. | |
label | string | — | Label text displayed above the select, auto-linked via id. | |
messageType variant | errorhelper | helper | Controls the messageType behavior and appearance of the Select component. Available options: error, helper. | |
mint | MintProp | 'none' | Micro-interaction preset applied to the trigger — the default trigger
button, or the wrapper around a customTrigger. Only applies while
not disabled. | |
multiPlaceholder | string | ((selected: SelectOption<T>[]) => string) | — | Placeholder shown when one or more values are picked. Pass a string for a static label ("3 selected") or a function that receives the currently selected options and returns a custom string. When unset, the trigger lists the selected labels comma-separated. | |
multiple | falsetrue | — | Single-select mode (the default). Omit or set to false explicitly. | |
name | string | — | Shared name for a hidden input for native form submission. | |
nullOption | string | NullOptionConfig | — | Render an explicit "no value" option as the first row of the listbox.
Selecting it sets the bound value to null. When value is null,
the trigger displays this label instead of the placeholder.
Pass a string to use it as the label, or a NullOptionConfig for more control.
Ignored when groups is set — group structures own their option list. | |
onClickOutside | () => void | — | Fires after an outside click closes the listbox. Use for analytics
or side-effects on dismiss. Does NOT control whether the listbox
closes — that is governed by closeOnClickOutside. | |
onEscape | () => void | — | Fires after Escape closes the listbox. Use for analytics or to clear
ephemeral state on dismiss. Does NOT control whether the listbox
closes — that is governed by closeOnEscape. | |
onOpenChange | (open: boolean) => void | — | Fires when the listbox opens or closes from user interaction (trigger
click, keyboard, selection, Escape, Tab-out, outside click). Receives the
new open state — use it e.g. to lazy-load options on first open. Not
called when the consumer writes bind:open directly. | |
onValueChange | (value: T | null) => void | — | Fires after the selected value changes. Receives the new value or null on clear. | |
open | boolean | false | Controls whether the listbox is open. Supports bind:open for parents that
need to coordinate open/close (e.g. attaching a custom trigger button outside
the Select wrapper). | |
options | SelectOption<T>[] | — | Flat list of selectable options. | |
placeholder | string | 'Select...' | Text shown when no value is selected. Never shown in single mode alongside
nullOption: value defaults to null, which is that option's own value,
so the trigger names the null option from the start and the placeholder has
no state left to describe. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Select: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
required | boolean | false | Marks the field as mandatory: adds the asterisk to the label and sets
aria-required on the trigger. When name is set (and the field is not
disabled), an empty Select also blocks native form submission — the hidden
form-bridge input is exempt from constraint validation, so an invisible
required input participates in its place while nothing is selected. Focus
then returns to the trigger, which means no native validation bubble; pass
error to tell the reader what is wrong. | |
selected variant | true | false | Controls the selected behavior and appearance of the Select component. Available options: true. | |
selectionIndicator | checkmarknone | — | Selection indicator rendered next to each option.
- 'checkmark' — trailing check icon (default)
- 'none' — no indicator (typical when using customItem) | |
size variant | lgmdsmxl +1 more | md | Controls the dimensions, padding, and text size of the Select. Affects the component's physical footprint. Available options: lg, md, sm, and 2 more. | |
slotClasses | Partial<Record<SelectSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: wrapper (root —
what class also targets) | base | trigger | triggerText | placeholder |
chevron | clear | listbox | option | optionLabel | optionHint | optionCheck |
optionCheckbox | group | groupLabel | label | requiredMark | message. | |
syncWidth | boolean | true | Whether the listbox matches the trigger's width. Set false for icon-only
or compact triggers where the listbox should size to its content instead. | |
tier variant | commitmodify | modify | Selects the semantic radius tier of the Select — the shape family it belongs to (--radius-commit/-modify/-contain/-bridge). Shape is retuned per family in your theme, so this picks the family rather than a pixel value. Available options: commit, modify. | |
unstyled | boolean | — | Remove all default tv() classes. | |
usePortal | boolean | true | When true, the listbox is rendered into the browser top layer via the native
popover API, so it cannot be clipped by overflow: auto ancestors. When
false, the listbox stays in the regular DOM flow — useful inside other
popovers/portals to avoid double-portaling. | |
value | T[] | [] | Currently selected values. Supports bind:value. | |
variant | SelectVariants['variant'] | 'outlined' | Visual style.
- outlined (default) — visible border, surface-base background
- filled — surface-interactive fill, no border (compact toolbars)
- ghost — transparent until hover/focus (dense menus, inline editors)
- underline — bottom-line only, no border-box (editorial knob-strips,
docs playgrounds)
- bare — no frame, no fill, no padding, no fixed height: an inline word
choice that keeps the listbox role. size keeps only the type step, and
focus is an outline in --blocks-focus-ring-color |
06 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
SelectValue | type | helper | 0 | Primitive value types accepted by Select / Combobox options. Strings are the default; numbers and booleans cover form fields bound to numeric IDs or yes/no flags without forcing the consumer to convert back and forth at every call site. | |
SelectOption | interface | helper | 1 | A single select option. | |
SelectGroup | interface | helper | 1 | A labelled group of options. | |
NullOptionConfig | interface | helper | 0 | Configuration for an explicit "no value" option rendered at the top of the listbox.
Selecting it sets value to null and fires onValueChange(null).
Pass a string to use it as the label, or an object for additional control:
nullOption="No selection"
nullOption={{ label: 'Leave unassigned' }} | |
SelectSingleProps | interface | props | 0 | Single-select arm. value is T | null, selectionIndicator excludes
'checkbox' (a checkmark or no indicator), nullOption is available,
multiPlaceholder is *not* — there's never more than one selected label
to summarize. | |
SelectMultipleProps | interface | props | 0 | Multi-select arm. value is T[], selectionIndicator excludes
'checkmark' (a checkbox or no indicator), multiPlaceholder is
available, nullOption is *not* — clearing in multi mode empties the
array instead of binding a null sentinel. | |
SelectProps | type | helper | 0 | Discriminated union over multiple. Consumers pick a single shape at the
call site and the value type narrows accordingly — <Select multiple bind:value>
binds to T[], <Select bind:value> binds to T | null. See
SelectSingleProps / SelectMultipleProps for the per-mode details. | |
SelectVariants | type | variant | 0 | — | |
SelectSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
MintProp | type | helper | 1 | — | |
MintName | type | helper | 0 | A mint name: a built-in (autocompleted), 'none' to disable, or any
consumer-registered name. (string & {}) keeps the registry open — a
custom name still type-checks, it just isn't suggested. A typo therefore
also still compiles (it resolves like an unregistered custom name and
warns at runtime); the union buys completion and docs, not validation. | |
MintConfig | interface | helper | 0 | — | |
BuiltinMintName | type | helper | 0 | Built-in mint names as a literal union, so the mint prop autocompletes
across every component — the single list the hand-curated playground knobs
and docs used to drift away from. |
07 Installation
Import
import { Select } from '@urbicon-ui/blocks';