Skip to content
weftGitHub

Select

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

SourcePackage @weft-js/warpPlayground

Preview

tsx
import { Select } from '@weft-js/warp'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 @weft-js/warp

Usage

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

Sizes

Use size to pick sm, md or lg.

tsx
import { Select, Stack } from '@weft-js/warp'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 '@weft-js/warp'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 '@weft-js/warp'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 '@weft-js/warp'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 '@weft-js/warp'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, Stack, Text } from '@weft-js/warp'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 = (p: { name: string; children?: unknown }) => (  <Stack gap="tight">    <Text role="caption" subtle>{p.name}</Text>    {p.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 '@weft-js/core'import { Select, Stack, Text } from '@weft-js/warp'export function Linked() {  const project = state<string | null>('mobile-bff')  const picked = derived(() => PROJECTS.find(p => p.id === project.value)!)  return (    <Stack gap="related">      <Select label="Project" searchable options={PROJECT_OPTIONS} value={project} />      <Text subtle>{picked.value.status} in {picked.value.region}, {picked.value.deploys} deploys so far.</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 '@weft-js/core'import { Button, Input, Row, Select, Stack, Text } from '@weft-js/warp'export function FormInvite() {  const email = state('')  const role = state<string | null>('Member')  const tried = state(false)  const sent = state([{ email: 'noor@weft.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

Playground

Size
Variant
import { Select } from '@weft-js/warp'<Select options={REGIONS} label="Region" hint="Moving a project redeploys it." />

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.

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