Combobox
A text input that filters a list as you type. It is windowed above 200 options and calls loadMore for the next page.
client - size not measured yet
Combobox
client - size not measured yetA text input that filters a list as you type. It is windowed above 200 options and calls loadMore for the next page.
Try it
size
invalid
fullWidth
placement
visibleRows
hasMore
loadMoreThreshold
status
The code
combobox.tsx
import { Combobox } from 'topclassui/combobox'
<div className="grid justify-items-start gap-1">
<label htmlFor="combobox-playground" className="text-sm font-medium text-body">Framework</label>
<Combobox id="combobox-playground" items={FRAMEWORKS} placeholder="Search frameworks" />
</div>Filtering runs over the options loaded so far; the next page arrives through hasMore and loadMore.
Every state
4 statesImport from the package
topclassui/combobox
import { Combobox } from 'topclassui/combobox'Install once:npm i topclassui@beta zod
Examples
itemstakes{ value, label, disabled? }: typing is matched against the label, and the form receives the value. Keep the array the same between renders.- The component asks and never fetches: with
hasMoreit callsloadMoreonce the open list isloadMoreThresholdrows (10) from its end, and you append the next page toitems. statussays what is on its way:loadingdraws a spinner in place of the caret,loading-morewrites a line under the options.virtualizeisautoby default: above 200 options only the rows in view and a few around them are mounted, and the highlight still reaches every option.- A query with no match says
emptyFallback, or the provider's "no results" label.maxLengthcaps the query at 256 by default.
basic
basic.tsx
import { Combobox } from 'topclassui/combobox'
const FRAMEWORKS = [
{ value: 'next', label: 'Next.js' },
{ value: 'astro', label: 'Astro' },
{ value: 'remix', label: 'Remix' },
{ value: 'sveltekit', label: 'SvelteKit' },
{ value: 'nuxt', label: 'Nuxt' },
{ value: 'gatsby', label: 'Gatsby' },
{ value: 'vite', label: 'Vite' },
{ value: 'qwik', label: 'Qwik City' },
{ value: 'solid', label: 'SolidStart' },
{ value: 'angular', label: 'Angular' },
]
/** Named by a label that points at its id. Typing filters by "contains"; the arrows move, Enter chooses. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-1">
<label htmlFor="combobox-basic" className="text-sm font-medium text-body">Framework</label>
<Combobox id="combobox-basic" items={FRAMEWORKS} placeholder="Search frameworks" />
</div>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { Combobox } from 'topclassui/combobox'
const FRAMEWORKS = [
{ value: 'next', label: 'Next.js' },
{ value: 'astro', label: 'Astro' },
{ value: 'remix', label: 'Remix' },
]
/** The value, not the label, is what the form receives: framework=next, before any script runs and after. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-3">
<label htmlFor="combobox-form" className="text-sm font-medium text-body">Framework</label>
<Combobox id="combobox-form" name="framework" items={FRAMEWORKS} defaultValue="next" />
<Button type="submit">Save</Button>
</form>
)
}
sizes
sizes.tsx
import { Combobox } from 'topclassui/combobox'
const FRUITS = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'cherry', label: 'Cherry' },
{ value: 'date', label: 'Date' },
]
/** The five steps of the field scale, each an Input wide at its own type step. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<Combobox aria-label="Fruit, extra small" size="xs" items={FRUITS} defaultValue="apple" />
<Combobox aria-label="Fruit, small" size="sm" items={FRUITS} defaultValue="banana" />
<Combobox aria-label="Fruit, medium" size="md" items={FRUITS} defaultValue="cherry" />
<Combobox aria-label="Fruit, large" size="lg" items={FRUITS} defaultValue="date" />
<Combobox aria-label="Fruit, extra large" size="xl" items={FRUITS} />
</div>
)
}
load-more
Pages asked for: 0, loaded: 1
load-more.tsx
'use client'
import { useCallback, useState } from 'react'
import { Combobox } from 'topclassui/combobox'
const PAGE = 50
const LAST = 4
/** One page of the server's answer, made here so the demo needs no network. */
function pageOf(page: number) {
return Array.from({ length: PAGE }, (_, index) => {
const n = page * PAGE + index + 1
return { value: `r${String(n)}`, label: `Result ${String(n)}` }
})
}
/** The component asks and never fetches: loadMore is called once per page as the open list nears its end, and the
* page holds what has arrived. Four pages of fifty, each after a short wait. */
export default function Demo() {
const [items, setItems] = useState(() => pageOf(0))
const [asked, setAsked] = useState(0)
const [loading, setLoading] = useState(false)
const pages = items.length / PAGE
const loadMore = useCallback(() => {
setAsked((n) => n + 1)
setLoading(true)
return new Promise<void>((resolve) => {
setTimeout(() => {
setItems((current) => [...current, ...pageOf(current.length / PAGE)])
setLoading(false)
resolve()
}, 300)
})
}, [])
return (
<div className="grid justify-items-start gap-1">
<label htmlFor="combobox-load-more" className="text-sm font-medium text-body">Search results</label>
<Combobox
id="combobox-load-more"
items={items}
hasMore={pages < LAST}
loadMore={loadMore}
status={loading ? 'loading-more' : 'idle'}
/>
<p data-testid="asked" className="text-xs text-muted">{`Pages asked for: ${String(asked)}, loaded: ${String(pages)}`}</p>
</div>
)
}
status
status.tsx
import { Combobox } from 'topclassui/combobox'
const FIRST_PAGE = [
{ value: 'a', label: 'Aleppo' },
{ value: 'b', label: 'Beirut' },
{ value: 'c', label: 'Cairo' },
]
/** loading: a spinner in place of the caret, and the list says Loading until the first options arrive. loading-more:
* the options so far, and a line under them. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<Combobox aria-label="City, loading" status="loading" items={[]} />
<Combobox aria-label="City, loading more" status="loading-more" items={FIRST_PAGE} />
</div>
)
}
ten-thousand
ten-thousand.tsx
import { Combobox } from 'topclassui/combobox'
/** Ten thousand options, built once at module scope so the array is stable between renders. */
const OPTIONS = Array.from({ length: 10_000 }, (_, index) => ({
value: String(index + 1),
label: `Option ${String(index + 1)}`,
}))
/** Windowed: about twenty rows are mounted whatever the scroll, and the highlight still reaches every one of them. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-1">
<label htmlFor="combobox-ten-thousand" className="text-sm font-medium text-body">Ten thousand options</label>
<Combobox id="combobox-ten-thousand" name="option" items={OPTIONS} />
</div>
)
}
long-content
long-content.tsx
import { Combobox } from 'topclassui/combobox'
const WORD = 'Supercalifragilistic'.repeat(15)
const LONG = [
{ value: 'word', label: WORD },
{ value: 'sentence', label: 'A label long enough to run past the width of the input and end in an ellipsis' },
{ value: 'short', label: 'Short' },
]
/** One 300-character word chosen: the input scrolls its text, each row ends in an ellipsis, and nothing widens. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
<Combobox aria-label="Long, own width" items={LONG} defaultValue="word" />
<Combobox aria-label="Long, full width" fullWidth items={LONG} defaultValue="sentence" />
</div>
)
}
In Arabic, right to left
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { Combobox } from 'topclassui/combobox'
const CITIES = [
{ value: 'amman', label: 'عمّان' },
{ value: 'irbid', label: 'إربد' },
{ value: 'zarqa', label: 'الزرقاء' },
{ value: 'aqaba', label: 'العقبة' },
{ value: 'madaba', label: 'مادبا' },
]
/** Arabic, inside TopClassProvider as the root layout would mount it. The caret sits at the left, the list opens at
* the right edge of the input, and the tick ends each row at its left. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-1">
<label htmlFor="combobox-rtl" className="text-sm font-medium text-body">المدينة</label>
<Combobox id="combobox-rtl" name="city" items={CITIES} defaultValue="amman" />
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- A name is required:
idfor a<label htmlFor>, oraria-label, oraria-labelledby. The input is an<input role="combobox">. - The input keeps the focus while the highlight moves through the list, as the editable combobox of the ARIA Authoring Practices does.
- Escape on the closed input clears the value, so the form then sends an empty value.
Right to left
The caret and the tick sit at the left, and bottom-start lines the list up with the right edge of the input. ArrowLeft and ArrowRight move the caret in the text, in Arabic too.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.