Select
Pick one value from a list. A menu on a desktop, a sheet on a phone.
Preview
import { Select } from '@kerf-js/veneer'export function Basic() { return ( <Select label="Region" options={REGION_OPTIONS} defaultValue="eu-west" /> )}const REGIONS = [ { region: 'eu-west', requests: 34.2, share: 40 }, { region: 'us-east', requests: 24.8, share: 29 }, { region: 'ap-south', requests: 12.1, share: 14 }, { region: 'us-west', requests: 9.4, share: 11 }, { region: 'eu-north', requests: 5.6, share: 6 },]const REGION_OPTIONS = REGIONS.map((r) => ({ value: r.region, label: r.region,}))Installation
npm install @kerf-js/veneerTry it
Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.
import { Select } from '@kerf-js/veneer'<Select options={REGIONS} label="Region" hint="Moving a project redeploys it." />Usage
import { Select } from '@kerf-js/veneer'<Select label="Region" options={REGION_OPTIONS} defaultValue="eu-west"/>Sizes
Use size to pick sm, md or lg.
import { Select, Stack } from '@kerf-js/veneer'export function Sizes() { return ( <Stack gap="group"> <Select label="Region" size="sm" options={REGION_OPTIONS} /> <Select label="Region" options={REGION_OPTIONS} /> <Select label="Region" size="lg" options={REGION_OPTIONS} /> </Stack> )}const REGIONS = [ { region: 'eu-west', requests: 34.2, share: 40 }, { region: 'us-east', requests: 24.8, share: 29 }, { region: 'ap-south', requests: 12.1, share: 14 }, { region: 'us-west', requests: 9.4, share: 11 }, { region: 'eu-north', requests: 5.6, share: 6 },]const REGION_OPTIONS = REGIONS.map((r) => ({ value: r.region, label: r.region,}))Placeholder
Use placeholder when no answer should be assumed.
import { Select } from '@kerf-js/veneer'export function Placeholder() { return ( <Select label="Region" placeholder="Choose a region" options={REGION_OPTIONS} /> )}const REGIONS = [ { region: 'eu-west', requests: 34.2, share: 40 }, { region: 'us-east', requests: 24.8, share: 29 }, { region: 'ap-south', requests: 12.1, share: 14 }, { region: 'us-west', requests: 9.4, share: 11 }, { region: 'eu-north', requests: 5.6, share: 6 },]const REGION_OPTIONS = REGIONS.map((r) => ({ value: r.region, label: r.region,}))Options with a hint
An option's hint is a quieter second line.
import { Select } from '@kerf-js/veneer'export function Hints() { return ( <Select label="Role" options={ROLE_OPTIONS} defaultValue="Member" /> )}const ROLE_OPTIONS = [ { value: 'Admin', label: 'Admin', hint: 'Manages people and billing', }, { value: 'Member', label: 'Member', hint: 'Deploys and edits projects', }, { value: 'Viewer', label: 'Viewer', hint: 'Reads logs and metrics', },]Groups and disabled options
Give options a group to sort them under headings, and mark one disabled to show it without letting it be picked.
import { Select } from '@kerf-js/veneer'export function Groups() { return ( <Select label="Deploy to" options={TARGET_OPTIONS} defaultValue="staging" /> )}const TARGET_OPTIONS = [ { group: 'Production', options: [ { value: 'eu-west', label: 'eu-west' }, { value: 'us-east', label: 'us-east' }, ], }, { group: 'Preview', options: [ { value: 'staging', label: 'staging' }, { value: 'canary', label: 'canary' }, { value: 'qa', label: 'qa', disabled: true }, ], },]Search in the list
searchable adds it. Lists over 15 options get it anyway.
import { Select } from '@kerf-js/veneer'export function Searchable() { return ( <Select label="Project" searchable options={PROJECT_OPTIONS} defaultValue="checkout" /> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const PROJECT_OPTIONS = PROJECTS.map((p) => ({ value: p.id, label: p.name, hint: p.owner,}))States
For a value people can see but not change, show a read-only Input.
import { Grid, Select } from '@kerf-js/veneer'import { Stack, Text } from '@kerf-js/veneer'export function States() { return ( <Grid cols={3} gap="group" > <Cell name="Empty"> <Select label="Region" placeholder="Choose a region" options={REGION_OPTIONS} /> </Cell> <Cell name="Filled"> <Select label="Region" options={REGION_OPTIONS} defaultValue="us-east" /> </Cell> <Cell name="Hint"> <Select label="Region" options={REGION_OPTIONS} hint="Moving a project redeploys it." /> </Cell> <Cell name="Invalid"> <Select label="Region" placeholder="Choose a region" options={REGION_OPTIONS} error="Choose where this project runs." /> </Cell> <Cell name="Disabled"> <Select label="Region" options={REGION_OPTIONS} defaultValue="us-east" disabled /> </Cell> <Cell name="Read-only"> <Select label="Region" options={REGION_OPTIONS} defaultValue="us-east" readonly /> </Cell> </Grid> )}const REGIONS = [ { region: 'eu-west', requests: 34.2, share: 40 }, { region: 'us-east', requests: 24.8, share: 29 }, { region: 'ap-south', requests: 12.1, share: 14 }, { region: 'us-west', requests: 9.4, share: 11 }, { region: 'eu-north', requests: 5.6, share: 6 },]const Cell = ({ name, children,}: { name: string children?: unknown}) => ( <Stack gap="tight"> <Text role="caption" subtle > {name} </Text> {children} </Stack>)const REGION_OPTIONS = REGIONS.map((r) => ({ value: r.region, label: r.region,}))Show what was picked
Pass a State to value and it stays bound.
import { derived, state } from '@kerf-js/core'import { Select, Stack, Text } from '@kerf-js/veneer'export function Linked() { const project = state<string | null>('mobile-bff') const picked = derived(() => PROJECTS.find((p) => p.id === project.value)!, ) const details = derived(() => { const { status, region, deploys } = picked.value return ( `${status} in ${region}, ` + `${deploys} deploys so far.` ) }) return ( <Stack gap="related"> <Select label="Project" searchable options={PROJECT_OPTIONS} value={project} /> <Text subtle>{details.value}</Text> </Stack> )}type Status = | 'Live' | 'Building' | 'Failed' | 'Paused'const PROJECTS: { id: string name: string owner: string region: string status: Status deploys: number uptime: number latency: number cost: number updated: string}[] = [ { id: 'storefront', name: 'storefront', owner: 'Priya Raman', region: 'eu-west', status: 'Live', deploys: 412, uptime: 99.98, latency: 84, cost: 1240, updated: '2026-10-31', }, { id: 'checkout', name: 'checkout', owner: 'Tomás Oliveira', region: 'eu-west', status: 'Live', deploys: 288, uptime: 99.95, latency: 112, cost: 980, updated: '2026-10-31', }, { id: 'search-api', name: 'search-api', owner: 'Lena Fischer', region: 'us-east', status: 'Building', deploys: 196, uptime: 99.91, latency: 146, cost: 1610, updated: '2026-10-30', }, { id: 'billing', name: 'billing', owner: 'Mara Lindqvist', region: 'us-east', status: 'Live', deploys: 154, uptime: 99.99, latency: 73, cost: 720, updated: '2026-10-29', }, { id: 'mobile-bff', name: 'mobile-bff', owner: 'Sam Okafor', region: 'ap-south', status: 'Failed', deploys: 233, uptime: 99.42, latency: 208, cost: 860, updated: '2026-10-29', }, { id: 'notifier', name: 'notifier', owner: 'Jun Tanaka', region: 'eu-north', status: 'Live', deploys: 97, uptime: 99.97, latency: 58, cost: 310, updated: '2026-10-27', }, { id: 'reports', name: 'reports', owner: 'Noor Haddad', region: 'us-west', status: 'Paused', deploys: 41, uptime: 99.8, latency: 321, cost: 190, updated: '2026-10-22', }, { id: 'edge-cache', name: 'edge-cache', owner: 'Jun Tanaka', region: 'global', status: 'Live', deploys: 318, uptime: 100, latency: 21, cost: 1430, updated: '2026-10-31', }, { id: 'auth', name: 'auth', owner: 'Mara Lindqvist', region: 'eu-west', status: 'Live', deploys: 176, uptime: 99.99, latency: 66, cost: 540, updated: '2026-10-28', }, { id: 'ingest', name: 'ingest', owner: 'Lena Fischer', region: 'us-east', status: 'Live', deploys: 264, uptime: 99.93, latency: 131, cost: 2120, updated: '2026-10-30', },]const PROJECT_OPTIONS = PROJECTS.map((p) => ({ value: p.id, label: p.name, hint: p.owner,}))Invite a teammate
Send one with a bad address, then a good one.
import { derived, state } from '@kerf-js/core'import { Button, Input, Row, Select, Stack, Text} from '@kerf-js/veneer'export function FormInvite() { const email = state('') const role = state<string | null>('Member') const tried = state(false) const sent = state([ { email: 'noor@kerf.dev', role: 'Viewer' }, ]) const problem = derived(() => !tried.value || EMAIL.test(email.value) ? undefined : "That doesn't look like an email address.", ) const invite = (e: Event) => { e.preventDefault() tried.value = true if (!EMAIL.test(email.value)) return sent.value = [ { email: email.value, role: role.value! }, ...sent.value, ] email.value = '' tried.value = false } return ( <Stack gap="group"> <Stack as="form" gap="group" onSubmit={invite} > <Input label="Email" type="email" icon="mail" value={email.value} error={problem.value} onInput={(e) => (email.value = e.currentTarget.value) } /> <Select label="Role" options={ROLE_OPTIONS} value={role} /> <Button type="submit" full > Send invite </Button> </Stack> <Stack gap="tight"> <Text role="label">Pending</Text> {sent.value.map((s) => ( <Row key={s.email} justify="between" > <Text>{s.email}</Text> <Text subtle>{s.role}</Text> </Row> ))} </Stack> </Stack> )}const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/const ROLE_OPTIONS = [ { value: 'Admin', label: 'Admin', hint: 'Manages people and billing', }, { value: 'Member', label: 'Member', hint: 'Deploys and edits projects', }, { value: 'Viewer', label: 'Viewer', hint: 'Reads logs and metrics', },]When to use
Use it for
- One answer from a list people don't need to compare side by side
- Six options or more
Reach for something else
- RadioGroup, when there are few options and they all deserve reading
- Segmented, when two to four short options switch a view
- CompactSelect, when it sits in a table row or a toolbar
Accessibility
- EnterSpace
- Opens, or picks the highlighted option
- ↑↓
- Moves through the options
- HomeEnd
- Jumps to the first or last
- Esc
- Closes and returns focus
- Aletter
- Jumps to the next option starting with it
- The trigger is a button with the
comboboxrole, and the list alistbox. - A hidden real
<select>holds the value, so a form posts, resets and validates as usual, and with scripting off that select is what shows.
Theming
Restyle it app-wide from veneer() in your kerf config. Every key takes colour, spacing, size, radius, shadow, type and motion; its own keys are listed. A state nests inside: hover: { bg: 'ink' }. Text on a fill you set is contrast-checked at build. How theming works
| Key | Own keys | States |
|---|---|---|
parts.field | bg, radius | hover, focus, press, active, open, disabled |
parts.field.label | text | hover, focus |
veneer({ colors: { brand: '#e8590c', ink: '#1b1b1f' }, parts: { field: { radius: 4, label: { focus: { text: 'brand' } } }, },})API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The name people read and screen readers announce. Always required, and it floats on the border by default. |
options | (Option | OptionGroup)[] | — | The list: { value, label, hint, disabled }, or { group, options } for a named group. |
placeholder | string | — | A first, empty option. |
value | string | State<string | null> | null | — | A string, or a State to bind. |
defaultValue | string | null | — | The first value, when you don't hold it yourself. |
open | boolean | State<boolean> | — | Whether the list is open, when you hold that yourself. |
defaultOpen | boolean | false | Starts open, when the component keeps its own open state. |
presentation | Presentation | — | Force a popover or a sheet. Otherwise it follows the device. |
searchable | boolean | — | A search box in the list. On by default past 15 options. |
| Event | Type | Default | Description |
|---|---|---|---|
onValueChange | ((value: string, reason: ChangeReason) => void) | — | Hears the new value and why it changed. |
onOpenChange | ((open: boolean) => void) | — | Called with true when the list opens and false when it closes. |
onChange | ((e: Event & { currentTarget: HTMLSelectElement; }) => void) | — | Called with the new value. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="select"] [data-part="input"]
inputvaluecarrier