Primitives Forms
Listbox
A visible list of options the user selects with the keyboard or a pointer, for the cases a native select does not cover: a list that stays open, multi-select with keyboard extension, rich option content. The DOM is the state (aria-selected and a roving tabindex); the behavior adds arrow keys, Home and End, typeahead, selection modes and multi-select keys through shared click and keydown listeners. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1838 B brotli · 2030 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
role="listbox",role="option",aria-selected,roving tabindex,:dir(rtl)- Shared listeners
- click, keydown
- Per-instance listeners
- none
- Lazy state
- none
- Native base
ARIA listbox markup (role=listbox, option)
On this page
Example
- Apple
- Banana
- Cherry
- Date
<ul id="fruit" role="listbox" aria-label="Fruit" data-slean="listbox">
<li id="fruit-apple" role="option" aria-selected="true" tabindex="0" data-slean-value="apple">
Apple
</li>
<li id="fruit-banana" role="option" aria-selected="false" tabindex="-1" data-slean-value="banana">
Banana
</li>
<li
id="fruit-cherry"
role="option"
aria-selected="false"
aria-disabled="true"
tabindex="-1"
data-slean-value="cherry"
>
Cherry
</li>
<li id="fruit-date" role="option" aria-selected="false" tabindex="-1" data-slean-value="date">
Date
</li>
</ul><script lang="ts">
// With @svelte-lean/vite this import is injected for the static data-slean="listbox" marker.
// Without the plugin, write it yourself once; both paths register the same behavior.
import '@svelte-lean/primitives/listbox/register';
import '@svelte-lean/styles/listbox.css';
</script>
<ul id="fruit" role="listbox" aria-label="Fruit" data-slean="listbox">
<li id="fruit-apple" role="option" aria-selected="true" tabindex="0" data-slean-value="apple">
Apple
</li>
<li id="fruit-banana" role="option" aria-selected="false" tabindex="-1" data-slean-value="banana">
Banana
</li>
<li
id="fruit-cherry"
role="option"
aria-selected="false"
aria-disabled="true"
tabindex="-1"
data-slean-value="cherry"
>
Cherry
</li>
<li id="fruit-date" role="option" aria-selected="false" tabindex="-1" data-slean-value="date">
Date
</li>
</ul>- Cheese
- Olives
- Peppers
<div id="toppings" data-slean="listbox" data-slean-multiple>
<ul role="listbox" aria-label="Toppings" aria-multiselectable="true" data-slean-part="list">
<li id="toppings-cheese" role="option" aria-selected="true" tabindex="0" data-slean-value="cheese">
Cheese
</li>
<li id="toppings-olives" role="option" aria-selected="false" tabindex="-1" data-slean-value="olives">
Olives
</li>
<li id="toppings-peppers" role="option" aria-selected="false" tabindex="-1" data-slean-value="peppers">
Peppers
</li>
</ul>
</div><script lang="ts">
import '@svelte-lean/primitives/listbox/register';
import '@svelte-lean/styles/listbox.css';
</script>
<div id="toppings" data-slean="listbox" data-slean-multiple>
<ul role="listbox" aria-label="Toppings" aria-multiselectable="true" data-slean-part="list">
<li id="toppings-cheese" role="option" aria-selected="true" tabindex="0" data-slean-value="cheese">
Cheese
</li>
<li id="toppings-olives" role="option" aria-selected="false" tabindex="-1" data-slean-value="olives">
Olives
</li>
<li id="toppings-peppers" role="option" aria-selected="false" tabindex="-1" data-slean-value="peppers">
Peppers
</li>
</ul>
</div>Last slean:change on the examples: No change yet.
Multi-select in a popover
A multi-select dropdown is a composition: a button with popovertarget and a popover
holding a multi-select listbox. The popover owns showing, light dismiss, Escape and focus
return; the listbox owns the keys and the selection; the button's label follows slean:change.
- Open
- In review
- Done
- Archived
Why this implementation exists
For one choice inside a form the recommended primitive is the native <select>: the browser owns the popup, the keyboard and form participation,
and the package owns nothing. A listbox is for what a select does not do: a list that is visible
the whole time, several selected options with Shift and Ctrl keys, options with more than a text
label. ARIA gives the roles and the states for that, and the browser gives focus and the tab
order; what is missing is the movement between options, the roving tab stop, typeahead and the
selection rules of the APG pattern. Svelte Lean adds only that, as one behavior shared by every
listbox on the page. The selection is never mirrored in JavaScript: every event reads aria-selected from the DOM and writes it back, so a listbox the application re-renders
cannot disagree with the behavior.
The browser owns
- the tab order: Tab enters the list on the option with tabindex="0"
- the listbox and option roles, aria-selected and aria-multiselectable announcements
- the click and keydown events routed to the behavior
- scrolling the focused option into view
Svelte Lean owns
- selection on click, Space and Enter; arrow keys, Home and End; typeahead; automatic and manual modes
- multi-select toggling, Shift+Arrow extension and Ctrl+A
- writing aria-selected and the roving tabindex
- the cancelable slean:change event and selectOptions(), listboxOptions(), listboxValue()
- development validation of the markup
- listbox.css and the contract
Usage
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesnpm install --save-dev @svelte-lean/vitepnpm add -D @svelte-lean/viteyarn add -D @svelte-lean/vitebun add -d @svelte-lean/vitenpm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesWith the Vite plugin, every static data-slean="listbox" gets import '@svelte-lean/primitives/listbox/register' appended to its compiled module; without
the plugin, write that import yourself once. The stylesheet stands alone: it draws the list, the rows,
the selected state, the multi-select mark and its own focus ring.
// Any module that runs once on the client, for example the root layout. Safe on the server,
// idempotent, and the same module the plugin injects.
import '@svelte-lean/primitives/listbox/register';import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/listbox.css';The author renders the initial state completely: aria-selected on every option and
exactly one tabindex="0". The options are one attribute each on the root:
<!-- Arrow keys move focus only; Space, Enter or a click select: -->
<ul role="listbox" aria-label="Fruit" data-slean="listbox" data-slean-selection="manual">
<!-- A row of options, ArrowRight and ArrowLeft (swapped under dir="rtl"), no wrap at the ends: -->
<ul
role="listbox"
aria-label="Size"
aria-orientation="horizontal"
data-slean="listbox"
data-slean-orientation="horizontal"
data-slean-loop="false"
>Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <ul role="listbox" aria-label="…" data-slean="listbox"> or a wrapper element | – | yes | The listbox element itself, or an element around the list part. Options: data-slean-multiple, data-slean-selection, data-slean-orientation, data-slean-loop. |
| list | <ul role="listbox" aria-label="…"> | list | no | Only when the root is a wrapper; exactly one. Set aria-multiselectable="true" with the multiple option and aria-orientation="horizontal" with the orientation option. |
| option | <li role="option" aria-selected="…" tabindex="0 or -1" data-slean-value="…"> | – | yes | Found by role, no part attribute. Exactly one option has tabindex="0"; disabled options carry aria-disabled="true" or data-slean-disabled. |
| group | <li role="group" aria-label="…"> | – | no | A section of options. |
| label | <span> | label | styles only | A group heading. |
Runtime profile
The bytes in the runtime block are the production build of @svelte-lean/primitives/listbox/register including the shared kernel, from packages/primitives/artifacts/size.json (1838 B brotli · 2030 B gzip). A consumer sees the
same bytes: the listbox-only fixture builds to one Svelte Lean chunk of 1838 B brotli, against 1521 B brotli for tabs alone. The two listeners
(click and keydown) serve every listbox on the page; one typeahead
buffer exists for the whole page and is created on first use. There is no per-root state, no WeakMap, no observer and no layout read; the unit test asserts that the behavior
source contains none of them (no WeakMap, WeakSet or lazyState, no observer constructor, no getBoundingClientRect, offsetWidth, offsetHeight or getComputedStyle), and the proof page shows the listener account of this site.
Accessibility contract
- Root: the
role="listbox"element with a name (aria-labeloraria-labelledby), or a wrapper around adata-slean-part="list"with that role.aria-multiselectable="true"goes withdata-slean-multiple,aria-orientation="horizontal"withdata-slean-orientation="horizontal"; development validation checks that they agree. - Options are
role="option"elements withdata-slean-value,aria-selectedand atabindex; any element works, and a<button>is not needed. Ids are authored; the behavior generates none. - Focus: a roving tab stop. After every move or click exactly one option has
tabindex="0"; a click focuses the option: the behavior callsfocus()on it instead of relying on the browser's mouse-focus rules, which differ by element and browser and are not measured here. In manual single-select mode and in multi-select mode, focus moves without changing the selection until Space, Enter or a click. - Disabled:
data-slean-disabledoraria-disabled="true"on the root skips routing. An option witharia-disabled="true"ordata-slean-disabledis skipped by arrows, Home, End, typeahead and Ctrl+A, and its clicks, Space and Enter are ignored; a disabled option the author rendered as selected keeps its state. - RTL: direction is read from the nearest
dirattribute;dir="auto"is resolved through:dir(rtl). Under RTL with horizontal orientation, ArrowRight moves to the previous option, the one on the right. - When a
slean:changelistener cancels, the selection stays; keyboard focus and the tab stop still move to the requested option.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab/Shift+Tab | anywhere | Enters the list on the tabindex="0" option (native) |
| ArrowDown/ArrowUp | focus on an option, vertical | Next or previous enabled option, wrapping unless data-slean-loop="false"; selects it in automatic single-select mode, moves focus only otherwise |
| ArrowRight/ArrowLeft | focus on an option, horizontal | As above; swapped under dir="rtl" |
| Home/End | focus on an option | First or last enabled option; selects it in automatic single-select mode |
| a character | focus on an option | Typeahead over option text; a repeated character cycles; selects in automatic single-select mode |
| Space/Enter | focus on an option | Selects it (single-select, the way to select in manual mode) or toggles it (multi-select) |
| Shift+ArrowDown/Shift+ArrowUp | focus on an option, multi-select | Moves and adds the focused option and the target to the selection |
| Ctrl+A/Cmd+A | focus on an option, multi-select | Selects every enabled option |
Keys with Alt, Ctrl or Meta are otherwise ignored so browser shortcuts keep working; handled
keys call preventDefault(). Shift with Home, End or a character moves focus only.
The APG's optional Shift+Space, Ctrl+Shift+Home, Ctrl+Shift+End and Shift+click ranges are not
implemented; the contract says so.
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
ARIA listbox roles and states (listbox, option, group, aria-selected, aria-multiselectable, aria-orientation, aria-disabled) | ARIA 1.2, widely supported by assistive technology | Not applicable within the support policy |
Shared router: composedPath(), getAttribute, ES2022 output | Widely available | No transpilation to an older target is provided |
:dir(rtl), only to resolve dir="auto" | Newly available since December 2023 (Chrome 120, Safari 16.4, Firefox 49) | Guarded in packages/primitives/src/internal/direction.ts: dir="auto" resolves to left-to-right; explicit dir attributes work everywhere |
Without JavaScript
The authored markup stays: the selected options are marked and readable, and nothing can be
changed. A form that must work without script uses a native <select> or checkboxes;
the listbox submits no value by itself, with or without script.
Server rendering
Everything is static markup; the server renders the selection and the tab stop, and no ids are
generated. The register module is safe to import on the server: without a document the registration waits until a browser runtime exists (packages/primitives/tests/ssr.test.ts). The playground's SSR page renders a listbox and asserts that hydration changes nothing.
Before hydration
Nothing runs at hydration: the shared listeners were installed when the behavior registered, and the first interaction is the first work done for a root. Before the register module has loaded, a click on an option changes nothing, as the playground's delayed-hydration test asserts; after it, the same click selects.
Styling
listbox.css styles the list and its rows, the selected state, the hover state, the
disabled state, a group heading, and the multi-select mark drawn with borders. Options are
focused directly, so the file draws its own inset focus ring for :focus-visible;
the shared ring in base.css covers only parts. The tokens it reads and the states it
targets:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | padding, gap, padding-block |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-space-2 | 0.5rem | gap, padding-inline, padding-inline-start, inset-inline-start |
--slean-control-height-sm | 2rem | min-block-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-text-sm | 0.875rem | font-size |
--slean-muted | var(--slean-neutral-3) | background |
--slean-option-active | var(--slean-muted) | background |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-width | 2px | outline-offset |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color, box-shadow |
--slean-accent | oklch(54% 0.19 258) | background |
--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-fg-muted | var(--slean-neutral-11) | color |
--slean-text-xs | 0.75rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
State selectors the stylesheet targets, all from the platform or ARIA: :focus-visible, :hover, [aria-disabled="true"], [aria-selected="true"], [role="group"], [role="listbox"], [role="option"].
Variant attributes: data-slean-orientation (horizontal); data-slean-multiple.
Controlled integration
What exists today: the root dispatches a bubbling, cancelable slean:change CustomEvent before the selection is written, with detail of type ListboxChangeDetail: in a single-select root value is the selected data-slean-value and previous the one before or null; in a
multi-select root both are arrays in document order. preventDefault() cancels the
change. selectOptions() writes a selection from script, listboxOptions() and listboxValue() read the DOM.
<script lang="ts">
import {
listboxValue,
selectOptions,
listboxOptions,
type ListboxChangeDetail
} from '@svelte-lean/primitives/listbox';
let root: HTMLElement;
let selected = $state<string[]>(['cheese']);
$effect(() => {
// slean:change bubbles from the root before aria-selected is written; in a multi-select
// root value and previous are arrays in document order. preventDefault() cancels.
const onChange = (event: Event) => {
const { value } = (event as CustomEvent<ListboxChangeDetail>).detail;
selected = Array.isArray(value) ? value : [value];
};
root.addEventListener('slean:change', onChange);
return () => root.removeEventListener('slean:change', onChange);
});
function clear() {
// Pass every option that should stay selected; returns false when a listener cancelled.
selectOptions(root, []);
selected = listboxValue(root);
}
function selectAll() {
selectOptions(root, listboxOptions(root));
}
</script>
<div id="toppings" data-slean="listbox" data-slean-multiple bind:this={root}>
…
</div>
<p>Selected: {selected.join(', ') || 'nothing'}</p>
<button type="button" onclick={clear}>Clear</button>
<button type="button" onclick={selectAll}>Select all</button>Note A Svelte adapter is not built. No hidden form field, no virtualization, no drag selection and no Shift+click or Ctrl+click ranges exist; each would be documented in the contract before it is built.
Compatibility notes
The listbox depends on ARIA 1.2 roles, ES2022 output and composedPath(), and reads :dir(rtl) only to resolve dir="auto", with a guard where the
pseudo-class is missing. Tested in Chromium; Firefox and WebKit runs do not exist yet. The
policy is on Browser support.
Testing
packages/primitives/tests/listbox.test.tsunit tests (happy-dom): click, arrows, Home/End, typeahead, selection modes, multi-select, disabled, RTL, nesting, cancellation, 1000 rootsapps/playground/tests/primitives/listbox.spec.tsPlaywright: real focus movement, :focus-visible, typeahead timing, multi-select keys, RTL, slean:change, 1000 rootsapps/playground/tests/primitives/nested.spec.tsa listbox inside a tab panelfixtures/listbox-onlyconsumer build: only the listbox registration ships; tabs, menu and combobox are absentapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/listbox/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/listbox/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/listbox/behavior.tsthe behavior definition, selectOptions(), listboxOptions(), listboxValue()packages/primitives/src/listbox/register.tsthe registration module, the only side effectpackages/primitives/src/listbox/validate.tsdevelopment validation messagespackages/styles/css/listbox.cssthe optional stylesheet