Listbox
Listbox renders a selectable list of options. Built on Ark UI listbox. It is a thin pass-through, like Select: Listbox owns the collection, and you render the Listbox* parts as children. Filtering is your job — filter the items array, then pass the same array to Listbox and render from it, so the collection and the children can never disagree.
// apps/docs/src/components/examples/listbox/default/react.tsx
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-react';
import { Check } from 'lucide-react';
const items = [
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'vue', label: 'Vue' },
{ value: 'solid', label: 'Solid' },
];
export default function ReactListboxDefault() {
return (
<Listbox items={items} defaultValue={['svelte']}>
<ListboxContent>
{items.map((item) => (
<ListboxItem key={item.value} item={item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator>
<Check />
</ListboxItemIndicator>
</ListboxItem>
))}
</ListboxContent>
</Listbox>
);
}<!-- apps/docs/src/components/examples/listbox/default/svelte.svelte -->
<script lang="ts">
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-svelte';
import { Check } from 'lucide-svelte';
const items = [
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' },
{ value: 'vue', label: 'Vue' },
{ value: 'solid', label: 'Solid' },
];
</script>
<Listbox {items} defaultValue={['svelte']}>
<ListboxContent>
{#each items as item}
<ListboxItem {item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator><Check /></ListboxItemIndicator>
</ListboxItem>
{/each}
</ListboxContent>
</Listbox>Composing the parts
ListboxContent is required. It is the only element carrying role="listbox", tabIndex, aria-activedescendant, aria-multiselectable, and the keyboard map. A ListboxItem rendered outside it still emits role="option" but is orphaned — no keyboard navigation, no active-option tracking, and no warning. Always nest items in ListboxContent.
The filter input (ListboxInput) must be a sibling of ListboxContent, never nested inside it — a text input inside role="listbox" is invalid ARIA. ListboxInput wraps Ark’s listbox input, which owns the list linkage (arrow keys move the active option; aria-activedescendant stays current). It is a typeahead input: it does not filter. The query stays yours — pass value / onChange (oninput in Svelte) and filter items yourself. Add autoHighlight so the first match is selected as you type.
ListboxInput and ListboxContent are both focusable, so Tab lands twice within one control; that is Ark’s design.
Examples
Controlled
Selected: Medium
Selected: Medium
// apps/docs/src/components/examples/listbox/controlled/react.tsx
import { useState } from 'react';
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-react';
import { Check } from 'lucide-react';
const items = [
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
];
export default function ReactListboxValueText() {
const [value, setValue] = useState<string[]>(['md']);
const selected = items.find((item) => item.value === value[0]);
return (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">Selected: {selected?.label ?? 'None'}</p>
<Listbox items={items} value={value} onValueChange={(details) => setValue(details.value)}>
<ListboxContent>
{items.map((item) => (
<ListboxItem key={item.value} item={item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator>
<Check />
</ListboxItemIndicator>
</ListboxItem>
))}
</ListboxContent>
</Listbox>
</div>
);
}<!-- apps/docs/src/components/examples/listbox/controlled/svelte.svelte -->
<script lang="ts">
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-svelte';
import { Check } from 'lucide-svelte';
const items = [
{ value: 'sm', label: 'Small' },
{ value: 'md', label: 'Medium' },
{ value: 'lg', label: 'Large' },
];
let value = $state<string[]>(['md']);
const selected = $derived(items.find((item) => item.value === value[0]));
</script>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">Selected: {selected?.label ?? 'None'}</p>
<Listbox {items} {value} onValueChange={(details) => (value = details.value)}>
<ListboxContent>
{#each items as item}
<ListboxItem {item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator><Check /></ListboxItemIndicator>
</ListboxItem>
{/each}
</ListboxContent>
</Listbox>
</div>Filtering
// apps/docs/src/components/examples/listbox/filtering/react.tsx
// Filtering is consumer-owned: `visible` is filtered with helical-ui's defaultListboxFilter and
// the SAME array is both passed to Listbox and rendered — so the collection and the children
// cannot diverge. Ark's ListboxInput is a typeahead input (zag exposes only autoHighlight /
// keyboardPriority — there is no controlled query prop on the root), so the consumer controls
// the query with plain HTML value/onChange on the input, while Ark keeps the list linkage.
// ListboxInput is a sibling of ListboxContent (never inside role="listbox"), while the
// styled Listbox root keeps both controls inside one visual surface.
import { useMemo, useState } from 'react';
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
ListboxInput,
ListboxEmpty,
} from '@cloudvoyant/helical-react';
import { defaultListboxFilter } from '@cloudvoyant/helical-ui';
import { Check } from 'lucide-react';
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{ value: 'elderberry', label: 'Elderberry' },
];
export default function ReactListboxFiltering() {
const [query, setQuery] = useState('');
// Memoized so `items` keeps a stable identity across renders (the collection depends on it).
const visible = useMemo(() => items.filter((item) => defaultListboxFilter(item, query)), [query]);
return (
<Listbox items={visible}>
<ListboxInput
value={query}
onChange={(event) => setQuery(event.target.value)}
autoHighlight
placeholder="Filter fruit…"
/>
<ListboxContent>
{visible.map((item) => (
<ListboxItem key={item.value} item={item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator>
<Check />
</ListboxItemIndicator>
</ListboxItem>
))}
<ListboxEmpty>No fruit matches “{query}”.</ListboxEmpty>
</ListboxContent>
</Listbox>
);
}<!-- apps/docs/src/components/examples/listbox/filtering/svelte.svelte -->
<!-- Filtering is consumer-owned: `visible` is filtered with defaultListboxFilter and the SAME -->
<!-- array is passed to Listbox and rendered, so collection and children cannot diverge. -->
<!-- Ark's ListboxInput is a typeahead input with no controlled query prop, so the consumer owns -->
<!-- the query via plain value/oninput. It is a SIBLING of ListboxContent while the Listbox -->
<!-- root keeps both controls inside one visual surface. -->
<script lang="ts">
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
ListboxInput,
ListboxEmpty,
} from '@cloudvoyant/helical-svelte';
import { defaultListboxFilter } from '@cloudvoyant/helical-ui';
import { Check } from 'lucide-svelte';
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
{ value: 'elderberry', label: 'Elderberry' },
];
let query = $state('');
const visible = $derived(items.filter((item) => defaultListboxFilter(item, query)));
</script>
<Listbox items={visible}>
<ListboxInput
value={query}
oninput={(event) => (query = (event.currentTarget as HTMLInputElement).value)}
autoHighlight
placeholder="Filter fruit…"
/>
<ListboxContent>
{#each visible as item}
<ListboxItem {item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator><Check /></ListboxItemIndicator>
</ListboxItem>
{/each}
<ListboxEmpty>No fruit matches “{query}”.</ListboxEmpty>
</ListboxContent>
</Listbox>Multiple
// apps/docs/src/components/examples/listbox/multiple/react.tsx
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-react';
import { Check } from 'lucide-react';
const items = [
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
];
export default function ReactListboxMultiple() {
return (
<Listbox items={items} selectionMode="multiple" defaultValue={['red', 'blue']}>
<ListboxContent>
{items.map((item) => (
<ListboxItem key={item.value} item={item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator>
<Check />
</ListboxItemIndicator>
</ListboxItem>
))}
</ListboxContent>
</Listbox>
);
}<!-- apps/docs/src/components/examples/listbox/multiple/svelte.svelte -->
<script lang="ts">
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxItemText,
ListboxItemIndicator,
} from '@cloudvoyant/helical-svelte';
import { Check } from 'lucide-svelte';
const items = [
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
];
</script>
<Listbox {items} selectionMode="multiple" defaultValue={['red', 'blue']}>
<ListboxContent>
{#each items as item}
<ListboxItem {item}>
<ListboxItemText>{item.label}</ListboxItemText>
<ListboxItemIndicator><Check /></ListboxItemIndicator>
</ListboxItem>
{/each}
</ListboxContent>
</Listbox>API Reference
Listbox — items (ListboxItemData[]) or collection, value / defaultValue (string[]), onValueChange, selectionMode (single | multiple).
ListboxInput — Ark’s typeahead input. It is a plain <input> under the hood, so you own the query with value / onChange (oninput in Svelte) and filter items yourself. Must be a sibling of ListboxContent.
ListboxContent — the scrollable option surface; ListboxItems and ListboxEmpty go inside.
ListboxItem — item (ListboxItemData, required). children are the row’s content: compose ListboxItemText and ListboxItemIndicator.
ListboxItemText / ListboxItemIndicator — the row label and the selected-state icon (consumer-provided).
ListboxEmpty — empty state; defaults to “No results.”
ListboxLabel — an accessible label for the list.
defaultListboxFilter (from @cloudvoyant/helical-ui) — a convenience predicate: case-insensitive substring match on label.