Select
One choice from a list of plain data. The popup stays inside the viewport and scrolls itself, seven rows tall by default.
client - size not measured yet
Select
client - size not measured yetOne choice from a list of plain data. The popup stays inside the viewport and scrolls itself, seven rows tall by default.
Try it
size
invalid
fullWidth
placement
visibleRows
loadMoreThreshold
The code
select.tsx
import { Select } from 'topclassui/select'
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-playground" className="text-sm text-body">Country</label>
<Select id="select-playground" items={COUNTRIES} defaultValue="jo" />
</div>items is plain data, so a server page can render a Select: no function crosses to the browser.
Every state
4 statesPick a paid plan.
Import from the package
topclassui/select
import { Select } from 'topclassui/select'Install once:npm i topclassui@beta zod
Examples
itemstakes{ value, label, disabled? }: the value is what the form receives, the label what the row and the trigger show.- In a form it sends the chosen value, and before the page hydrates it sends the default. A form reset brings the default back.
- The list shows seven rows, or
visibleRows(1 to 20), then scrolls inside itself. It is not windowed: for thousands of options, use a Combobox. placementtakes the twelve shared values,bottom-startby default; the list flips at an edge and stays 8px inside the screen.- The width is an Input's at the same
size;fullWidthfills the parent, and a width class inclassNamelands on the trigger inside the box.
basic
basic.tsx
import { Select } from 'topclassui/select'
const COUNTRIES = [
{ value: 'jo', label: 'Jordan' },
{ value: 'eg', label: 'Egypt' },
{ value: 'ma', label: 'Morocco' },
{ value: 'sa', label: 'Saudi Arabia' },
]
/** A label that points at the trigger by its id, and a list of plain data: the server prints "Jordan" in the trigger. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-basic" className="text-sm text-body">Country</label>
<Select id="select-basic" items={COUNTRIES} defaultValue="jo" />
</div>
)
}
form
form.tsx
import { Button } from 'topclassui/button'
import { Select } from 'topclassui/select'
const COUNTRIES = [
{ value: 'jo', label: 'Jordan' },
{ value: 'eg', label: 'Egypt' },
{ value: 'ma', label: 'Morocco' },
]
/** The Select carries the name and the default. It sends country=jo, before any script runs and after. */
export default function Demo() {
return (
<form className="grid justify-items-start gap-3">
<label htmlFor="select-form" className="text-sm text-body">Country</label>
<Select id="select-form" name="country" items={COUNTRIES} defaultValue="jo" required />
<Button type="submit">Save</Button>
</form>
)
}
sizes
sizes.tsx
import { Select } from 'topclassui/select'
const SIZES = [
{ size: 'xs', label: 'Extra small' },
{ size: 'sm', label: 'Small' },
{ size: 'md', label: 'Medium' },
{ size: 'lg', label: 'Large' },
{ size: 'xl', label: 'Extra large' },
] as const
const PLANS = [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, { value: 'team', label: 'Team' }]
/** The five sizes: the height, the padding and the type step change, and the width follows the type step. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-3">
{SIZES.map(({ size, label }) => (
<Select key={size} aria-label={label} size={size} items={PLANS} defaultValue="pro" />
))}
</div>
)
}
four-rows
four-rows.tsx
import { Select } from 'topclassui/select'
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December',
].map((label, at) => ({ value: String(at + 1), label }))
/** visibleRows={4}: the list is four rows tall and scrolls through the other eight. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-four-rows" className="text-sm text-body">Month</label>
<Select id="select-four-rows" items={MONTHS} defaultValue="3" visibleRows={4} />
</div>
)
}
sixty-items
sixty-items.tsx
import { Select } from 'topclassui/select'
/** Sixty options, the thirteenth disabled: the list shows seven rows, scrolls inside itself, and opens on the chosen one. */
const OPTIONS = Array.from({ length: 60 }, (_, at) => ({
value: String(at + 1),
label: `Option ${String(at + 1)}`,
...(at === 12 ? { disabled: true } : {}),
}))
export default function Demo() {
return (
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-sixty" className="text-sm text-body">Sixty options</label>
<Select id="select-sixty" items={OPTIONS} defaultValue="30" />
</div>
)
}
long-content
long-content.tsx
import { Select } from 'topclassui/select'
const LONG = 'Department of Regional Planning, Infrastructure and Sustainable Urban Development, Northern Governorates Office, with responsibility for the coordination of transport, housing, water and energy projects across every municipality of the region, reporting to the minister'
const OFFICES = [
{ value: 'hq', label: 'Head office' },
{ value: 'north', label: LONG },
{ value: 'south', label: 'Southern office' },
]
/** A label far longer than the trigger: it ends in an ellipsis there and in its row, and the list is never wider than
* the screen. The value is never cut: the form still sends north. */
export default function Demo() {
return (
<div className="grid justify-items-start gap-1.5">
<label htmlFor="select-long" className="text-sm text-body">Office</label>
<Select id="select-long" name="office" items={OFFICES} defaultValue="north" />
</div>
)
}
In Arabic, right to left
rtl.tsx
import { TopClassProvider } from 'topclassui/provider'
import { Select } from 'topclassui/select'
const COUNTRIES = [
{ value: 'jo', label: 'الأردن' },
{ value: 'eg', label: 'مصر' },
{ value: 'ma', label: 'المغرب' },
{ value: 'sa', label: 'السعودية' },
]
/** Arabic, inside TopClassProvider as the root layout would mount it. The caret and the tick sit at the left, and
* bottom-start lines the list up with the right edge of the trigger. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-1.5">
<label htmlFor="select-rtl" className="text-sm text-body">الدولة</label>
<Select id="select-rtl" name="country" items={COUNTRIES} defaultValue="jo" />
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- A name is required:
idfor a<label htmlFor>, oraria-label, oraria-labelledby. The trigger is a<button role="combobox">. - A row is 40px tall, 44px under a touch pointer, and a long label ends in an ellipsis in the row and in the trigger.
invalidsets aria-invalid on the trigger, whose line turns to danger and thickens.
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 trigger. The arrow keys are vertical, so they do not change.
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.