# Autocomplete

> A text field with suggestions from a <datalist> or rows you render, free text kept: the browser’s list without a wrapper, an owned popup with one (Tier 2).

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

A text field with suggestions that filter as you type; any other text is kept. Suggestions come from a datalist or from rows you render.

> **Note** When the value must be one of the options, use [Select with search](https://svelte-lean.vercel.app/primitives/select.md#search): the form then submits an option’s value, never free text.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static
	// data-slean="autocomplete" on the <div>; a bare <input data-slean="autocomplete"> adds none.
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

	const cities = ['Ankara', 'Berlin', 'Lisbon', 'Nairobi', 'Oslo', 'Seoul', 'Tokyo', 'Toronto'];
	const p = autocompleteParts('autocomplete-city');
	let city = $state('');
</script>

<div class="field">
	<label {...p.label}>City</label>
	<div id="autocomplete-city" data-slean="autocomplete">
		<input {...p.input} name="city" placeholder="Select a city" bind:value={city} />
		<datalist {...p.datalist}>
			{#each cities as name (name)}
				<option value={name}></option>
			{/each}
		</datalist>
		<button {...p.clear}></button>
	</div>
	<p>Value: {city || 'empty'}</p>
</div>

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

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

Type a letter or press ArrowDown. Enter takes the active suggestion; Escape closes, then clears; the clear button empties the field.

## Examples

### Native

```svelte
<script lang="ts">
	// Tier 0: the browser shows and filters the list; no behavior ships for a bare input.
	import '@svelte-lean/styles/autocomplete.css';

	const languages = ['Go', 'Kotlin', 'Python', 'Rust', 'Svelte', 'Swift', 'TypeScript'];
	let language = $state('');
</script>

<div class="field">
	<label for="autocomplete-language">Language</label>
	<input
		id="autocomplete-language"
		name="language"
		list="autocomplete-language-options"
		autocomplete="off"
		data-slean="autocomplete"
		bind:value={language}
	/>
	<datalist id="autocomplete-language-options">
		{#each languages as name (name)}
			<option value={name}></option>
		{/each}
	</datalist>
	<p>
		{language
			? languages.includes(language)
				? `${language} is in the list.`
				: `“${language}” is free text.`
			: 'Type or pick a language.'}
	</p>
</div>

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

Tier 0: the browser’s list, no runtime. Works with JavaScript disabled.

### Rich options

```svelte
<script lang="ts">
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

	const people = [
		{ id: 'amara', name: 'Amara Okafor', email: 'amara@example.com' },
		{ id: 'jonas', name: 'Jonas Lindqvist', email: 'jonas@example.com' },
		{ id: 'mei', name: 'Mei Tanaka', email: 'mei@example.com' },
		{ id: 'rafael', name: 'Rafael Souza', email: 'rafael@example.com' }
	];
	// popup: true — the rows are written here instead of coming from a <datalist>.
	const p = autocompleteParts('autocomplete-assignee', { popup: true });
</script>

<div class="field">
	<label {...p.label}>Assignee</label>
	<div id="autocomplete-assignee" data-slean="autocomplete">
		<input {...p.input} name="assignee" placeholder="Search people" />
		<button {...p.clear}></button>
		<div {...p.popup}>
			<ul {...p.list}>
				{#each people as person (person.id)}
					<!-- data-slean-label is what the filter matches and what the input receives. -->
					<li
						id="autocomplete-assignee-{person.id}"
						role="option"
						aria-selected="false"
						data-slean-value={person.id}
						data-slean-label={person.name}
					>
						<span class="person">
							<span>{person.name}</span>
							<small>{person.email}</small>
						</span>
					</li>
				{/each}
			</ul>
			<p data-slean-part="empty" hidden>Nobody matches</p>
		</div>
	</div>
</div>

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

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

	.person {
		display: grid;
	}

	.person small {
		color: var(--slean-fg-muted);
	}
</style>
```

Rows written in a popup of your own; data-slean-label is what the filter matches and the input receives.

### Remote results

```svelte
<script lang="ts">
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

	const LANGUAGES = ['Elixir', 'Go', 'Haskell', 'Kotlin', 'OCaml', 'Python', 'Rust', 'Zig'];
	const p = autocompleteParts('autocomplete-remote', { popup: true });
	let results = $state(LANGUAGES);
	let pending = $state(false);
	let timer: ReturnType<typeof setTimeout> | undefined;

	// data-slean-filter="none": the application renders the options. The timeout stands in for a
	// request; the behavior navigates and accepts whatever is in the DOM when a key arrives.
	function search(event: Event & { currentTarget: HTMLInputElement }) {
		const query = event.currentTarget.value.trim().toLowerCase();
		clearTimeout(timer);
		pending = true;
		timer = setTimeout(() => {
			results = LANGUAGES.filter((name) => name.toLowerCase().includes(query));
			pending = false;
		}, 400);
	}
</script>

<div class="field">
	<label {...p.label}>Language</label>
	<div id="autocomplete-remote" data-slean="autocomplete" data-slean-filter="none">
		<input {...p.input} placeholder="Search languages" oninput={search} />
		<div {...p.popup}>
			<ul {...p.list} aria-busy={pending}>
				{#each results as name (name)}
					<li
						id="autocomplete-remote-{name.toLowerCase()}"
						role="option"
						aria-selected="false"
						data-slean-value={name}
					>
						{name}
					</li>
				{/each}
			</ul>
			{#if results.length === 0 || pending}
				<p data-slean-part="empty">{pending ? 'Searching…' : 'No language matches'}</p>
			{/if}
		</div>
	</div>
</div>

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

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

With data-slean-filter="none" the application renders the options, here after a simulated request.

### Command palette

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

	const commands = [
		{ value: 'new', label: 'New file' },
		{ value: 'open', label: 'Open recent' },
		{ value: 'theme', label: 'Toggle theme' },
		{ value: 'settings', label: 'Settings' }
	];
	let palette: HTMLDialogElement;
	let ran = $state('');

	function run(event: Event) {
		ran = (event as CustomEvent<AutocompleteChangeDetail>).detail.label;
		palette.close(); // the dialog returns focus to the button that opened it
	}
</script>

<button
	type="button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	commandfor="autocomplete-palette"
	command="show-modal"
>
	Open the command palette
</button>
<p role="status">{ran ? `Ran: ${ran}` : 'No command run yet.'}</p>
<dialog
	id="autocomplete-palette"
	bind:this={palette}
	data-slean="dialog"
	aria-label="Command palette"
	class="palette"
>
	<!-- In a dialog the list sits in the page flow: a hidden popup, not a popover. -->
	<div
		id="autocomplete-commands"
		data-slean="autocomplete"
		data-slean-autoselect
		{@attach (node) => on(node, 'slean:change', run)}
	>
		<input type="text" aria-label="Command" autocomplete="off" placeholder="Type a command" />
		<div hidden data-slean-part="popup">
			<ul id="autocomplete-commands-list" role="listbox" aria-label="Commands">
				{#each commands as command (command.value)}
					<li
						id="autocomplete-commands-{command.value}"
						role="option"
						aria-selected="false"
						data-slean-value={command.value}
					>
						{command.label}
					</li>
				{/each}
			</ul>
		</div>
	</div>
</dialog>

<style>
	.palette {
		inline-size: min(28rem, calc(100vw - 2rem));
	}
</style>
```

The dialog owns the backdrop, Escape and focus return; slean:change runs the command.

### Autoselect

```svelte
<script lang="ts">
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

	const countries = ['Argentina', 'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada', 'Chile'];
	const p = autocompleteParts('autocomplete-country');
</script>

<!-- startsWith matches the start of a name; the first match becomes active while typing, and
     Enter or Tab accepts it. -->
<div class="field">
	<label {...p.label}>Country</label>
	<div
		id="autocomplete-country"
		data-slean="autocomplete"
		data-slean-filter="startsWith"
		data-slean-autoselect
	>
		<input {...p.input} placeholder="Type a country" />
		<datalist {...p.datalist}>
			{#each countries as name (name)}
				<option value={name}></option>
			{/each}
		</datalist>
	</div>
</div>

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

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

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/autocomplete.css';
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';

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

<div class="looks">
	{#each variants as variant (variant)}
		{#each sizes as size (size)}
			{@const p = autocompleteParts(`autocomplete-${variant}-${size}`)}
			<div class="field">
				<label {...p.label}>{variant}, {size}</label>
				<div
					id="autocomplete-{variant}-{size}"
					data-slean="autocomplete"
					data-variant={variant}
					data-size={size}
				>
					<input {...p.input} placeholder="A fruit" />
					<datalist {...p.datalist}>
						{#each fruits as name (name)}
							<option value={name}></option>
						{/each}
					</datalist>
				</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 shell every field shares.

## Helper or plain markup

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

`With autocompleteParts()`

```svelte
<script lang="ts">
	import { autocompleteParts } from '@svelte-lean/primitives/autocomplete';
	const a = autocompleteParts('city');
</script>

<label {...a.label}>City</label>
<div id="city" data-slean="autocomplete">
	<input {...a.input} name="city" placeholder="Select a city" />
	<datalist {...a.datalist}>
		<option value="Ankara"></option>
		<option value="Berlin"></option>
	</datalist>
	<button {...a.clear}></button>
</div>
```

`Plain markup`

```html
<label id="city-label" for="city-input">City</label>
<div id="city" data-slean="autocomplete">
	<input
		id="city-input"
		type="text"
		list="city-options"
		autocomplete="off"
		name="city"
		placeholder="Select a city"
		data-slean-part="input"
	/>
	<datalist id="city-options">
		<option value="Ankara"></option>
		<option value="Berlin"></option>
	</datalist>
	<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: `autocompleteParts('city')` writes `city-label`, `city-input` and `city-options`, and the `for` and `list` 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.
- `popup: true` leaves out `list` for a popup you write; `messages: { clear }` translates the clear button’s name.

### Points to watch

- Spread each record on the element it names: `a.input` on the text `<input>`, `a.datalist` on the `<datalist>`.
- It registers nothing and imports no stylesheet: the root's `id` and `data-slean="autocomplete"` stay in your markup, where `@svelte-lean/vite` finds them.
- The combobox role is written by the behavior on the first focus or press, not by the helper; until then `<input list>` is a combobox to assistive technology.
- 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="autocomplete"`; without it, import the register module once.

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

`+layout.svelte or any client module`

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

`stylesheets`

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

`autocompleteParts(id)` from `@svelte-lean/primitives/autocomplete` writes the label, input and datalist attributes from one root id; wrap them in `<div id data-slean="autocomplete">`. `bind:value` goes on the input. For rows with their own markup, pass `{ popup: true }` and write the popup and its `role="listbox"` yourself.

`data-slean-filter="startsWith"` matches the start of a label; `data-slean-filter="none"` leaves the rows to the application, for remote results; `data-slean-autoselect` makes the first match active as you type. A command palette is an autocomplete with `data-slean-autoselect` in a dialog.

`autocompleteParts(id, { labelledBy })` names the field by elements rendered elsewhere (a heading, a column header) and `{ label }` by text, instead of an `<id>-label` label; `messages: { clear }` translates the clear button’s name (English `Clear` by default).

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div id="…" data-slean="autocomplete">, or the bare <input list>` | – | yes | The owned field; the ids of the parts derive from its id. Options: data-slean-filter, data-slean-autoselect, data-slean-loop; looks: data-variant, data-color, data-size, data-status="warning". A bare \<input list data-slean="autocomplete"\> is the Tier 0 form. |
| input | `<input type="text" list="…" autocomplete="off">` | `input` | yes | The value and the form name, with a \<label\>. The part attribute is optional when it is the root’s only input. |
| datalist | `<datalist id="…"> with <option value="…">` | – | no | The suggestions, named by the input’s list; the browser’s list without scripts, the rows of the popup with them. |
| prefix | `any element before the input` | `prefix` | no | A start icon or text inside the field. |
| suffix | `any element after the input` | `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 the input has text (it reads :placeholder-shown, so give the input a placeholder); empties it. |
| popup | `<div popover="manual"> or <div hidden>` | `popup` | no | Created on the first open; write it yourself for rich rows, an empty message or a list the application renders. |
| list | `<ul role="listbox">` | `list` | no | Inside the popup; the input’s aria-controls names it. |
| empty | `<p hidden>` | `empty` | no | In an authored popup, outside the listbox: shown when the filter leaves no option (the application shows it with data-slean-filter="none"). |
| group label | `<div> inside a role="group"` | `group-label` | no | A heading for a group of authored options. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `a character`/`Backspace`/`Delete` | focus in the input | Native editing; the input event filters the options and opens the popup (with autoselect, the first match becomes active) |
| `ArrowDown` | focus in the input | Opens on the option that holds the input’s value, else the first enabled one; then the next, wrapping unless data-slean-loop="false" |
| `ArrowUp` | focus in the input | Opens on the option that holds the input’s value, else the last; then the previous |
| `Alt+ArrowDown` | popup closed | Opens without an active option |
| `Enter` | popup open | Accepts the active option; with no active option closes and stays native |
| `Escape` | focus in the input | Closes the popup; when it is closed, clears the input; otherwise left to ancestors |
| `Tab`/`Shift+Tab` | popup open | Accepts the active option with data-slean-autoselect, otherwise closes; focus moves natively |
| `Home`/`End`/`Shift+Arrow`/`Ctrl+Arrow` | focus in the input | Native caret movement and text selection |
| `any key during an IME composition` | isComposing or keyCode 229 | Ignored; Enter that commits a candidate never accepts an option |

## Styling

`control.css` draws the owned field (variants, color, sizes, density, adornments, clear button, the chevron as the root’s `::after`, the popup and the option rows); `autocomplete.css` draws the bare input from the same tokens and hides the browser’s list indicator. The tokens of `control.css` and `autocomplete.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 |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-inline, max-inline-size, gap, padding-block |
| `--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 |
| `--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 |
| `--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 |
| `--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 |
| `--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 |
| `--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 |

State selectors the stylesheet targets, all from the platform or ARIA: `::-webkit-calendar-picker-indicator`, `::placeholder`, `:checked`, `:disabled`, `:focus-visible`, `:focus-within`, `:hover`, `:placeholder-shown`, `:popover-open`, `:user-invalid`, `[aria-disabled="true"]`, `[aria-expanded="true"]`, `[aria-invalid="true"]`, `[hidden]`, `[popover]`, `[role="listbox"]`, `[role="option"]`, `[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`.

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 3962 B brotli · 4425 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input list>`, `<datalist>`, `role="combobox"`, `aria-activedescendant`, `popover="manual"`, `KeyboardEvent.isComposing`, `anchor-name`
- **Shared listeners**: click, keydown, input, pointerdown, pointerover, focusin, focusout, toggle
- **Per-instance listeners**: scoped to one interaction session
- **Lazy state**: WeakMap entry created on first interaction
- **Native base**: `<input list> + <datalist>; owned: role=combobox input + [popover] + ARIA listbox`

Tier 0 for a bare `<input list>`, Tier 2 for the owned field: a lazy scoped controller. Eight shared listeners (`click`, `keydown`, `input`, `pointerdown`, `pointerover`, `focusin`, `focusout`, and `toggle` in the capture phase) serve every autocomplete on the page. Taking the list over on the first focus allocates nothing; a root gets a `WeakMap` entry when its popup first opens and, while it is open, one document `pointerdown` listener that closes it on a press outside. The Vite plugin injects the register module for a marker on a `<div>` and nothing for the same marker on a bare `<input>`.

Why this implementation

Suggestions under a text field are a combobox. The native `<input list>` with a `<datalist>` does it with no script, and it is the Tier 0 form here, but the browser draws the list, matches by its own rule and takes no CSS for it. The owned form keeps the same input and datalist and draws the list in a popup, identical in every engine: a check mark on the accepted value, a clear button, an empty message, rich rows.

The value is the input’s text, free text included; that is what separates autocomplete from select. On the first focus or press the behavior takes the list over (it moves `list` aside so the browser’s list stays closed) and renders the datalist’s options into a popup it creates on the first open. Without JavaScript the browser’s datalist still works.

### The browser owns

- text editing: typing, the caret, selection, Backspace, Delete, Home, End, shortcuts and IME composition
- the value: whatever text the field holds, and its form participation
- the datalist as the source of the suggestions, and the whole list when scripting is off
- the popup in the top layer through showPopover() and hidePopover(), placed by CSS anchor positioning

### Svelte Lean owns

- taking the list over on the first focus or press: the combobox role and states, the browser’s list kept closed
- creating the popup on the first open and rendering the datalist’s options into it on every open
- opening on typing, a click and the arrows; closing on Escape, a choice, Tab, focus loss and an outside press
- the active option (aria-activedescendant), the contains and startsWith filters, autoselect, the check mark on the accepted value
- the clear button and the empty message; rows the application renders with data-slean-filter="none"
- writing the chosen option into the input, the input event after it and the cancelable slean:change event
- the lazy controller: a WeakMap entry when a popup first opens and one scoped document listener while open
- autocompleteParts(), the attribute helper; development validation of the markup

Accessibility

- At load, `<input list>` is a combobox to assistive technology (HTML-AAM). On the first focus or press the behavior writes `role="combobox"`, `aria-expanded`, `aria-autocomplete` and `aria-controls`; the input keeps DOM focus and `aria-activedescendant` names the active option.
- The created listbox is named by the input’s label; options are `role="option"`, with `aria-selected="true"` on the active one and `aria-disabled` on a disabled one.
- Typing, the caret, Home, End and IME composition stay native; keys during a composition are ignored, so Enter that commits a candidate never accepts an option.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<input list>, <datalist>` | Widely available; the native list differs between browsers | A plain text input; the typed value is still submitted |
| `Popover API: popover, showPopover(), hidePopover(), ToggleEvent` | Baseline newly available since April 2024 | Use an authored popup toggled with hidden |
| `showPopover({ source })` | Chrome 133 and Safari 26; not Baseline | Passed as an option, so browsers without it ignore the argument |
| `CSS anchor positioning: anchor-name, anchor-scope, position-area, anchor-size()` | Baseline newly available since January 2026 | The popover popup is centered in the top layer |
| `KeyboardEvent.isComposing, AbortSignal on addEventListener` | Widely available | Not applicable within the support policy |

Without JavaScript

The input keeps its `list`: the browser’s datalist suggests, typing and submitting need no script. `autocomplete.css` hides the clear button and the chevron, which would do nothing.

Server rendering and hydration

The input, the datalist and any popup you write render on the server; `autocompleteParts()` returns the same attributes on the server and in the browser. Nothing is created there or at hydration.

Before the behavior loads, the field is a text input with the browser’s datalist. The first focus or press after hydration takes the list over.

Compatibility

`<input list>` is Baseline 2019; how the browser’s own list matches and shows options differs between browsers, which is why the owned form exists. 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.

Tests and source

- `packages/primitives/tests/autocomplete.test.ts` unit tests (happy-dom with a Popover API shim): taking the datalist over, the created popup, the lazy controller with 1000 roots, native editing never prevented, keyboard, filters, pointer, validation
- `apps/playground/tests/primitives/autocomplete.spec.ts` Playwright: the takeover, the real popover in the top layer, anchor placement, filters, autoselect, an asynchronous list, a modal dialog, IME composition
- `apps/playground/tests/primitives/lazy.spec.ts` 100 roots: no controller after load, one per interacted root, released on close and focus loss
- `fixtures/autocomplete-only` consumer build: only the autocomplete registration ships; a bare \<input list\> adds nothing
- `apps/playground/tests/primitives/inputs-overlays.spec.ts` Playwright, with page JavaScript enabled and disabled, and 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/autocomplete/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/autocomplete/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/autocomplete/behavior.ts` the behavior definition, upgradeAutocomplete(), openAutocomplete(), closeAutocomplete(), acceptOption()
- `packages/primitives/src/autocomplete/parts.ts` autocompleteParts(), the attribute helper
- `packages/primitives/src/autocomplete/register.ts` the registration module, the only side effect
- `packages/primitives/src/autocomplete/validate.ts` development validation messages
- `packages/styles/css/control.css` the control shell shared with the select and the date picker
- `packages/styles/css/autocomplete.css` the optional stylesheet
