# Listbox

> Tier 1 selection widget: single and multi-select, automatic and manual modes, typeahead, orientation, RTL, keyboard, ARIA relationships and the slean:change event.

Page: https://svelte-lean.vercel.app/primitives/listbox · Markdown: https://svelte-lean.vercel.app/primitives/listbox.md · Index: https://svelte-lean.vercel.app/llms.txt

A visible list to pick one or several options from, where a native select falls short: an always-open list, multi-select, rich options.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the behavior registers itself for data-slean="listbox".
	import '@svelte-lean/styles/listbox.css';
	import type { ListboxChangeDetail } from '@svelte-lean/primitives/listbox';
	import { on } from 'svelte/events';

	const fruits = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
	let fruit = $state('Apple');

	function onFruit(event: Event) {
		fruit = String((event as CustomEvent<ListboxChangeDetail>).detail.value);
	}
</script>

<ul
	role="listbox"
	aria-label="Fruit"
	data-slean="listbox"
	class="fruit"
	{@attach (node) => on(node, 'slean:change', onFruit)}
>
	{#each fruits as option (option)}
		<li
			role="option"
			aria-selected={option === fruit}
			tabindex={option === fruit ? 0 : -1}
			data-slean-value={option}
		>
			{option}
		</li>
	{/each}
</ul>
<p>Selected: {fruit}</p>

<style>
	.fruit {
		max-inline-size: 16rem;
	}
</style>
```

Tab enters on the selected option; the arrows select as they move, Home and End jump, a letter searches.

## Examples

### Multi-select

```svelte
<script lang="ts">
	import '@svelte-lean/styles/listbox.css';
	import type { ListboxChangeDetail } from '@svelte-lean/primitives/listbox';
	import { on } from 'svelte/events';

	const toppings = ['Cheese', 'Olives', 'Peppers', 'Mushrooms', 'Basil'];
	let chosen = $state(['Cheese', 'Basil']);

	function onToppings(event: Event) {
		const { value } = (event as CustomEvent<ListboxChangeDetail>).detail;
		chosen = Array.isArray(value) ? value : [];
	}
</script>

<ul
	role="listbox"
	aria-label="Toppings"
	aria-multiselectable="true"
	data-slean="listbox"
	data-slean-multiple
	class="toppings"
	{@attach (node) => on(node, 'slean:change', onToppings)}
>
	{#each toppings as option, i (option)}
		<li
			role="option"
			aria-selected={chosen.includes(option)}
			tabindex={i === 0 ? 0 : -1}
			data-slean-value={option}
		>
			{option}
		</li>
	{/each}
</ul>
<p>{chosen.length} selected: {chosen.join(', ') || 'none'}</p>

<style>
	.toppings {
		max-inline-size: 16rem;
	}
</style>
```

Arrows move focus only; Space, Enter or a click toggles, Shift+Arrow extends, Ctrl+A (Cmd+A) selects every enabled option.

### Multi-select in a popover

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/popover.css';
	import '@svelte-lean/styles/listbox.css';
	import type { ListboxChangeDetail } from '@svelte-lean/primitives/listbox';
	import { on } from 'svelte/events';

	const statuses = ['Open', 'In review', 'Done', 'Archived'];
	let filter = $state(['Open', 'In review']);
	const summary = $derived(
		filter.length === 0 ? 'Any' : filter.length === 1 ? filter[0] : `${filter.length} selected`
	);

	function onStatus(event: Event) {
		const { value } = (event as CustomEvent<ListboxChangeDetail>).detail;
		filter = Array.isArray(value) ? value : [];
	}
</script>

<button
	type="button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	popovertarget="status-filter"
	aria-haspopup="listbox"
>
	Status: {summary}
</button>
<div
	id="status-filter"
	popover
	data-slean="popover"
	data-slean-align="start"
	class="filter"
>
	<ul
		role="listbox"
		aria-label="Status"
		aria-multiselectable="true"
		data-slean="listbox"
		data-slean-multiple
		{@attach (node) => on(node, 'slean:change', onStatus)}
	>
		{#each statuses as option, i (option)}
			<li
				role="option"
				aria-selected={filter.includes(option)}
				tabindex={i === 0 ? 0 : -1}
				data-slean-value={option}
			>
				{option}
			</li>
		{/each}
	</ul>
</div>

<style>
	/* One surface: the popover's, with the list inside it unframed. */
	.filter {
		padding: 0.25rem;
	}

	.filter [role='listbox'] {
		border: 0;
		padding: 0;
	}
</style>
```

The popover owns showing, light dismiss and focus return; the listbox owns the keys; the button label follows slean:change.

### Rich options

```svelte
<script lang="ts">
	import '@svelte-lean/styles/avatar.css';
	import '@svelte-lean/styles/listbox.css';
	import type { ListboxChangeDetail } from '@svelte-lean/primitives/listbox';
	import { on } from 'svelte/events';

	const people = [
		{ id: 'ada', name: 'Ada Okafor', team: 'Design', initials: 'AO' },
		{ id: 'lin', name: 'Lin Chen', team: 'Engineering', initials: 'LC' },
		{ id: 'sam', name: 'Sam Rivera', team: 'Away until Monday', initials: 'SR', away: true },
		{ id: 'noor', name: 'Noor Haddad', team: 'Support', initials: 'NH' }
	];
	let assignee = $state('lin');

	function onAssignee(event: Event) {
		assignee = String((event as CustomEvent<ListboxChangeDetail>).detail.value);
	}
</script>

<ul
	role="listbox"
	aria-label="Assignee"
	data-slean="listbox"
	class="people"
	{@attach (node) => on(node, 'slean:change', onAssignee)}
>
	{#each people as person (person.id)}
		<li
			role="option"
			aria-selected={person.id === assignee}
			aria-disabled={person.away || undefined}
			tabindex={person.id === assignee ? 0 : -1}
			data-slean-value={person.id}
		>
			<span data-slean="avatar" data-size="small">
				<span data-slean-part="fallback" aria-hidden="true">{person.initials}</span>
			</span>
			<span class="person">
				{person.name}
				<small>{person.team}</small>
			</span>
		</li>
	{/each}
</ul>

<style>
	.people {
		max-inline-size: 18rem;
	}

	.person {
		display: grid;
	}
</style>
```

Options hold any content. An aria-disabled option is skipped by the keys and ignores clicks.

### Horizontal

```svelte
<script lang="ts">
	import '@svelte-lean/styles/listbox.css';
	import type { ListboxChangeDetail } from '@svelte-lean/primitives/listbox';
	import { on } from 'svelte/events';

	const sizes = ['XS', 'S', 'M', 'L', 'XL'];
	let size = $state('M');

	function onSize(event: Event) {
		size = String((event as CustomEvent<ListboxChangeDetail>).detail.value);
	}
</script>

<!-- A row: ArrowRight and ArrowLeft (swapped under 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"
	class="sizes"
	{@attach (node) => on(node, 'slean:change', onSize)}
>
	{#each sizes as option (option)}
		<li
			role="option"
			aria-selected={option === size}
			tabindex={option === size ? 0 : -1}
			data-slean-value={option}
		>
			{option}
		</li>
	{/each}
</ul>

<style>
	.sizes {
		inline-size: fit-content;
	}
</style>
```

### Groups

```svelte
<script lang="ts">
	import '@svelte-lean/styles/listbox.css';

	const regions = [
		{ id: 'europe', name: 'Europe', zones: ['Berlin', 'Istanbul', 'Lisbon', 'London', 'Paris'] },
		{ id: 'americas', name: 'Americas', zones: ['Chicago', 'Mexico City', 'New York', 'São Paulo'] },
		{ id: 'asia', name: 'Asia', zones: ['Dubai', 'Seoul', 'Singapore', 'Tokyo'] }
	];
</script>

<!-- Manual selection: the arrows move focus, Space or Enter selects. The root wraps a list
     that scrolls. -->
<div data-slean="listbox" data-slean-selection="manual" class="zones">
	<div role="listbox" aria-label="Time zone" data-slean-part="list">
		{#each regions as region (region.id)}
			<div role="group" aria-labelledby="region-{region.id}">
				<div id="region-{region.id}" data-slean-part="group-label">{region.name}</div>
				{#each region.zones as zone (zone)}
					<div
						role="option"
						aria-selected={zone === 'Istanbul'}
						tabindex={zone === 'Istanbul' ? 0 : -1}
						data-slean-value={zone}
					>
						{zone}
					</div>
				{/each}
			</div>
		{/each}
	</div>
</div>

<style>
	.zones {
		max-inline-size: 16rem;
	}

	.zones [role='listbox'] {
		max-block-size: 14rem;
	}
</style>
```

Group labels come from role="group" and aria-labelledby; typing a letter jumps to the next match.

### Form value

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/listbox.css';

	const plans = ['Free', 'Team', 'Business'];
	let sent = $state('');

	function submit(event: SubmitEvent) {
		event.preventDefault();
		sent = String(new FormData(event.currentTarget as HTMLFormElement).get('plan'));
	}
</script>

<!-- The native part carries the value into the form; Reset puts it back and the list follows. -->
<form class="plans" onsubmit={submit} onreset={() => (sent = '')}>
	<div data-slean="listbox">
		<input hidden name="plan" value="Team" data-slean-part="native" />
		<ul role="listbox" aria-label="Plan" data-slean-part="list">
			{#each plans as plan (plan)}
				<li
					role="option"
					aria-selected={plan === 'Team'}
					tabindex={plan === 'Team' ? 0 : -1}
					data-slean-value={plan}
				>
					{plan}
				</li>
			{/each}
		</ul>
	</div>
	<div class="actions">
		<button type="submit" data-slean="button">Submit</button>
		<button type="reset" data-slean="button" data-variant="outlined" data-color="neutral">
			Reset
		</button>
	</div>
	{#if sent}<p role="status">Submitted: plan={sent}</p>{/if}
</form>

<style>
	.plans {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		max-inline-size: 16rem;
	}

	.plans [data-slean='listbox'] {
		inline-size: 100%;
	}

	.actions {
		display: flex;
		gap: var(--slean-space-2);
	}
</style>
```

A hidden input part carries the value: select a plan and submit, then Reset.

### Colors

```svelte
<script lang="ts">
	import '@svelte-lean/styles/listbox.css';

	const colors = ['success', 'warning', 'error'];
	const levels = ['Low', 'Medium', 'High'];
</script>

<!-- data-color picks the selected fill and the check mark. -->
<div class="colors">
	{#each colors as color (color)}
		<ul role="listbox" aria-label="Level ({color})" data-slean="listbox" data-color={color}>
			{#each levels as level (level)}
				<li
					role="option"
					aria-selected={level === 'Medium'}
					tabindex={level === 'Medium' ? 0 : -1}
					data-slean-value={level}
				>
					{level}
				</li>
			{/each}
		</ul>
	{/each}
</div>

<style>
	.colors {
		display: flex;
		flex-wrap: wrap;
		gap: var(--slean-space-3);
	}
</style>
```

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="listbox"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/listbox/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/listbox.css';
```

Render the initial state completely: `aria-selected` on every option and exactly one `tabindex="0"`. Listen to `slean:change` on the root to follow the selection; it fires before `aria-selected` is written and can be cancelled.

The options are one attribute each on the root: `data-slean-multiple`, `data-slean-selection="manual"`, `data-slean-orientation="horizontal"` and `data-slean-loop="false"`. For a form value and `bind:value`, put an `<input hidden name data-slean-part="native">` beside the list in a wrapper root: the behavior writes it (comma-separated when multiple) and follows `setValue()` and a form reset.

`options`

```html
<!-- 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". |
| group | `<li role="group" aria-label="…">` | – | no | A section of options. |
| group-label | `<span>` | `group-label` | styles only | A group heading. |
| native | `<input hidden name="…">` | `native` | no | In a wrapper root, outside the listbox: carries the value into forms and bind:value (comma-separated when multiple); setValue() and a form reset are followed. |

## 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 |

## Styling

`listbox.css` styles the list and its rows, the selected, hover and disabled states (the selected fill and the check mark follow `data-color`), a group label, the horizontal row and the multi-select check mark. Options are focused directly, so the file draws its own inset focus ring for `:focus-visible`. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding, gap, padding-block |
| `--slean-border` | `var(--slean-neutral-6)` | border |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline, padding-inline-start, inset-inline-start |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | 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-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color, box-shadow |
| `--slean-color-main` | `var(--slean-primary-main)` | 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

`slean:change` carries `value` and `previous`: in a single-select root the selected `data-slean-value` and the one before (or null), in a multi-select root arrays in document order. `selectOptions()` writes a selection from script; `listboxOptions()` and `listboxValue()` read the DOM.

`toppings.svelte`

```svelte
<script lang="ts">
	import {
		listboxOptions,
		listboxValue,
		selectOptions
	} from '@svelte-lean/primitives/listbox';

	let root: HTMLElement;
	let selected = $state<string[]>(['cheese']);

	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));
		selected = listboxValue(root);
	}
</script>

<div id="toppings" data-slean="listbox" data-slean-multiple bind:this={root}>
	…
</div>
<button type="button" onclick={clear}>Clear</button>
<button type="button" onclick={selectAll}>Select all</button>
```

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2357 B brotli · 2616 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="listbox"`, `role="option"`, `aria-selected`, `roving tabindex`, `:dir(rtl)`
- **Shared listeners**: click, keydown, change, reset
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `ARIA listbox markup (role=listbox, option)`

Four listeners (`click`, `keydown`, `change` and `reset`, the last two for the optional native part) serve every listbox on the page, and one typeahead buffer 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. The `listbox-only` fixture measures what a consumer build ships.

Why this implementation

For one choice inside a form the native `<select>` fits better: the browser owns the popup, the keyboard and form participation. A listbox is for what a select does not do: a list visible the whole time, several selected options with Shift and Ctrl keys, options with more than a text label.

ARIA gives the roles and states 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: 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()
- the optional native part: writing the form value, following setValue() and a form reset
- development validation of the markup
- listbox.css and the contract

Accessibility

- Root: the `role="listbox"` element with a name, or a wrapper around a `data-slean-part="list"` with that role. `aria-multiselectable="true"` goes with `data-slean-multiple` and `aria-orientation="horizontal"` with `data-slean-orientation="horizontal"`; development validation checks that they agree.
- Options are `role="option"` elements with `data-slean-value`, `aria-selected` and a `tabindex`; any element works. Ids are authored; the behavior generates none.
- A roving tab stop: after every move or click exactly one option has `tabindex="0"`, and a click focuses the option. In manual single-select and in multi-select mode, focus moves without changing the selection until Space, Enter or a click.
- `aria-disabled="true"` on the root skips routing. A disabled option is skipped by arrows, Home, End, typeahead and Ctrl+A, and its clicks, Space and Enter are ignored.
- Direction is read from the nearest `dir` attribute (`dir="auto"` through `:dir(rtl)`); under RTL with horizontal orientation, ArrowRight moves to the previous option.
- When a `slean:change` listener cancels, the selection stays; focus and the tab stop still move.

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 native part submits the server-rendered value; a form whose value must change without script uses a native `<select>` or checkboxes.

Server rendering and hydration

The server renders the selection and the tab stop, and no ids are generated. The register module is safe to import on the server (`packages/primitives/tests/ssr.test.ts`); the playground’s SSR page renders a listbox and asserts that hydration changes nothing.

Nothing runs at hydration: the shared listeners were installed when the behavior registered. 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.

Compatibility

ARIA 1.2 roles, ES2022 output and `composedPath()`; `:dir(rtl)` only resolves `dir="auto"`, with a guard where the pseudo-class is missing. No virtualization, drag selection, or Shift+click and Ctrl+click ranges. Tested in Chromium; Firefox and WebKit runs do not exist yet.

Tests and source

- `packages/primitives/tests/listbox.test.ts` unit tests (happy-dom): click, arrows, Home/End, typeahead, selection modes, multi-select, disabled, RTL, nesting, cancellation, 1000 roots
- `apps/playground/tests/primitives/listbox.spec.ts` Playwright: real focus movement, :focus-visible, typeahead timing, multi-select keys, RTL, slean:change, 1000 roots
- `apps/playground/tests/primitives/nested.spec.ts` a listbox inside a tab panel
- `fixtures/listbox-only` consumer build: only the listbox registration ships; tabs, menu, select and autocomplete are absent
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states

- `packages/primitives/src/listbox/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/listbox/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/listbox/behavior.ts` the behavior definition, selectOptions(), listboxOptions(), listboxValue()
- `packages/primitives/src/listbox/register.ts` the registration module, the only side effect
- `packages/primitives/src/listbox/validate.ts` development validation messages
- `packages/styles/css/listbox.css` the optional stylesheet
