# Select

> A styled native <select>, or an owned field over it: search, multiple with tags, creatable values, groups, a clear button, the same in every browser; the native select without JavaScript.

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

Pick one or several values from a list: a styled native select, or an owned field with search, tags and creatable values over the native one.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const fruits = [
		{ value: 'apple', label: 'Apple' },
		{ value: 'banana', label: 'Banana' },
		{ value: 'cherry', label: 'Cherry', disabled: true },
		{ value: 'grape', label: 'Grape' },
		{ value: 'mango', label: 'Mango' },
		{ value: 'orange', label: 'Orange' }
	];
	const p = selectParts('select-fruit');
	let fruit = $state('');
	const label = (value: string) => fruits.find((f) => f.value === value)?.label;
</script>

<div class="field">
	<label {...p.label}>Fruit</label>
	<div id="select-fruit" data-slean="select">
		<select {...p.native} name="fruit" 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 {...p.trigger}>{label(fruit) ?? 'Select a fruit'}</button>
		<button {...p.clear}></button>
	</div>
	<p>Value: {fruit || 'none'}</p>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

Click the field, or focus it and press ArrowDown, Enter or a letter. Escape closes without a change; the clear button returns to the placeholder.

## Examples

### Search

`data-slean-search` makes the trigger a text input that filters the options; the value is still an option of the native select. The search code is its own module, added only where the attribute is used: the select alone ships 5022 B, with search 5461 B (brotli, with the kernel).

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const countries = [
		{ value: 'ar', label: 'Argentina' },
		{ value: 'br', label: 'Brazil' },
		{ value: 'ca', label: 'Canada' },
		{ value: 'de', label: 'Germany' },
		{ value: 'jp', label: 'Japan' },
		{ value: 'kr', label: 'South Korea' },
		{ value: 'nl', label: 'Netherlands' },
		{ value: 'pt', label: 'Portugal' },
		{ value: 'tr', label: 'Türkiye' },
		{ value: 'us', label: 'United States' }
	];
	const p = selectParts('select-country', { search: true });
	let country = $state('');
	const label = (value: string) => countries.find((c) => c.value === value)?.label ?? '';
</script>

