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.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-select.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-select.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-select.jsonWrapping 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.
<script lang="ts">
import Select from '#lib/components/coral/kit/select/select.svelte';
const statuses = [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'In review' },
{ value: 'approved', label: 'Approved' },
{ value: 'archived', label: 'Archived', disabled: true }
];
let status = $state<string>();
</script>
<div class="w-64">
<Select options={statuses} bind:value={status} placeholder="Select a status..." />
</div>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.
optionsisOption<T>[], so a numeric id goes in and a numeric id comes out. bits-ui keys items by string; the mapping happens here, once. onchangefires when the user changes something. Not on mount, not whenvalueis 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:
| Use | When |
|---|---|
select | The list is short and known - a status, a role, a priority. No search. |
combobox | The 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)
<script lang="ts">
import Select from '#lib/components/coral/kit/select/select.svelte';
// `value` is whatever the project already has - here a numeric id, not a string.
const owners = [
{ value: 1, label: 'Amara Diallo' },
{ value: 2, label: 'Kenji Tanaka' },
{ value: 3, label: 'Priya Sharma' }
];
let ownerId = $state<number>();
</script>
<div class="flex w-64 flex-col gap-3">
<Select options={owners} bind:value={ownerId} placeholder="Assign an owner..." />
<p class="text-sm text-muted-foreground">
Selected id: {ownerId ?? 'none'} ({typeof ownerId})
</p>
</div>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
<script lang="ts">
import Select from '#lib/components/coral/kit/select/select.svelte';
const regions = [
{
label: 'Americas',
options: [
{ value: 'us-east-1', label: 'N. Virginia' },
{ value: 'sa-east-1', label: 'São Paulo' }
]
},
{
label: 'Europe',
options: [
{ value: 'eu-west-1', label: 'Ireland' },
{ value: 'eu-central-1', label: 'Frankfurt' }
]
}
];
let region = $state<string>();
</script>
<div class="w-64">
<Select options={regions} bind:value={region} placeholder="Select a region..." />
</div>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
<script lang="ts">
import Select from '#lib/components/coral/kit/select/select.svelte';
const priorities = [
{ value: 'low', label: 'Low', description: 'Whenever there is room' },
{ value: 'normal', label: 'Normal', description: 'The default queue' },
{ value: 'high', label: 'High', description: 'Ahead of everything else' }
];
let priority = $state<string | undefined>('normal');
</script>
<div class="flex w-72 flex-col gap-3">
<Select options={priorities} bind:value={priority} clearable placeholder="No priority" />
<p class="text-sm text-muted-foreground">Value: {priority ?? 'undefined'}</p>
</div>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
<script lang="ts">
import Select from '#lib/components/coral/kit/select/select.svelte';
const fruits = [
{ value: 1, label: 'Açaí' },
{ value: 2, label: 'Guava' },
{ value: 3, label: 'Kiwi' }
];
let submitted = $state<string>();
function onsubmit(event: SubmitEvent) {
event.preventDefault();
const data = new FormData(event.currentTarget as HTMLFormElement);
submitted = data.get('fruit')?.toString() || 'nothing';
}
</script>
<form {onsubmit} class="flex w-64 flex-col gap-3">
<!-- `required` blocks the submit while nothing is picked - try it with the field empty. -->
<Select options={fruits} name="fruit" placeholder="Select a fruit..." clearable required />
<button type="submit" class="text-sm underline underline-offset-4">Submit</button>
{#if submitted}
<p class="text-sm text-muted-foreground">Posted fruit={submitted}</p>
{/if}
</form>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 andrequiredon 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:
| Prop | Type | Default | Description |
|---|---|---|---|
options | Option<T>[] | OptionGroup<T>[] | - | The list, flat or grouped. |
value | T | - | Bindable. Matched with ===. |
onchange | (option: Option<T> | undefined) => void | - | User-driven changes only. Never on mount. |
placeholder | string | Select an option... | Shown while nothing is selected. |
disabled | boolean | false | Blocks the trigger. |
clearable | boolean | false | Adds a clear control; re-picking deselects. |
clearLabel | string | Clear selection | Accessible label for the clear control. |
name | string | - | Submits with a surrounding form. |
form | string | - | id of the form, for a select outside it. |
required | boolean | false | Blocks submission while nothing is selected. |
serialize | (value: T) => string | String | What the field writes. Required for object T. |
size | 'sm' | 'default' | 'default' | Trigger height, from the primitive. |
id | string | generated | Put on the trigger, for a <Label for>. |
aria-* | aria-label, -labelledby, -describedby, -invalid | - | Put on the trigger. |
class | string | - | Merged onto the trigger. |
contentClass | string | - | Merged onto the dropdown. |
trigger | Snippet<[{ selected, placeholder, disabled }]> | - | Replaces the label inside the trigger. |
option | Snippet<[{ option, selected }]> | - | Replaces the body of each row. The check stays. |
Option<T>
Shared with the combobox, from #lib/components/coral/lib/options.js:
| Field | Type | Description |
|---|---|---|
value | T | What the caller gets back. |
label | string | What is shown, and what typeahead searches. |
disabled | boolean | Blocks selection. The option stays visible. |
description | string | Second line under the label. |
keywords | string[] | 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-labelof 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 anaria-labeloraria-labelledbyof yours, and for a<label for>pointing at the trigger'sid- anaria-labelwritten 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-haspopupandaria-activedescendanton 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
clearLabelon 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.