Skip to main content
Search
Search the documentation

Select

A short list of known options, with the trigger label, the value mapping and the change signal already wired.

pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-select.json

Wrapping a select is the first thing most projects do with the primitive and the last thing any of them revisit: derive the trigger label, map the value in and out of the primitive's own keys, decide what cleared means. Written by hand, no two copies of those few lines stay the same for long.

What Coral adds

  • The trigger label derives itself. The hand-rolled version opens with the same line every time: options.find((o) => o.value === value)?.label ?? placeholder.
  • The value keeps its type. options is Option<T>[], so a numeric id goes in and a numeric id comes out. bits-ui keys items by string; the mapping happens here, once.
  • onchange fires when the user changes something. Not on mount, not when value is assigned from code.
  • Typeahead works with the list closed, the way a native <select> does, because the labels are handed to the primitive up front.
  • Groups, descriptions and a clear control come with the same Option<T> the combobox uses.

Select or combobox?

They are not interchangeable and the boundary is worth stating once:

UseWhen
selectThe list is short and known - a status, a role, a priority. No search.
comboboxThe list is long, needs a search box, comes from the server, or is multi-select.

Both open a listbox of options. The difference is the trigger: a select's is a button, nothing to type into, and the platform's own typeahead while it is focused and shut. A combobox's is a text input, which announces itself as editable and sets the expectation that typing narrows the list.

That expectation is the whole point when there is a search behind it, and a liability when there is not. Stripping the search out of a combobox leaves a text field that ignores what you type into it.

There is no type="multiple" here, on purpose. A multi-select is not a select with one more prop: it needs a checkbox per row, badges in the trigger, and, as soon as the list is long enough for anyone to want more than two of its entries, a search box. That component is the combobox. Multiple selection arrives with search attached; keeping it out of select costs no one anything and keeps this component the size of the problem it solves.

Typed values

Selected id: none (undefined)

The obvious item key is String(option.value), and it is lossy twice: two ids that stringify the same collapse onto one item, and any object value becomes [object Object], so every option in the list shares a key. Coral keys items by their position instead, which cannot collide with itself, and hands the value back untouched.

Groups

Pass OptionGroup[] instead of Option[] and each run gets a heading. Do not mix the two shapes in one array: the first entry decides how the whole array is read.

Clearable

Value: normal

clearable does both halves of unsetting: a ✕ appears on the trigger, and re-picking the selected option deselects it. Cleared means undefined, not ''. An empty string is a value, and a field that reports one when nothing is selected pushes the check onto every caller.

The clear control sits beside the trigger rather than inside it. The trigger is a <button>, and a button nested in a button is invalid HTML that browsers recover from by dropping one of the two.

Forms

name renders the field that submits. It is Coral's own rather than the primitive's, because bits-ui would submit the internal item key. serialize controls what gets written; it defaults to String, which is right for ids, numbers and enum members and wrong for objects, so set it when T is not a primitive.

form points the field at a form by id, for a select that renders outside it: a portalled dialog, a sticky toolbar. required blocks submission while nothing is selected.

The field is clipped to a pixel rather than type="hidden", because a hidden input is barred from constraint validation and required on one does nothing at all. It stays out of the tab order and out of the accessibility tree; the only thing that reaches it is the browser's own validation message.

Import

<script lang="ts">
	import Select from '#lib/components/coral/kit/select/select.svelte';
	import type { Option } from '#lib/components/coral/lib/options.js';
</script>

Props

Everything the shadcn root accepts stays available: open, onOpenChange, loop, scrollAlignment, autocomplete. On top of that:

PropTypeDefaultDescription
optionsOption<T>[] | OptionGroup<T>[]-The list, flat or grouped.
valueT-Bindable. Matched with ===.
onchange(option: Option<T> | undefined) => void-User-driven changes only. Never on mount.
placeholderstringSelect an option...Shown while nothing is selected.
disabledbooleanfalseBlocks the trigger.
clearablebooleanfalseAdds a clear control; re-picking deselects.
clearLabelstringClear selectionAccessible label for the clear control.
namestring-Submits with a surrounding form.
formstring-id of the form, for a select outside it.
requiredbooleanfalseBlocks submission while nothing is selected.
serialize(value: T) => stringStringWhat the field writes. Required for object T.
size'sm' | 'default''default'Trigger height, from the primitive.
idstringgeneratedPut on the trigger, for a <Label for>.
aria-*aria-label, -labelledby, -describedby, -invalid-Put on the trigger.
classstring-Merged onto the trigger.
contentClassstring-Merged onto the dropdown.
triggerSnippet<[{ selected, placeholder, disabled }]>-Replaces the label inside the trigger.
optionSnippet<[{ option, selected }]>-Replaces the body of each row. The check stays.

Option<T>

Shared with the combobox, from #lib/components/coral/lib/options.js:

FieldTypeDescription
valueTWhat the caller gets back.
labelstringWhat is shown, and what typeahead searches.
disabledbooleanBlocks selection. The option stays visible.
descriptionstringSecond line under the label.
keywordsstring[]Search synonyms. Read by the combobox; ignored by the select.

Accessibility

The primitive owns the listbox semantics, roving focus and typeahead. Coral adds three things:

  • A name, only when nothing else gives one. While nothing is selected the trigger gets an aria-label of the placeholder, so a screen reader announces what the control is for rather than reading the visible placeholder as if it were a choice. It steps aside for an aria-label or aria-labelledby of yours, and for a <label for> pointing at the trigger's id - an aria-label written over an associated label silently replaces it, and a field labelled "Plan" would be announced as "Select an option...".
  • The combobox role. The primitive leaves it off the trigger while setting aria-haspopup and aria-activedescendant on it, and a bare button may not carry the second. The trigger is the select-only combobox from the ARIA practices guide, it says which list it controls while that list is open, and the list is named after the trigger.
  • A clear control that is a real button with clearLabel on it, outside the trigger, so it is reachable by keyboard.

id, aria-label, aria-labelledby, aria-describedby and aria-invalid go to the trigger, which is what a <Label for> or a form library's control hands out.

The trigger snippet replaces the label, not the button: the chevron and the trigger element belong to the primitive. For a trigger that is not a button-with-a-label at all, use the combobox's trigger snippet, which replaces the whole element.