Forms
Combobox
Chooses one option from a long list by typing part of its name.
Installation
npx sinaui add comboboxCopy src/lib/ui/Combobox.svelte, floating.ts, glide.ts, Icon.svelte and tokens.css. No other dependencies.
For a dozen options or fewer, a Select is quicker to use: nothing to type.
For options from a server, loaded page by page, the example below uses TanStack Query (any data library works):
pnpm add @tanstack/svelte-queryExamples
Basic
Matches anywhere in the name, ignoring case and accents: “cordoba” finds Córdoba. With a name, a hidden input submits the value.
<script lang="ts">
import Combobox from '#lib/ui/Combobox.svelte';
let city = $state('');
const cities = [
'Aleppo',
'Baghdad',
'Balkh',
'Basra',
'Bukhara',
'Cairo',
'Córdoba',
'Damascus',
'Fez',
'Isfahan',
'Kairouan',
'Merv',
'Nīshāpūr',
'Palermo',
'Rayy',
'Samarkand',
'Toledo'
].map((label) => ({ value: label.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(), label }));
</script>
<Combobox
label="City of study"
placeholder="Search cities"
options={cities}
bind:value={city}
name="city"
/>With descriptions
A second line per option. Disabled options still show up in results but can't be chosen.
Try “sina” or “haytham”: accents don't matter.
<script lang="ts">
import Combobox from '#lib/ui/Combobox.svelte';
let scholar = $state('ibn-sina');
const scholars = [
{ value: 'khwarizmi', label: 'Al-Khwārizmī', description: 'Algebra · Baghdad' },
{ value: 'kindi', label: 'Al-Kindī', description: 'Philosophy · Baghdad' },
{ value: 'razi', label: 'Al-Rāzī', description: 'Medicine · Rayy' },
{ value: 'ibn-sina', label: 'Ibn Sīnā', description: 'Medicine · Bukhara' },
{ value: 'haytham', label: 'Ibn al-Haytham', description: 'Optics · Cairo' },
{ value: 'biruni', label: 'Al-Bīrūnī', description: 'Astronomy · Ghazni' },
{ value: 'zahrawi', label: 'Al-Zahrāwī', description: 'Surgery · Córdoba' },
{ value: 'khayyam', label: 'Omar Khayyām', description: 'Mathematics · Nīshāpūr' },
{ value: 'idrisi', label: 'Al-Idrīsī', description: 'Geography · Palermo' },
{
value: 'ibn-battuta',
label: 'Ibn Baṭṭūṭa',
description: 'Away on a journey',
disabled: true
}
];
</script>
<Combobox
label="Scholar"
options={scholars}
bind:value={scholar}
hint="Try “sina” or “haytham”: accents don't matter."
/>Search inside the list
A button that opens a list with its own search box: the chosen value can't be typed over, only searched for. This is Select's searchable option.
<script lang="ts">
import CustomSelect from '#lib/ui/CustomSelect.svelte';
const scholars = [
['Abbas ibn Firnas', 'Flight, Córdoba'],
['Al-Battani', 'Astronomy, Raqqa'],
['Al-Biruni', 'Astronomy, Ghazni'],
['Al-Farabi', 'Philosophy, Baghdad'],
['Al-Idrisi', 'Geography, Palermo'],
['Al-Jazari', 'Engineering, Diyarbakır'],
['Al-Khwarizmi', 'Mathematics, Baghdad'],
['Al-Kindi', 'Philosophy, Baghdad'],
['Al-Razi', 'Medicine, Rayy'],
['Al-Zahrawi', 'Surgery, Córdoba'],
['Banu Musa', 'Mechanics, Baghdad'],
['Fatima al-Fihri', 'Founded al-Qarawiyyin, Fez'],
['Ibn al-Haytham', 'Optics, Cairo'],
['Ibn Battuta', 'Travel, Tangier'],
['Ibn Khaldun', 'History, Tunis'],
['Ibn Rushd', 'Philosophy, Córdoba'],
['Ibn Sina', 'Medicine, Bukhara'],
['Maryam al-Ijliya', 'Astrolabes, Aleppo'],
['Nasir al-Din al-Tusi', 'Astronomy, Maragheh'],
['Omar Khayyam', 'Mathematics, Nishapur'],
['Sutayta al-Mahamali', 'Mathematics, Baghdad'],
['Thabit ibn Qurra', 'Mathematics, Baghdad']
].map(([label, description]) => ({
value: label.toLowerCase().replace(/\W+/g, '-'),
label,
description
}));
let mentor = $state('');
</script>
<CustomSelect
label="Mentor"
options={scholars}
bind:value={mentor}
placeholder="Choose a scholar"
searchable
searchLabel="Search scholars"
empty="No scholar by that name"
/>Thousands of options
With TanStack Query: the search goes to the server as you type, and the next page loads as you scroll near the end.
Loading…
10,000 entries, loaded 40 at a time as you scroll.
<script lang="ts">
import { QueryClient, createInfiniteQuery } from '@tanstack/svelte-query';
import Combobox from '#lib/ui/Combobox.svelte';
// Stands in for your API: 10,000 catalog entries, searched on the "server", 40 to a page.
const subjects = [
'Optics',
'Algebra',
'Medicine',
'Astronomy',
'Geography',
'Poetry',
'Logic',
'Music'
];
const cities = [
'Baghdad',
'Cairo',
'Córdoba',
'Fez',
'Damascus',
'Bukhara',
'Samarkand',
'Isfahan'
];
const catalog = Array.from({ length: 10_000 }, (_, i) => ({
value: `ms-${i + 1}`,
label: `${subjects[i % 8]}, ${cities[Math.floor(i / 8) % 8]} copy ${Math.floor(i / 64) + 1}`,
description: `Shelf ${(i % 400) + 1}`
}));
const fold = (s: string) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
async function fetchPage(search: string, page: number) {
await new Promise((r) => setTimeout(r, 350));
const q = fold(search.trim());
const hits = q ? catalog.filter((m) => fold(m.label).includes(q)) : catalog;
const next = (page + 1) * 40 < hits.length ? page + 1 : undefined;
return { items: hits.slice(page * 40, page * 40 + 40), next };
}
const client = new QueryClient();
let search = $state('');
let timer: ReturnType<typeof setTimeout>;
// A short pause after typing, so a word is one request rather than one per letter.
const onsearch = (q: string) => {
clearTimeout(timer);
timer = setTimeout(() => (search = q), 200);
};
const query = createInfiniteQuery(
() => ({
queryKey: ['catalog', search],
queryFn: ({ pageParam }) => fetchPage(search, pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next
}),
() => client
);
const options = $derived(query.data?.pages.flatMap((p) => p.items) ?? []);
let manuscript = $state('');
</script>
<Combobox
label="Manuscript"
placeholder="Search the catalog"
hint="10,000 entries, loaded 40 at a time as you scroll."
{options}
bind:value={manuscript}
{onsearch}
onloadmore={() => query.hasNextPage && !query.isFetchingNextPage && query.fetchNextPage()}
loading={query.isFetching}
/>With an error
Choose where to collect the manuscript.
<script lang="ts">
import Combobox from '#lib/ui/Combobox.svelte';
let room = $state('');
const rooms = [
{ value: 'bayt', label: 'Bayt al-Ḥikma, Baghdad' },
{ value: 'qarawiyyin', label: 'Al-Qarawiyyīn, Fez' },
{ value: 'azhar', label: 'Al-Azhar, Cairo' }
];
</script>
<Combobox
label="Reading room"
options={rooms}
bind:value={room}
error={room ? undefined : 'Choose where to collect the manuscript.'}
/>Props
| Prop | Type | Default |
|---|---|---|
label | string (required) | |
options | { value, label, description?, disabled? }[] | |
value | string, bindable | '' |
placeholder | string | |
hint | string | |
error | string | |
name | string | |
empty | string | 'No matches' |
disabled | boolean | false |
onsearch | (query) => void: you filter; options show as given | |
onloadmore | () => void: scrolled near the end | |
loading | boolean | false |
Accessibility
- Follows the WAI-ARIA combobox pattern with list autocomplete: focus stays in the text box and points at the highlighted option.
- Screen readers hear how many options match as you type.
- Typing never changes the value on its own. Leaving the field without choosing puts back the last choice.
| Key | Action |
|---|---|
| ↓ / ↑ | Opens the list, then moves through it. |
| Enter | Chooses the highlighted option. |
| Tab | Chooses the highlighted option and moves on. |
| Esc | Closes the list and undoes the typing. Pressed again, clears the field. |
| Alt + ↓ | Opens the list without moving. |