Primitives Forms
Select
A select field: the value, a placeholder, a clear button and a chevron inside one field, and a dropdown list with a check mark on the chosen option, drawn the same in every browser. A native select underneath keeps the options, the value and the form name, and is the field when JavaScript is off. Tier 2.
- Tier
- 2 · Lazy scoped controller
- Behavior JS
- 3362 B brotli · 3731 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<select>,role="combobox",aria-activedescendant,popover="manual",anchor-name,@media (scripting)- Shared listeners
- click, keydown, pointerdown, pointerover, focusout, change, toggle
- Per-instance listeners
- scoped to one interaction session
- Lazy state
- WeakMap entry created on first interaction
- Native base
<select> + role=combobox trigger + [popover] + ARIA listbox (role=listbox, option)
On this page
Example
The bound value: empty
<label id="fruit-label" for="fruit-native">Fruit</label>
<div id="fruit" data-slean="select">
<select id="fruit-native" name="fruit" data-slean-part="native">
<option value="" hidden>Select a fruit</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="cherry" disabled>Cherry</option>
<option value="grape">Grape</option>
<option value="mango">Mango</option>
</select>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="fruit-list"
aria-labelledby="fruit-label"
data-slean-part="trigger"
>
<span data-slean-part="value"></span>
<span data-slean-part="placeholder">Select a fruit</span>
</button>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<span data-slean-part="toggle" aria-hidden="true"></span>
<div popover="manual" data-slean-part="popup">
<ul id="fruit-list" role="listbox" aria-labelledby="fruit-label"></ul>
</div>
</div><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="select".
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/select.css';
const FRUITS = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry', disabled: true },
{ value: 'grape', label: 'Grape' },
{ value: 'mango', label: 'Mango' }
];
// The native select holds the value; bind:value follows the change event the behavior
// dispatches after it writes it.
let fruit = $state('');
const label = (value: string) => FRUITS.find((f) => f.value === value)?.label ?? '';
</script>
<label id="fruit-label" for="fruit-native">Fruit</label>
<div id="fruit" data-slean="select">
<select id="fruit-native" name="fruit" data-slean-part="native" bind:value={fruit}>
<option value="" hidden>Select a fruit</option>
{#each FRUITS as f (f.value)}
<option value={f.value} disabled={f.disabled}>{f.label}</option>
{/each}
</select>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="fruit-list"
aria-labelledby="fruit-label"
data-slean-part="trigger"
>
<span data-slean-part="value">{label(fruit)}</span>
<span data-slean-part="placeholder">Select a fruit</span>
</button>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<span data-slean-part="toggle" aria-hidden="true"></span>
<div popover="manual" data-slean-part="popup">
<ul id="fruit-list" role="listbox" aria-labelledby="fruit-label"></ul>
</div>
</div>Why this implementation exists
The native <select> has the value, form submission, autofill, disabled options and optgroups, and it is the right control without JavaScript. Its interface is not the same everywhere: the customizable select (appearance: base-select) is in Chrome and Safari 27, not in Firefox, and the platform menus cannot show a placeholder, a clear button or tags. ADR 0007 keeps the native select as the value and draws the field over it.
The owned field is the control shell every form control shares (control.css). The popup listbox is empty in the markup: the behavior renders it from the native options each time it opens, so the options the application renders into the <select> are the only list there is, and the value is never mirrored. Focus stays on the trigger and the active option is announced through aria-activedescendant, the APG select-only combobox.
The browser owns
- the value, the options, disabled options, optgroups and form submission of the native <select>
- the popup in the top layer through showPopover() and hidePopover()
- placement under the field through CSS anchor positioning in the stylesheet
- the combobox, listbox and option roles and the aria-activedescendant announcement
- the native select itself when scripting is off
Svelte Lean owns
- the field: value, placeholder, clear button and chevron inside one control shell (control.css)
- rendering the listbox from the native options and optgroups on every open
- the keyboard of the APG select-only combobox: arrows, Home and End, PageUp and PageDown, type-ahead, Enter, Space, Escape, Tab
- writing the native select and dispatching input and change; the cancelable slean:select event
- tags with a remove button for a multiple select, and Backspace to drop the last one
- the lazy controller: a WeakMap entry on the first interaction and one scoped document listener while open
- development validation of the markup, select.css and the contract
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="select"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/select/register';import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/select.css';Wrap a native <select data-slean-part="native"> in <div data-slean="select"> with a role="combobox" trigger (holding the value and placeholder parts), an optional clear button and chevron, and a popover="manual" popup with an empty role="listbox". A hidden first option is the placeholder.
Bind the native select with bind:value and render the chosen label in the value part; the behavior writes the native select, dispatches input and change, and updates the label through its text node. After setting the value in script, dispatch change on the native select.
For search inside the list use Combobox. A bare <select data-slean="select"> is the Tier 0 styled native select (see below).
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div id="…" data-slean="select"> | – | yes | The field. Options: data-slean-loop, data-slean-render="app"; styles: data-size="sm|lg", data-status="error|warning". |
| native | <select name="…"> | native | yes | The options, the value, the form name, disabled and multiple. A hidden option is the placeholder. Shown only without scripting. |
| trigger | <button type="button" role="combobox" aria-haspopup="listbox" aria-controls="…"> | trigger | yes | Holds DOM focus and a name (aria-labelledby). A <div role="combobox" tabindex="0"> for a multiple select, whose tags contain buttons. |
| value | <span> | value | yes | Inside the trigger: the chosen label (server-render it), or the tags of a multiple select. |
| placeholder | <span> | placeholder | no | After value; shown by the stylesheet while value is empty. |
| clear | <button type="button" tabindex="-1" aria-label="…"> | clear | no | Shown on hover or focus while there is a value; returns to the placeholder option. |
| toggle | <span aria-hidden="true"> | toggle | no | The chevron, a mask from --slean-icon-chevron-down; turns while the popup is open. |
| popup | <div popover="manual"> | popup | yes | Holds an empty <ul role="listbox"> the trigger’s aria-controls names; the behavior fills it on open. |
| empty | any element in the popup | empty | no | Shown when the native select has no visible option. |
| tag | <span data-slean-value="…"> | tag | no | Multiple: one per chosen option, rendered by the behavior, or by the application with data-slean-render="app". |
| remove | <button type="button" tabindex="-1" aria-label="…"> | remove | no | Inside a tag; drops that option. |
Runtime profile
Tier 2: a lazy scoped controller. Seven shared listeners (click, keydown, pointerdown, pointerover, focusout, change and toggle in the capture phase) serve every select on the page. A root gets a WeakMap entry on its first interaction and, while its popup is open, one document pointerdown listener that closes it on a press outside; focus leaving the root deletes the entry. The Vite plugin injects the register module for a select marker on a <div> and nothing for the same marker on a bare <select>.
Accessibility contract
- The trigger is a
role="combobox"witharia-haspopup="listbox",aria-expanded(written),aria-controlsnaming the listbox andaria-labelledbynaming the label; it keeps DOM focus andaria-activedescendantnames the active option. - Options are
role="option"witharia-selectedon the chosen ones andaria-disabledfor a disabled native option; an optgroup is arole="group"labelled by its label. A multiple select’s listbox hasaria-multiselectable="true". - The clear button, the tag remove buttons and the chevron are out of the tab order; Backspace removes the last tag from the keyboard.
- An error is
aria-invalid="true"on the trigger with the message inaria-describedby; the native select has no validation bubble while it is hidden, so validate in the application.
Keyboard
| Key | When | Result |
|---|---|---|
| ArrowDown/ArrowUp/Enter/Space | trigger focused, popup closed | Opens on the chosen option, else the first enabled one |
| ArrowDown/ArrowUp | popup open | Moves the active option, skipping disabled ones; wraps unless data-slean-loop="false" |
| Home/End | trigger focused | Opens if needed and moves to the first or last enabled option |
| PageUp/PageDown | popup open | Ten options up or down |
| Enter/Space | popup open | Chooses the active option; a single select closes, a multiple select toggles it and stays open |
| letters | trigger focused | Opens if needed and moves to the matching option (type-ahead) |
| Escape/Alt+ArrowUp | popup open | Closes without a change; when closed, Escape is left to ancestors |
| Tab | popup open | Closes without a change; focus moves natively |
| Backspace | multiple, popup closed | Drops the last tag |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<select>, <option>, <optgroup> | Widely available | Not applicable |
Popover API: popover, showPopover(), hidePopover(), :popover-open | Baseline 2024 | The popup renders in place instead of in the top layer |
CSS anchor positioning: anchor-name, anchor-scope, position-area, anchor-size() | Not Baseline: Chrome 125 (anchor-scope 131) and Safari 26 | The popover popup is centered in the top layer |
:has(), @media (scripting) | Baseline 2023 | The clear button and the placeholder do not react to the value; without the scripting query both the field and the native select show |
Without JavaScript
@media (scripting: none) in select.css hides the trigger, the clear button, the chevron and the popup and shows the native <select>, styled as the Tier 0 select below: the platform’s picker, keyboard and form submission. With scripting the native select is hidden and the owned field works on it.
Server rendering
Render the chosen option’s label in the value part, aria-expanded as false, an empty listbox and a closed popup. The register module is safe to import on the server.
Before hydration
Before the behavior loads, the field shows the server’s label and nothing opens. The registration attaches no per-root state, so the first interaction after hydration creates the controller and renders the options.
Styling
control.css draws the field: border, hover, focus halo, sizes (data-size), status (data-status), the clear and chevron icon masks, the popup surface, the option rows, the group labels and the check mark. select.css adds the value, the placeholder and the tags, hands the field to the native select without scripting, and styles the Tier 0 select. The tokens of control.css and select.css:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | gap, padding-inline, margin-block, padding, scroll-padding-block, padding-block |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-3 | 0.75rem | padding-inline, background-position, padding-inline-end |
--slean-space-2 | 0.5rem | padding-inline, max-inline-size, gap, padding-block, padding-inline-start |
--slean-control-border | var(--slean-border-strong) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-control-bg | var(--slean-surface) | background, background-color |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-leading | 1.5 | line-height |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-control-border-hover | var(--slean-accent) | border-color |
--slean-control-border-focus | var(--slean-accent) | border-color |
--slean-control-ring | 0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent) | box-shadow |
--slean-danger | oklch(55% 0.2 25) | border-color |
--slean-control-ring-danger | 0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent) | box-shadow |
--slean-warning | oklch(76% 0.16 80) | border-color |
--slean-control-ring-warning | 0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent) | box-shadow |
--slean-control-border-disabled | var(--slean-border) | border-color |
--slean-control-bg-disabled | var(--slean-muted) | background, background-color |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-control-height-sm | 2rem | min-block-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-control-height-lg | 2.75rem | min-block-size |
--slean-space-4 | 1rem | padding-inline, padding-block, padding-inline-start |
--slean-text-md | 1rem | font-size |
--slean-control-icon | var(--slean-fg-muted) | color, background-image, background |
--slean-icon-chevron-down | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-duration-normal | 160ms | transition |
--slean-icon-clear | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='m'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M6 6l4 4m0-4l-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='6.5' mask='url(%23m)'/%3E%3C/svg%3E") | mask-image |
--slean-border | var(--slean-neutral-6) | border |
--slean-popup-radius | var(--slean-radius-md) | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-popup-shadow | var(--slean-shadow-lg) | box-shadow |
--slean-icon-check | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-option-active | var(--slean-muted) | background |
--slean-option-selected | var(--slean-accent-soft) | background |
--slean-option-selected-fg | var(--slean-accent-soft-fg) | color, box-shadow |
--slean-font-weight-semibold | 600 | font-weight |
--slean-text-xs | 0.75rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
--slean-accent | oklch(54% 0.19 258) | color |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-width | 2px | outline-offset |
--slean-muted | var(--slean-neutral-3) | background |
--slean-icon-close | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-muted-hover | var(--slean-neutral-4) | background |
State selectors the stylesheet targets, all from the platform or ARIA: ::checkmark, ::picker(select), ::picker-icon, ::placeholder, :checked, :dir(rtl), :disabled, :empty, :focus-visible, :focus-within, :hover, :open, :placeholder-shown, :popover-open, :user-invalid, [aria-disabled="true"], [aria-expanded="true"], [aria-invalid="true"], [hidden], [multiple], [popover], [role="listbox"], [role="option"], [size].
Variant attributes: data-status (error, warning); data-size (sm, lg); data-slean-active; data-slean-selected.
Compatibility notes
Popover is Baseline 2024; CSS anchor positioning (Chrome 125, Safari 26) places the popup under the field, and without it the popover popup is centered in the top layer. :has() and @media (scripting) are Baseline 2023. The owned field is drawn by the stylesheet, so it looks the same in Chromium, WebKit and Gecko; the customizable select only affects the Tier 0 select.
Examples
Groups
Each <optgroup label> of the native select becomes a role="group" list with a label in the popup. A select without a placeholder option opens on its chosen option.
<label id="office-label" for="office-native">Office</label>
<div id="office" data-slean="select">
<select id="office-native" name="office" data-slean-part="native">
<optgroup label="Europe">
<option value="berlin">Berlin</option>
<option value="istanbul" selected>Istanbul</option>
<option value="lisbon">Lisbon</option>
</optgroup>
<optgroup label="Asia">
<option value="seoul">Seoul</option>
<option value="tokyo">Tokyo</option>
</optgroup>
</select>
<button type="button" role="combobox" aria-haspopup="listbox" aria-expanded="false"
aria-controls="office-list" aria-labelledby="office-label" data-slean-part="trigger">
<span data-slean-part="value">Istanbul</span>
</button>
<span data-slean-part="toggle" aria-hidden="true"></span>
<div popover="manual" data-slean-part="popup">
<!-- The behavior renders one role="group" with a group-label per <optgroup>. -->
<ul id="office-list" role="listbox" aria-labelledby="office-label"></ul>
</div>
</div>Multiple
A <select multiple> shows the chosen options as tags inside the field. The
popup stays open while options are toggled; a tag’s remove button drops that option and
Backspace drops the last one. The trigger contains buttons, so it is a focusable <div role="combobox">. Here the application renders the tags from its bound
value (data-slean-render="app").
The bound value: design, research
<script lang="ts">
const TEAMS = [
{ value: 'design', label: 'Design' },
{ value: 'engineering', label: 'Engineering' },
{ value: 'research', label: 'Research' },
{ value: 'support', label: 'Support' },
{ value: 'sales', label: 'Sales' }
];
let teams = $state(['design', 'research']);
const label = (value: string) => TEAMS.find((t) => t.value === value)?.label ?? '';
</script>
<label id="teams-label" for="teams-native">Teams</label>
<!-- data-slean-render="app": the application renders the tags; the behavior never writes them. -->
<div id="teams" data-slean="select" data-slean-render="app">
<select id="teams-native" name="teams" multiple data-slean-part="native" bind:value={teams}>
{#each TEAMS as t (t.value)}
<option value={t.value}>{t.label}</option>
{/each}
</select>
<!-- The trigger holds buttons, so it is a focusable div, not a <button>. -->
<div role="combobox" tabindex="0" aria-haspopup="listbox" aria-expanded="false"
aria-controls="teams-list" aria-labelledby="teams-label" data-slean-part="trigger">
<span data-slean-part="value"
>{#each teams as t (t)}<span data-slean-part="tag" data-slean-value={t}
>{label(t)}<button type="button" tabindex="-1" aria-label="Remove {label(t)}"
data-slean-part="remove"></button></span
>{/each}</span
>
<span data-slean-part="placeholder">Select teams</span>
</div>
<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
<span data-slean-part="toggle" aria-hidden="true"></span>
<div popover="manual" data-slean-part="popup">
<ul id="teams-list" role="listbox" aria-labelledby="teams-label"></ul>
</div>
</div>Sizes
data-size="sm" and data-size="lg" on the root change the height, the
padding and, for the large field, the text size; they are the heights of every field of control.css.
<div id="size-sm" data-slean="select" data-size="sm">…</div>
<div id="size-md" data-slean="select">…</div>
<div id="size-lg" data-slean="select" data-size="lg">…</div>Disabled and status
disabled on the native select and on the trigger disables the whole field. An error
is data-status="error" on the root or aria-invalid="true" on the
trigger, with the message related through aria-describedby; data-status="warning" is the warning border.
Choose a region.
<!-- Disabled: disabled on the native select and on the trigger. -->
<div id="plan" data-slean="select">
<select id="plan-native" name="plan" data-slean-part="native" disabled>…</select>
<button type="button" role="combobox" … data-slean-part="trigger" disabled>…</button>
…
</div>
<!-- Error: data-status="error" on the root (or aria-invalid="true" on the trigger), with the
message related through aria-describedby. -->
<div id="region" data-slean="select" data-status="error">
…
<button type="button" role="combobox" … aria-invalid="true"
aria-describedby="region-error" data-slean-part="trigger">…</button>
…
</div>
<p id="region-error">Choose a region.</p>Styled native select
A bare <select data-slean="select"> is the Tier 0 path: no behavior is
registered for it and select.css styles the closed box, with the customizable
select (appearance: base-select) drawing the picker where the browser has it. It is
also how the native part of an owned select looks without JavaScript. Use it where the
platform’s picker is wanted, for example in a dense toolbar.
<!-- The Tier 0 styled native select: no behavior, no JavaScript. -->
<label for="size">Font size</label>
<select id="size" name="size" data-slean="select" data-size="sm">
<option>12</option>
<option selected>14</option>
<option>16</option>
</select>Testing
packages/primitives/src/select/contract.mdthe test cases of the contract: tests/select.test.ts (happy-dom) and a Playwright spec on the playground’s /primitives/select pageapps/playground/src/routes/primitives/select/+page.sveltethe playground fixtures: single with a placeholder, groups, multiple with tags, small and disabled, the Tier 0 native selectapps/playground/tests/primitives/nested.spec.tsa select inside a modal dialog: the popup above the dialog, a click on an option, Escape closing the popup before the dialogapps/playground/tests/stress/memory.spec.ts200 open/close cycles: heap, DOM nodes and listeners back at baseline, one scoped listener per openfixtures/select-onlyconsumer build: only the select registration, lazyState and the popup session ship; tabs, menu, listbox, combobox and the calendar are absentapps/playground/tests/primitives/forms.spec.tsPlaywright: keyboard, form values, :user-invalid, names and descriptions, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)apps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/select/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/select/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/select/behavior.tsthe behavior definition, openSelect(), closeSelect(), chooseOption(), clearSelect(), renderSelectOptions()packages/primitives/src/select/register.tsthe registration module, the only side effectpackages/primitives/src/select/validate.tsdevelopment validation messagespackages/styles/css/control.cssthe control shell shared with the combobox and the date pickerpackages/styles/css/select.cssthe optional stylesheet