Skip to content
kerfjsGitHub

Select

Pick one value from a list. A menu on a desktop, a sheet on a phone.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
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/veneer

Try it

Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.

Live preview
Controls
Size
Variant
Codeupdates as you change the controls
import { Select } from '@kerf-js/veneer'<Select options={REGIONS} label="Region" hint="Moving a project redeploys it." />

Usage

tsx
import { Select } from '@kerf-js/veneer'<Select  label="Region"  options={REGION_OPTIONS}  defaultValue="eu-west"/>

Sizes

Use size to pick sm, md or lg.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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 combobox role, and the list a listbox.
  • 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

KeyOwn keysStates
parts.fieldbg, radiushover, focus, press, active, open, disabled
parts.field.labeltexthover, focus
ts
veneer({  colors: { brand: '#e8590c', ink: '#1b1b1f' },  parts: {    field: { radius: 4, label: { focus: { text: 'brand' } } },  },})

API Reference

PropTypeDefaultDescription
labelstring—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.
placeholderstring—A first, empty option.
valuestring | State<string | null> | null—A string, or a State to bind.
defaultValuestring | null—The first value, when you don't hold it yourself.
openboolean | State<boolean>—Whether the list is open, when you hold that yourself.
defaultOpenbooleanfalseStarts open, when the component keeps its own open state.
presentationPresentation—Force a popover or a sheet. Otherwise it follows the device.
searchableboolean—A search box in the list. On by default past 15 options.
EventTypeDefaultDescription
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

RelatedCompact selectRadio groupSegmented