<div class="field">
	<label {...p.label}>Country</label>
	<div id="select-country" data-slean="select" data-slean-search>
		<select {...p.native} name="country" bind:value={country}>
			<option value="" hidden>Select a country</option>
			{#each countries as c (c.value)}
				<option value={c.value}>{c.label}</option>
			{/each}
		</select>
		<input {...p.trigger} placeholder="Select a country" value={label(country)} />
	</div>
	<p>Value: {country || 'none'}</p>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

Type to filter; Enter takes the first match. Escape or leaving the field puts the chosen label back.

### Multiple

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const teams = [
		{ value: 'design', label: 'Design' },
		{ value: 'engineering', label: 'Engineering' },
		{ value: 'research', label: 'Research' },
		{ value: 'support', label: 'Support' },
		{ value: 'sales', label: 'Sales' }
	];
	const p = selectParts('select-teams', { multiple: true });
	let chosen = $state(['design', 'research']);
	const label = (value: string) => teams.find((t) => t.value === value)?.label ?? value;
</script>

<div class="field teams">
	<label {...p.label}>Teams</label>
	<!-- data-slean-render="app": the application renders the tags; the behavior never writes them. -->
	<div id="select-teams" data-slean="select" data-slean-render="app">
		<select {...p.native} name="teams" bind:value={chosen}>
			{#each teams as t (t.value)}
				<option value={t.value}>{t.label}</option>
			{/each}
		</select>
		<div {...p.trigger}>
			<span {...p.value}
				>{#each chosen as value (value)}<span {...p.tag(value)}
						>{label(value)}<button {...p.remove(label(value))}></button></span
					>{/each}</span
			>
			<span {...p.placeholder}>Select teams</span>
		</div>
		<button {...p.clear}></button>
	</div>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.teams [data-slean='select'] {
		inline-size: 22rem;
		max-inline-size: 100%;
	}
</style>
```

The popup stays open while options are toggled; a tag’s remove button or Backspace drops a value.

### Tags input

`multiple`, `data-slean-search` and `data-slean-create` together: Enter on text no option has adds it as a new option of the native select (5573 B with both modules).

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const known = ['bug', 'docs', 'feature', 'question'];
	const p = selectParts('select-labels', { search: true, multiple: true });
	let labels = $state(['bug']);
</script>

<div class="field teams">
	<label {...p.label}>Labels</label>
	<div
		id="select-labels"
		data-slean="select"
		data-slean-search
		data-slean-create
		data-slean-render="app"
	>
		<select {...p.native} name="labels" bind:value={labels}>
			{#each known as value (value)}
				<option {value}>{value}</option>
			{/each}
		</select>
		<span {...p.value}
			>{#each labels as value (value)}<span {...p.tag(value)}
					>{value}<button {...p.remove(value)}></button></span
				>{/each}</span
		>
		<input {...p.trigger} placeholder="Add labels" />
	</div>
	<p>Value: {labels.join(', ') || 'none'}</p>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.teams [data-slean='select'] {
		inline-size: 22rem;
		max-inline-size: 100%;
	}
</style>
```

Type a new label and press Enter: the native select gets the option, so the form submits it.

### Native select

```svelte
<script lang="ts">
	// The Tier 0 styled native select: no behavior, no JavaScript.
	import '@svelte-lean/styles/select.css';
</script>

<div class="field">
	<label for="select-native-size">Font size</label>
	<select id="select-native-size" name="size" data-slean="select">
		<option>12</option>
		<option selected>14</option>
		<option>16</option>
	</select>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}
</style>
```

Tier 0: no runtime. Where the browser supports appearance: base-select the picker is drawn from the tokens too.

### Looks

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	// data-variant is the shape (outlined is the default), data-size the height.
	const variants = ['outlined', 'filled', 'standard'];
	const sizes = ['small', 'medium', 'large'];
</script>

<div class="looks">
	{#each variants as variant (variant)}
		{#each sizes as size (size)}
			{@const p = selectParts(`select-${variant}-${size}`)}
			<div class="field">
				<label {...p.label}>{variant}, {size}</label>
				<div
					id="select-{variant}-{size}"
					data-slean="select"
					data-variant={variant}
					data-size={size}
				>
					<select {...p.native}>
						<option value="apple">Apple</option>
						<option value="mango" selected>Mango</option>
						<option value="peach">Peach</option>
					</select>
					<button {...p.trigger}>Mango</button>
				</div>
			</div>
		{/each}
	{/each}
</div>

<style>
	.looks {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
		align-items: end;
		gap: var(--slean-space-4) var(--slean-space-3);
		inline-size: 100%;
	}

	.field {
		display: grid;
		gap: var(--slean-space-1);
		font-size: var(--slean-text-sm);
	}
</style>
```

outlined (the default), filled and standard in the three sizes; the trigger is the same field shell as an input.

### Colors

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const colors = ['primary', 'secondary', 'success', 'warning'];
</script>

<!-- data-color is the hover and focus color; focus a field to see it. -->
<div class="field">
	{#each colors as color (color)}
		{@const p = selectParts(`select-color-${color}`)}
		<label {...p.label}>{color}</label>
		<div id="select-color-{color}" data-slean="select" data-color={color} data-variant="filled">
			<select {...p.native}>
				<option value="low">Low</option>
				<option value="high" selected>High</option>
			</select>
			<button {...p.trigger}>High</button>
		</div>
	{/each}
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

### Sizes and density

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const sizes = ['small', 'medium', 'large'];
	const compact = selectParts('select-size-compact');
</script>

<div class="field">
	{#each sizes as size (size)}
		{@const p = selectParts(`select-size-${size}`)}
		<label {...p.label}>{size}</label>
		<div id="select-size-{size}" data-slean="select" data-size={size}>
			<select {...p.native}>
				<option value="apple">Apple</option>
				<option value="mango" selected>Mango</option>
			</select>
			<button {...p.trigger}>Mango</button>
		</div>
	{/each}
	<!-- data-density on any ancestor tightens every control and option row below it. -->
	<div class="field" data-density="compact">
		<label {...compact.label}>compact density</label>
		<div id="select-size-compact" data-slean="select">
			<select {...compact.native}>
				<option value="apple">Apple</option>
				<option value="mango" selected>Mango</option>
			</select>
			<button {...compact.trigger}>Mango</button>
		</div>
	</div>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

### Start and end adornments

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const p = selectParts('select-plan');
</script>

<div class="field">
	<label {...p.label}>Plan</label>
	<div id="select-plan" data-slean="select">
		<span data-slean-part="prefix" aria-hidden="true">
			<svg
				width="16"
				height="16"
				viewBox="0 0 16 16"
				fill="none"
				stroke="currentColor"
				stroke-width="1.5"
			>
				<rect x="2.5" y="4" width="11" height="8.5" rx="1.5" />
				<path d="M2.5 7h11" />
			</svg>
		</span>
		<select {...p.native} name="plan">
			<option value="team" selected>Team</option>
			<option value="business">Business</option>
		</select>
		<button {...p.trigger}>Team</button>
		<span data-slean-part="suffix">per month</span>
	</div>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

### Groups

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const p = selectParts('select-office');
</script>

<div class="field">
	<label {...p.label}>Office</label>
	<div id="select-office" data-slean="select">
		<!-- The behavior renders one labelled role="group" per optgroup. -->
		<select {...p.native} name="office">
			<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 {...p.trigger}>Istanbul</button>
	</div>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

### Clear button

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const p = selectParts('select-priority');
</script>

<div class="field">
	<label {...p.label}>Priority</label>
	<div id="select-priority" data-slean="select">
		<select {...p.native} name="priority">
			<option value="" hidden>No priority</option>
			<option value="low">Low</option>
			<option value="high" selected>High</option>
		</select>
		<button {...p.trigger}>High</button>
		<!-- Shown on hover or focus while there is a value; it returns to the hidden option. -->
		<button {...p.clear}></button>
	</div>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

Hover or focus the field: the clear button takes the chevron’s place.

### Labels and words

`selectParts()` names the field with an `<id>-label` label by default; `labelledBy` names it by elements rendered elsewhere (a heading, a column header) and `label` by text. `messages` translates the words it writes: `clear` and `remove`, where `{label}` stands for the option's label. Tags the behavior renders read the same template from `data-slean-remove`.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	// `{label}` stands for the option's label. The helper writes the clear button's word; the
	// behavior renders the tags, and reads their remove template from data-slean-remove.
	const remove = '{label} öğesini kaldır';
	const p = selectParts('select-reviewers', {
		multiple: true,
		labelledBy: 'select-reviewers-heading',
		messages: { clear: 'Temizle', remove }
	});
</script>

<section class="field" lang="tr">
	<h4 id="select-reviewers-heading">İnceleyenler</h4>
	<div id="select-reviewers" data-slean="select" data-slean-remove={remove}>
		<select {...p.native} name="reviewers">
			<option value="ayse" selected>Ayşe</option>
			<option value="mehmet">Mehmet</option>
			<option value="zeynep" selected>Zeynep</option>
		</select>
		<div {...p.trigger}>
			<span {...p.value}></span>
			<span {...p.placeholder}>Kişi seçin</span>
		</div>
		<button {...p.clear}></button>
	</div>
</section>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	h4 {
		margin: 0;
		font-size: inherit;
		font-weight: var(--slean-font-weight-medium);
	}

	.field [data-slean='select'] {
		inline-size: 22rem;
		max-inline-size: 100%;
	}
</style>
```

The remove buttons are named “Ayşe öğesini kaldır”, the clear button “Temizle”; the field is named by the heading.

### Disabled and invalid

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const plan = selectParts('select-locked');
	const region = selectParts('select-region');
</script>

<div class="field">
	<label {...plan.label}>Locked plan</label>
	<!-- disabled on the native select and on the trigger disables the whole field. -->
	<div id="select-locked" data-slean="select">
		<select {...plan.native} name="locked" disabled>
			<option value="team" selected>Team</option>
		</select>
		<button {...plan.trigger} disabled>Team</button>
	</div>

	<label {...region.label}>Region</label>
	<!-- An error is aria-invalid on the trigger, with the message in aria-describedby. -->
	<div id="select-region" data-slean="select">
		<select {...region.native} name="region">
			<option value="" hidden>Select a region</option>
			<option value="eu">Europe</option>
			<option value="na">North America</option>
		</select>
		<!-- The role comes from the spread: the trigger is a combobox, which takes aria-invalid. -->
		<!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
		<button {...region.trigger} aria-invalid="true" aria-describedby="select-region-error">
			Select a region
		</button>
	</div>
	<p id="select-region-error" class="error">Choose a region.</p>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}

	.error {
		margin: 0;
		color: var(--slean-error-main);
	}
</style>
```

### In a form

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';

	const role = selectParts('select-role');
	let sent = $state('');

	function submit(event: SubmitEvent) {
		event.preventDefault();
		const data = new FormData(event.currentTarget as HTMLFormElement);
		sent = `role=${data.get('role')}`;
	}
</script>

<!-- The native select carries the name; a reset puts it back and the field follows. -->
<form class="field" onsubmit={submit} onreset={() => (sent = '')}>
	<label {...role.label}>Role</label>
	<div id="select-role" data-slean="select">
		<select {...role.native} name="role">
			<option value="viewer" selected>Viewer</option>
			<option value="editor">Editor</option>
			<option value="admin">Admin</option>
		</select>
		<button {...role.trigger}>Viewer</button>
	</div>
	<div class="actions">
		<button type="submit" data-slean="button">Send invitation</button>
		<button type="reset" data-slean="button" data-variant="outlined" data-color="neutral">
			Reset
		</button>
	</div>
	{#if sent}<p role="status">Submitted: {sent}</p>{/if}
</form>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}

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

Choose a role, then Reset: the form resets the native select and the field follows.

### Controlled

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import (and, for data-slean-search and
	// data-slean-create, their option modules) is injected for the static data-slean="select".
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/select.css';
	import { selectParts } from '@svelte-lean/primitives/select';
	import { setValue } from '@svelte-lean/primitives';
	import type { SelectChangeDetail } from '@svelte-lean/primitives/select';
	import { on } from 'svelte/events';

	const p = selectParts('select-status');
	let root: HTMLElement;
	let last = $state('none yet');

	function changed(event: Event) {
		const { value, previous } = (event as CustomEvent<SelectChangeDetail>).detail;
		last = `${previous} → ${value}`;
	}
</script>

<div class="field">
	<label {...p.label}>Status</label>
	<div
		id="select-status"
		data-slean="select"
		bind:this={root}
		{@attach (node) => on(node, 'slean:change', changed)}
	>
		<select {...p.native} name="status">
			<option value="open" selected>Open</option>
			<option value="closed">Closed</option>
		</select>
		<button {...p.trigger}>Open</button>
	</div>
	<!-- setValue() writes the native select and dispatches change; the field follows. -->
	<button type="button" data-slean="button" onclick={() => setValue(root, 'closed')}>
		Close from script
	</button>
	<p>Last change by the user: {last}</p>
</div>

<style>
	.field {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-2);
		font-size: var(--slean-text-sm);
	}

	.field [data-slean='select'] {
		inline-size: 16rem;
		max-inline-size: 100%;
	}
</style>
```

setValue() writes the native select from script; slean:change reports what the user chose.

## Helper or plain markup

Two ways to write the same markup: with `selectParts()` or by hand. Both render the same HTML, which a test of this site compares; use either.

`With selectParts()`

```svelte
<script lang="ts">
	import { selectParts } from '@svelte-lean/primitives/select';
	const p = selectParts('fruit');
</script>

<label {...p.label}>Fruit</label>
<div id="fruit" data-slean="select">
	<select {...p.native} name="fruit">
		<option value="" hidden>Select a fruit</option>
		<option value="apple">Apple</option>
		<option value="mango">Mango</option>
	</select>
	<button {...p.trigger}>Select a fruit</button>
	<button {...p.clear}></button>
</div>
```

`Plain markup`

```html
<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="mango">Mango</option>
	</select>
	<button
		type="button"
		id="fruit-trigger"
		role="combobox"
		aria-haspopup="listbox"
		aria-expanded="false"
		aria-controls="fruit-list"
		aria-labelledby="fruit-label fruit-trigger"
		data-slean-part="trigger"
	>
		Select a fruit
	</button>
	<button
		type="button"
		tabindex="-1"
		aria-label="Clear"
		data-slean-part="clear"
	></button>
</div>
```

### What the helper does

- One id in, every part id out: `selectParts('fruit')` writes `fruit-label`, `fruit-native` and `fruit-trigger`, and the `for`, `aria-controls` and `aria-labelledby` that point at them.
- Strings only: no handler, no state, no instance. The server and the browser render the same attributes, so hydration has nothing to patch.
- Options switch the attributes for you: `search: true` writes a text-input trigger (`aria-autocomplete`), `multiple: true` a focusable `<div>` trigger and a `multiple` native select; `messages` translates the Clear and Remove names.

### Points to watch

- Spread each record on the element it names: `p.native` on the `<select>`, `p.trigger` on the trigger.
- It registers nothing and imports no stylesheet: the root's `id` and `data-slean="select"` stay in your markup, where `@svelte-lean/vite` finds them.
- Tags the behavior renders read their Remove word from `data-slean-remove` on the root, not from `messages`.
- Ids must be unique on the page. Hand-written markup works the same; the ids and the references between them are then yours to keep in step.

## Usage

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

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

`+layout.svelte or any client module`

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

`stylesheets`

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

Bare: put `data-slean="select"` on a native `<select>`; a `hidden` first option is the placeholder. Owned: `selectParts(id)` from `@svelte-lean/primitives/select` writes the label, native and trigger attributes from one root id; the popup and its listbox are created on the first open. A `<button>` trigger shows the chosen label as its text; a multiple select puts the tags in a `value` part.

Labels and words: `selectParts(id, { labelledBy })` or `{ label }` names the field without an `<id>-label` label; `messages: { clear, remove }` translates the button names the helper writes (English by default), and `data-slean-remove` on the root the tags the behavior renders.

Modes are attributes on the root: `data-slean-search` (the trigger is an `<input>`, `selectParts(id, { search: true })`), native `multiple`, and `data-slean-create` with search. `@svelte-lean/vite` adds the search and create code only where the attribute is written. `bind:value` goes on the native select; `setValue()` sets it from script.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div id="…" data-slean="select">` | – | yes | The field; the ids of the parts derive from its id. Options: data-slean-search, data-slean-create, data-slean-filter, data-slean-loop, data-slean-render="app", data-slean-remove (the name of the remove buttons the behavior renders, {label} for the option’s label); looks: data-variant, data-color, data-size, data-status="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 role="combobox">, <div tabindex="0"> or <input>` | `trigger` | yes | Holds DOM focus and a name. A button shows the chosen label as its text; a multiple select uses a div (its tags hold buttons); with search it is a text input. |
| value | `<span>` | `value` | no | The chosen label, or the tags of a multiple select (required there). |
| placeholder | `<span>` | `placeholder` | no | After value; shown by the stylesheet while value is empty. |
| prefix | `any element before the trigger` | `prefix` | no | A start icon or text inside the field. |
| suffix | `any element after the trigger` | `suffix` | no | An end icon or text inside the field, before the chevron. |
| 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. |
| popup | `<div popover="manual">` | `popup` | no | Created on the first open, last in the root (data-slean-generated); write it yourself for an empty message or rich rows. |
| list | `<ul role="listbox">` | `list` | no | Inside the popup; its id is the one the trigger’s aria-controls names. |
| empty | `any element in an authored popup` | `empty` | no | Shown when no option is listed or the search matches none. |
| group label | `<div>, rendered from an <optgroup>` | `group-label` | no | The heading of a role="group" of options. |
| 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. Named "Remove {label}" unless selectParts() messages.remove or data-slean-remove say otherwise. |

## 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 (with search: an empty field) | Drops the last tag |
| `characters` | search field | Native editing; filters the options and makes the first match active |
| `Enter` | search field, popup open | Chooses the active option; with data-slean-create, the offered text becomes a new option |
| `Escape` | search field, popup open | Closes and puts the chosen label back in the field |

## Styling

`control.css` draws the owned field: border, hover and focus, the variants (`data-variant`), the color (`data-color`), sizes (`data-size`) and density, the status, the adornments, the clear button, the chevron (the root’s `::after`), the popup surface and the option rows. `select.css` adds the placeholder look, the tags and the search layout, hands the field to the native select without scripting, and styles the bare select. The tokens of `control.css` and `select.css`:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-inline, margin-inline, max-block-size, margin-block, padding, scroll-padding-block, padding-block |
| `--slean-control-height-md` | `2.25rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline, background-position, padding-inline-end |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-inline, max-inline-size, gap, padding-block, padding-inline-start |
| `--slean-control-border` | `var(--slean-border-strong)` | border, border-block-end-color |
| `--slean-field-radius` | `var(--slean-radius-md)` | border-radius, border-start-start-radius, border-start-end-radius |
| `--slean-control-bg` | `var(--slean-surface)` | background, background-color |
| `--slean-fg` | `var(--slean-neutral-12)` | color, border-block-end-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` | `initial` | border-color |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, box-shadow, border-block-end-color, color |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-error` | `0 0 0 3px color-mix(in oklch, var(--slean-error-main) 22%, transparent)` | box-shadow |
| `--slean-warning-main` | `oklch(52% 0.125 55)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-warning` | `0 0 0 3px color-mix(in oklch, var(--slean-warning-main) 28%, transparent)` | box-shadow |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color, border-block-end-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` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline, padding-block, padding-inline-start |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-control-filled-bg` | `var(--slean-muted)` | background-color |
| `--slean-control-filled-bg-hover` | `var(--slean-muted-hover)` | background-color |
| `--slean-control-icon` | `var(--slean-fg-muted)` | color, background, background-image |
| `--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-select` | `var(--slean-icon-chevron-down)` | mask-image, mask |
| `--slean-icon-select-turn` | `180deg` | rotate |
| `--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-elevation-3)` | box-shadow |
| `--slean-option-min-height` | `var(--slean-control-height-sm)` | min-block-size |
| `--slean-option-padding-block` | `var(--slean-space-1)` | padding-block |
| `--slean-option-padding-inline` | `var(--slean-space-2)` | padding-inline |
| `--slean-option-check-size` | `1rem` | inline-size, block-size |
| `--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-active-fg` | `var(--slean-fg)` | color |
| `--slean-option-selected` | `var(--slean-primary-soft)` | background |
| `--slean-option-selected-fg` | `var(--slean-primary-soft-text)` | color |
| `--slean-option-selected-weight` | `var(--slean-font-weight-semibold)` | font-weight |
| `--slean-option-selected-ring` | `inset 0 0 0 1px var(--slean-option-selected-fg)` | box-shadow |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-icon-plus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5v9M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-width` | `2px` | outline-offset |
| `--slean-chip-radius` | `var(--slean-radius-full)` | border-radius |
| `--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: `::-webkit-calendar-picker-indicator`, `::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]`, `[value=""]`.

Variant attributes: `data-status` (`warning`); `data-size` (`small`, `large`); `data-variant` (`filled`, `standard`); `data-slean-active`; `data-slean-selected`; `data-slean-new`; `data-full-width`; `data-slean-render` (`app`).

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 5022 B brotli · 5565 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<select>`, `appearance: base-select`, `role="combobox"`, `aria-activedescendant`, `popover="manual"`, `anchor-name`, `@media (scripting)`
- **Shared listeners**: click, keydown, input, pointerdown, pointerover, focusout, change, toggle, reset, animationstart
- **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)`

Tier 0 for a bare `<select>`, Tier 2 for the owned field: a lazy scoped controller. Nine shared listeners (`click`, `keydown`, `input`, `pointerdown`, `pointerover`, `focusout`, `change`, and `toggle` and `reset` 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 popup is created on the first open and appended last in the root, marked `data-slean-generated`. `data-slean-search` and `data-slean-create` are option modules (`select/search`, `select/create`) that the Vite plugin injects only where those attributes are on a select root.

Why this implementation

The native `<select>` has the value, form submission, autofill, `disabled` options and optgroups, and it is the right control without JavaScript. Its interface differs between browsers: the customizable select (`appearance: base-select`) is in Chrome and Safari 27, not in Firefox, and the platform menus have no search, clear button or tags. So the bare `<select data-slean="select">` is the Tier 0 select, and the owned `<div data-slean="select">` keeps the native select as the value and draws the field over it (ADR 0007).

The value is always an option of the native select, which is what separates select from autocomplete (free text). Search is the trigger as a text input that filters the options (the APG editable combobox restricted to its list, the former Combobox primitive); create appends a new `<option>` so the value is still a form control’s value.

### The browser owns

- the value, the options, disabled options, optgroups, multiple and form submission of the native \<select\>
- the bare select and, where appearance: base-select is supported, its styled picker
- the popup in the top layer through showPopover() and hidePopover()
- placement under the field through CSS anchor positioning in the stylesheet
- the native select itself when scripting is off

### Svelte Lean owns

- the field: value, placeholder, adornments, clear button and chevron inside one control shell (control.css)
- creating the popup and its listbox on the first open, and rendering the options from the native select on every open
- the keyboard of the APG select-only combobox: arrows, Home and End, PageUp and PageDown, type-ahead, Enter, Space, Escape, Tab
- search (data-slean-search): filtering as the user types and the label put back on close, in the select/search option module
- creatable values (data-slean-create): the offer row and the appended native option, in the select/create option module
- writing the native select and dispatching input and change; the cancelable slean:change event; following setValue() and form resets
- 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
- selectParts(), the attribute helper; development validation of the markup

Accessibility

- The trigger is a `role="combobox"` with `aria-expanded` (written), `aria-controls` naming the listbox and `aria-labelledby` naming the label; it keeps DOM focus and `aria-activedescendant` names the active option. A search trigger is a text input with `aria-autocomplete="list"`.
- The listbox is created closed, so nothing a screen reader reads at load is missing; `aria-controls` names it before it exists, which axe accepts while `aria-expanded="false"`.
- Options are `role="option"` with `aria-selected` on the chosen ones and `aria-disabled` for a disabled native option; an optgroup is a `role="group"` labelled by its label. A multiple select’s listbox has `aria-multiselectable="true"`.
- The clear button and the tag remove buttons 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 in `aria-describedby`.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<select>, <option>, <optgroup>` | Widely available | Not applicable |
| `appearance: base-select, ::picker(select), ::checkmark, :open` | Chrome and Edge 135, Safari 27.0; Firefox behind a flag; not Baseline | The bare select keeps the styled closed field and the platform’s picker |
| `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()` | Baseline newly available since January 2026 (Chrome 125, Safari 26, Firefox 147; anchor-scope Chrome 131) | 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 affixes, the clear button and the popup and shows the native `<select>`, styled as the Tier 0 select: the platform’s picker, keyboard and form submission. A bare select needs no script at all.

Server rendering and hydration

Render the chosen option’s label in the trigger and `aria-expanded` as `false`; `selectParts()` returns the same attributes on the server and in the browser. No popup is in the server markup. The register modules are safe to import on the server.

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 the popup.

Compatibility

Popover is Baseline 2024; CSS anchor positioning 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 (`appearance: base-select`) only affects the bare select.

Tests and source

- `packages/primitives/src/select/contract.md` the test cases of the contract: tests/select.test.ts (happy-dom) and a Playwright spec on the playground’s /primitives/select page
- `packages/primitives/tests/select-search.test.ts` the short markup (the popup created on the first open), search and create (happy-dom)
- `apps/playground/src/routes/primitives/select/+page.svelte` the playground fixtures: the short markup, search, tags input, groups, multiple with tags, disabled, the Tier 0 native select
- `apps/playground/tests/primitives/nested.spec.ts` a select inside a modal dialog: the popup above the dialog, a click on an option, Escape closing the popup before the dialog
- `apps/playground/tests/stress/memory.spec.ts` 200 open/close cycles: heap, DOM nodes and listeners back at baseline, one scoped listener per open
- `fixtures/select-only` consumer build: only the select registration, lazyState and the popup session ship; the search and create modules, tabs, menu, listbox, autocomplete and the calendar are absent
- `fixtures/select-search` consumer build: data-slean-search and data-slean-create make the plugin add the select/search and select/create option modules
- `apps/playground/tests/primitives/forms.spec.ts` Playwright: keyboard, form values, :user-invalid, names and descriptions, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states

- `packages/primitives/src/select/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/select/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/select/behavior.ts` the behavior definition, openSelect(), closeSelect(), chooseOption(), clearSelect(), renderSelectOptions()
- `packages/primitives/src/select/parts.ts` selectParts(), the attribute helper
- `packages/primitives/src/select/search/search.ts` the select/search option module
- `packages/primitives/src/select/create/create.ts` the select/create option module
- `packages/primitives/src/select/register.ts` the registration module, the only side effect
- `packages/primitives/src/select/validate.ts` development validation messages
- `packages/styles/css/control.css` the control shell shared with the autocomplete and the date picker
- `packages/styles/css/select.css` the optional stylesheet
