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/warpUsage
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
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.
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