Keyboard
| Key | Effect |
|---|---|
| Type | Filters listbox. |
| Arrow ↓ / ↑ | Move active option. |
| Enter | Commit active option. |
| Escape | Close listbox; clear if open. |
Pure state machines (FSMs). No DOM. Run in server, Vitest, Worker. Reach for these when you only want the behavior.
APG combobox (single-select listbox + filterable input).
pnpm add @kumiki/machinesimport { createComboboxMachine } from '@kumiki/machines/combobox';
const m = createComboboxMachine({
options: [{ id: '1', label: 'Alice' }],
});
m.send({ type: 'INPUT.CHANGE', value: 'al' });
m.send({ type: 'INPUT.NAVIGATE', direction: 'first' });
m.send({ type: 'INPUT.ENTER' });
console.log(m.context.value); // { id: '1', label: 'Alice' }// Each INPUT.CHANGE bumps the token. Late FETCH.RESOLVE
// arrivals from stale queries are dropped automatically.
const m = createComboboxMachine({ options: [] });
m.send({ type: 'INPUT.CHANGE', value: 'al' });
const stale = m.context.token;
m.send({ type: 'INPUT.CHANGE', value: 'ali' }); // bumps token
m.send({
type: 'FETCH.RESOLVE',
options: [/* stale results */],
token: stale, // dropped
});Svelte 5 {@attach} factories. Glue ARIA / keyboard / focus onto any DOM node you choose. **Best when you want full control over markup and styling.**
pnpm add @kumiki/headless<script lang="ts">
import { createCombobox } from '@kumiki/headless/combobox';
const cb = createCombobox({ options: items });
</script>
<input {@attach cb.input} />
<ul {@attach cb.listbox}>
{#each cb.filtered as opt (opt.id)}
<li {@attach cb.option(opt)}>{opt.label}</li>
{/each}
</ul>Compound components (<Root> / <Trigger> / …). Markup is fixed; styling is not. Same trade-off as a typical headless UI library.
pnpm add @kumiki/components/combobox<script lang="ts">
import { Root, Input, Listbox, Item, type ComboboxOption } from '@kumiki/components/combobox';
interface User extends ComboboxOption { id: string; label: string; email: string; }
const users: User[] = [
{ id: '1', label: 'Alice', email: 'alice@example.com' },
{ id: '2', label: 'Bob', email: 'bob@example.com' },
];
let value = $state<User | null>(null);
</script>
<Root options={users} bind:value>
<label for="user-input">User</label>
<Input id="user-input" placeholder="Search…" />
<Listbox>
{#snippet item(user: User)}
<Item value={user}>{user.label}</Item>
{/snippet}
{#snippet empty()}
<li>No results.</li>
{/snippet}
</Listbox>
</Root><script lang="ts">
import * as Combobox from '@kumiki/components/combobox';
</script>
<Combobox.Root options={items}>
<Combobox.Input />
<Combobox.Listbox>
{#snippet item(it)}
<Combobox.Item value={it}>{it.label}</Combobox.Item>
{/snippet}
</Combobox.Listbox>
</Combobox.Root>Styled, copy-paste presets (preview). Run pnpm kumiki add to drop the source into your project, then edit freely.
Live preview…
pnpm add @kumiki/atelier<script lang="ts">
import { Tailwind as Combobox } from '@kumiki/atelier/combobox';
const items = [
{ id: '1', value: 'apple', label: 'Apple' },
{ id: '2', value: 'banana', label: 'Banana' },
{ id: '3', value: 'cherry', label: 'Cherry' },
];
let query = $state('');
const filtered = $derived(
items.filter((i) => i.label.toLowerCase().includes(query.toLowerCase())),
);
</script>
<Combobox.Root {items}>
<Combobox.Input bind:value={query} aria-label="Fruit" />
<Combobox.Trigger />
<Combobox.Listbox>
{#each filtered as i (i.id)}
<Combobox.Item value={i}>{i.label}</Combobox.Item>
{/each}
</Combobox.Listbox>
</Combobox.Root>/ accessibility
axe-core — run on every PR (LTR + RTL × every documented state).
| Key | Effect |
|---|---|
| Type | Filters listbox. |
| Arrow ↓ / ↑ | Move active option. |
| Enter | Commit active option. |
| Escape | Close listbox; clear if open. |