Fields, selects, checkboxes, switches and every other way to type or choose.
Labels sit above fields, hints sit under labels, and errors say what to do. Every control is a real form element underneath, so the browser’s own behaviour comes for free.
18 parts
Label above, hint under it, error in words between the hint and the control, with the ids and aria wiring done for you.
import { useRef, useState, type FormEvent } from 'react'import { checkEmail, checkName } from '@rchursin/product/forms'import { Button, Field, TextInput } from '@rchursin/product/ui'
/** * Label above, hint under it, the error between the hint and the field. The form starts as if * Save was pressed once, so the email shows its error; after that, errors update as you type. */export default function FieldProfile() { const [name, setName] = useState('Alex Rivera') const [email, setEmail] = useState('alex@example') const [website, setWebsite] = useState('') const [tried, setTried] = useState(true) const [saved, setSaved] = useState(false) const nameRef = useRef<HTMLInputElement>(null) const emailRef = useRef<HTMLInputElement>(null)
const nameError = tried ? checkName(name) : undefined const emailError = tried ? checkEmail(email) : undefined
A one-line field in the inset lux-input finish, in two sizes, with an optional icon inside and a slot on the right.
import { useState } from 'react'import { Field, IconButton, TextInput } from '@rchursin/product/ui'
/** A fresh sample secret, made in the click (never during render) */function newSecret() { const bytes = crypto.getRandomValues(new Uint8Array(8)) return `whsec_${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`}
/** * icon draws inside the field on the left; trailing holds a button, a unit or a key hint on the * right. className styles the wrapper when either is set; inputClassName always styles the input. */export default function TextInputBasic() { const [query, setQuery] = useState('') const [secret, setSecret] = useState('whsec_4f9c2e71b8a05d36')
return ( <div className="flex w-full max-w-sm flex-col gap-5"> <Field label="Email">{(p) => <TextInput {...p} type="email" icon="mail" autoComplete="email" spellCheck={false} placeholder="name@example.com" />}</Field>import { useState } from 'react'import { Field, Textarea } from '@rchursin/product/ui'
const draft = `Sessions now pause when the laptop sleeps and pick up where they left off.Reminders snooze in 10, 30 or 60 minutes.The weekly email lists skills with no runs in 30 days.`
/** Type or paste a few lines: the field grows with them, up to eight lines, then scrolls. */export default function TextareaGrow() { const [notes, setNotes] = useState(draft)
return ( <div className="w-full max-w-md"> <Field label="Release notes" hint="One change per line. Shown on the changelog page."> {(p) => <Textarea {...p} name="notes" maxRows={8} value={notes} onChange={(e) => setNotes(e.target.value)} />} </Field> </div> )}The browser’s own select in the field finish, with an up-down chevron, so phones show their own picker and the keys work for free.
import { useState } from 'react'import { Field, Select } from '@rchursin/product/ui'
/** * The browser's own select in the field finish: the native list on phones, type-to-find and * arrow keys on desktop. Option groups and disabled selects work as usual. */export default function SelectSettings() { const [view, setView] = useState('week') const [weekStart, setWeekStart] = useState('sunday') const [zone, setZone] = useState('America/Los_Angeles')
return ( <div className="grid w-full max-w-lg grid-cols-1 gap-5 sm:grid-cols-2"> <Field label="Open the calendar on"> {(p) => ( <Select {...p} name="view" value={view} onChange={(e) => setView(e.target.value)}> <option value="day">Day</option> <option value="week">Week</option> <option value="month">Month</option>A labelled checkbox drawn as the brand square with a cut-in tick, with a hint and a mixed state.
import { useState } from 'react'import { Checkbox } from '@rchursin/product/ui'
const topics = [ { id: 'assigned', label: 'Tasks assigned to me' }, { id: 'comments', label: 'Comments on my tasks' }, { id: 'due', label: 'Tasks due tomorrow', hint: 'Sent at 9:00 in your time zone.' },]
/** * “All task emails” is mixed (a dash) while only some are on; pressing it turns them all on, * or all off. The last box is disabled and says why. */export default function CheckboxNotifications() { const [on, setOn] = useState<string[]>(['assigned', 'due']) const all = on.length === topics.length
function toggle(id: string) { setOn((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id])) }
return ( <fieldset className="flex w-full max-w-sm flex-col gap-3"> <legend className="mb-3 text-[13px] font-medium text-fg">Email me about</legend> <Checkbox label="All task emails" checked={all} indeterminate={on.length > 0 && !all} onChange={() => setOn(all ? [] : topics.map((t) => t.id))} /> <div className="ml-6.5 flex flex-col gap-3"> {topics.map((t) => ( <Checkbox key={t.id} name="notify" value={t.id} label={t.label} hint={t.hint} checked={on.includes(t.id)} onChange={() => toggle(t.id)} /> ))} </div> <Checkbox label="Product news" hint="Turned off by your workspace admin." disabled /> </fieldset> )}A fieldset of native radios, each a ring with the brand square inside when chosen, with optional hints.
import { useState } from 'react'import { RadioGroup } from '@rchursin/product/ui'
type Method = 'passkey' | 'app' | 'email' | 'sms'
/** * Native radios in a fieldset: Tab enters the group at the chosen option, arrow keys move and * choose. A disabled option stays visible and its hint says why. */export default function RadioGroupSignIn() { const [method, setMethod] = useState<Method>('passkey')
return ( <RadioGroup<Method> legend="Second step at sign-in" name="method" className="w-full max-w-sm" value={method} onChange={setMethod} options={[ { value: 'passkey', label: 'Passkey', hint: 'Fingerprint, face or screen lock.' }, { value: 'app', label: 'Authenticator app', hint: 'A 6-digit code that changes every 30 seconds.' }, { value: 'email', label: 'Code by email' }, { value: 'sms', label: 'Code by text message', hint: 'Not offered: texts can be intercepted.', disabled: true }, ]} /> )}A radio group drawn as cards, for choices that each need a sentence, where the whole card is the target.
import { useState } from 'react'import { RadioCards } from '@rchursin/product/forms'
type Visibility = 'private' | 'team' | 'link'
/** * For choices that need a sentence each. The whole card is the target; the chosen one gets a * firmer ring and the radio's square. Cards stack on phones; from 640px wide they sit side by side. */export default function RadioCardsVisibility() { const [visibility, setVisibility] = useState<Visibility>('team')
return ( <RadioCards<Visibility> legend="Who can see this note" name="visibility" className="w-full" value={visibility} onChange={setVisibility} options={[ { value: 'private', label: 'Only me', description: 'Hidden from search and from your team.', icon: 'lock' }, { value: 'team', label: 'My team', description: 'Everyone in the Platform workspace can read it.', icon: 'users' }, { value: 'link', label: 'Anyone with the link', description: 'Read-only. You can turn the link off later.', icon: 'link' }, ]} /> )}An on/off switch for settings that apply at once: label and hint on the left, a sunken track with a pearl thumb on the right.
import { useState } from 'react'import { Switch } from '@rchursin/product/ui'
/** * For settings that apply the moment they change, with no Save button. The label and hint sit * on the left; clicking the label flips the switch too. */export default function SwitchSettings() { const [due, setDue] = useState(true) const [weekly, setWeekly] = useState(false)
return ( <div className="flex w-full max-w-sm flex-col divide-y divide-line"> <Switch className="pb-4" label="Email me when a task is due" hint="Sent at 9:00 in your time zone." checked={due} onChange={setDue} /> <Switch className="py-4" label="Weekly summary" hint="Mondays: what you finished and what slipped." checked={weekly} onChange={setWeekly} /> <Switch className="pt-4" label="Desktop notifications" hint="Blocked in this browser’s site settings." checked={false} onChange={() => {}} disabled /> </div> )}A radio group drawn as one strip, with a lit pill that glides to the choice and arrow keys that move and choose.
import { useState } from 'react'import { Segmented } from '@rchursin/product/ui'
type Period = 'week' | 'month' | 'quarter'
// Sample numbers for the democonst focus: Record<Period, { hours: string; sessions: number }> = { week: { hours: '11 h 40 min', sessions: 23 }, month: { hours: '48 h 15 min', sessions: 96 }, quarter: { hours: '139 h 50 min', sessions: 281 },}
/** * One Tab stop for the whole strip; ← → (or ↑ ↓) move and choose at once, so the numbers * change as you go. The lit pill glides to the choice. */export default function SegmentedPeriod() { const [period, setPeriod] = useState<Period>('week') const { hours, sessions } = focus[period]
The lux-range finish for a native range input: a hairline track that fills to the thumb and a lit pearl thumb.
import { useId, useState, type CSSProperties } from 'react'
const MIN = 5const MAX = 90
/** * A native range input with the lux-range finish. --pct fills the track up to the thumb, the * <output> shows the value, and aria-valuetext says it with its unit (“25 minutes”). */export default function RangeFocusLength() { const id = useId() const [minutes, setMinutes] = useState(25) const pct = ((minutes - MIN) / (MAX - MIN)) * 100
return ( <div className="flex w-full max-w-sm flex-col gap-1.5"> <div className="flex items-baseline justify-between gap-3"> <label htmlFor={id} className="text-[13px] font-medium text-fg"> Focus length </label>A password field with a Show / Hide button and a caps-lock warning, where paste and password managers always work.
import { useState, type FormEvent } from 'react'import { PasswordInput } from '@rchursin/product/forms'import { Button, Field, TextInput } from '@rchursin/product/ui'
/** * Show / Hide reveals what was typed; submitting hides it again, so the browser never saves * the password as plain text. Turn caps lock on while typing to see the warning. */export default function PasswordSignIn() { const [email, setEmail] = useState('alex@example.com') const [password, setPassword] = useState('') const [sent, setSent] = useState(false)
function submit(e: FormEvent) { e.preventDefault() // a sample: nothing leaves the page setSent(true) }
return ( <form noValidate onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4">A one-time code field: one real input painted as six slots, so paste, autofill and screen readers just work.
import { useRef, useState } from 'react'import { OtpInput, messages } from '@rchursin/product/forms'import { Icon } from '@rchursin/product/icons/Icon'import { Field } from '@rchursin/product/ui'
/** The sample's right code. Any other code shows the error. */const SAMPLE_CODE = '424242'
/** * One input painted as six slots. Paste “424 242” or “Your code is 424-242”: only the digits * stay. The sixth digit checks the code at once; a wrong code is selected, so typing starts over. */export default function OtpVerify() { const input = useRef<HTMLInputElement>(null) const [code, setCode] = useState('') const [state, setState] = useState<'idle' | 'checking' | 'verified' | 'wrong'>('idle')
function check(value: string) { setState('checking') window.setTimeout(() => {A number field with − and + steppers, following the APG spinbutton: arrows step, Page keys step ×10, Home and End jump.
import { useState } from 'react'import { NumberField } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** ↑ ↓ step by $5, Page Up / Page Down by $50, Home and End jump to $0 and $500. */export default function NumberFieldBudget() { const [budget, setBudget] = useState(40)
return ( <div className="w-full max-w-xs"> <Field label="Budget alert" hint="Per agent, per week. Sample setting."> {(p) => <NumberField {...p} value={budget} onChange={setBudget} min={0} max={500} step={5} prefix="$" suffix="a week" noun="budget" />} </Field> </div> )}A text field that suggests options as you type, for long lists like time zones or skills (APG list autocomplete).
import { useState } from 'react'import { Combobox, type ComboboxOption } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** Cities by their IANA time zone. Keywords let “Seattle” or “Germany” find the right one. */const zones: ComboboxOption[] = [ { value: 'Pacific/Honolulu', label: 'Honolulu', hint: 'Hawaii' }, { value: 'America/Anchorage', label: 'Anchorage', hint: 'Alaska' }, { value: 'America/Los_Angeles', label: 'Los Angeles', hint: 'Pacific', keywords: ['San Francisco', 'Seattle', 'Vancouver'] }, { value: 'America/Denver', label: 'Denver', hint: 'Mountain', keywords: ['Salt Lake City'] }, { value: 'America/Phoenix', label: 'Phoenix', hint: 'Arizona' }, { value: 'America/Chicago', label: 'Chicago', hint: 'Central', keywords: ['Dallas', 'Austin', 'Mexico City'] }, { value: 'America/New_York', label: 'New York', hint: 'Eastern', keywords: ['Boston', 'Toronto', 'Miami'] }, { value: 'America/Sao_Paulo', label: 'São Paulo', hint: 'Brazil', keywords: ['Sao Paulo', 'Rio de Janeiro'] }, { value: 'Europe/London', label: 'London', hint: 'UK', keywords: ['Dublin', 'Lisbon'] }, { value: 'Europe/Berlin', label: 'Berlin', hint: 'Central Europe', keywords: ['Germany', 'Paris', 'Amsterdam', 'Madrid'] }, { value: 'Europe/Kyiv', label: 'Kyiv', hint: 'Eastern Europe', keywords: ['Ukraine', 'Athens', 'Helsinki'] }, { value: 'Asia/Dubai', label: 'Dubai', hint: 'Gulf' }, { value: 'Asia/Kolkata', label: 'Kolkata', hint: 'India', keywords: ['Bengaluru', 'Mumbai', 'Delhi'] }, { value: 'Asia/Singapore', label: 'Singapore', hint: 'Singapore' }, { value: 'Asia/Tokyo', label: 'Tokyo', hint: 'Japan', keywords: ['Osaka'] }, { value: 'Australia/Sydney', label: 'Sydney', hint: 'Australia East', keywords: ['Melbourne'] }, { value: 'Pacific/Auckland', label: 'Auckland', hint: 'New Zealand' },]
export default function ComboboxTimeZone() { const [zone, setZone] = useState<string | null>('America/Los_Angeles')
return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Field label="Time zone" hint="Type a city."> {(p) => <Combobox {...p} name="time-zone" listLabel="Time zones" options={zones} value={zone} onChange={setZone} placeholder="Type a city" />} </Field> <p className="font-mono text-[11px] text-fg-3">value: {zone ?? 'null'}</p> </div> )}A file picker whose real control is a “Choose file” button; dropping a file on the zone is a shortcut, never the only way.
import { useState } from 'react'import { FileDrop } from '@rchursin/product/forms'
/** * “Choose file” opens the file dialog; dropping a file on the dashed zone does the same. Type and * size are checked at once: try an image to see the error. Nothing is uploaded in this sample. */export default function FileDropImport() { const [file, setFile] = useState<File | null>(null)
return <FileDrop label="Import runs" accept={['text/csv', 'application/json']} maxBytes={5 * 1024 * 1024} value={file} onChange={setFile} className="w-full max-w-md" />}A textarea that counts down to its limit, GOV.UK style: it never cuts text off, and speaks the count when typing pauses.
import { useState } from 'react'import { CharacterCount } from '@rchursin/product/forms'import { Field } from '@rchursin/product/ui'
/** Counts down as you type. Screen readers hear the limit up front, and the count once typing pauses. */export default function CharacterCountBio() { const [bio, setBio] = useState('Platform lead. I look after our coding agents and the skills they run.')
return ( <div className="w-full max-w-md"> <Field label="Short bio" hint="Shown next to your name in reports." optional> {(p) => <CharacterCount {...p} name="bio" value={bio} onChange={setBio} maxLength={160} />} </Field> </div> )}A filterable list of options, on its own or in a popover: type to filter, arrows and Enter to choose, or 1–9 to pick at once.
import { useState } from 'react'import { Icon } from '@rchursin/product/icons/Icon'import { PickerButton, type PickerOption } from '@rchursin/product/ui'
const projects: PickerOption<string>[] = [ { value: 'platform', label: 'Platform', icon: 'layers' }, { value: 'agents', label: 'Agents', icon: 'agent' }, { value: 'skills', label: 'Skills', icon: 'skill', keywords: ['prompts'] }, { value: 'telemetry', label: 'Telemetry', icon: 'activity', keywords: ['otel', 'traces'] }, { value: 'docs', label: 'Docs', icon: 'file-text' }, { value: 'billing', label: 'Billing', icon: 'dollar', hint: 'No access', disabled: true },]
/** * The trigger, popover and filterable list in one. Open it, then press 1–5 to choose without * typing, or type to filter and press Enter. Esc closes and focus returns to the button. */export default function PickerProject() { const [project, setProject] = useState('platform') const current = projects.find((p) => p.value === project)!
return ( <PickerButton label="Project" numberKeys placeholder="Move to project…" options={projects} value={project} onChange={setProject} buttonClassName="lux-btn lux-btn-paper h-8 px-2.5 text-[13px] font-medium" buttonLabel={`Project: ${current.label}. Change project`} > {current.icon && <Icon name={current.icon} className="size-4 text-fg-2" />} {current.label} <Icon name="chevrons-up-down" className="size-3.5 text-fg-3" /> </PickerButton> )}The Tasks tool’s pickers for status, priority, assignee, labels and estimate, as a chip, a row or an icon.
import { useState } from 'react'import { labels as sampleLabels } from '@rchursin/product/data/sample'import { SampleBadge } from '@rchursin/product/shell/layout'import { AssigneePicker, EstimatePicker, LabelsPicker, PriorityPicker, StatusPicker, type Label, type PriorityId, type StatusId } from '@rchursin/product/tasks'
/** * A task's properties as chips. Each opens a filterable list: type to filter, ↑ ↓ and Enter, * or 1–9 to choose at once. Labels take several values and can create new ones. */export default function PropertyPickersTask() { const [status, setStatus] = useState<StatusId>('in_progress') const [priority, setPriority] = useState<PriorityId>(1) const [assignee, setAssignee] = useState<string | null>('me') const [labels, setLabels] = useState<string[]>(['skill', 'bug']) const [allLabels, setAllLabels] = useState<Label[]>(sampleLabels) const [estimate, setEstimate] = useState<number | null>(3)
function createLabel(name: string): Label { const label = { id: name.toLowerCase().replace(/\s+/g, '-'), name } setAllLabels((all) => (all.some((l) => l.id === label.id) ? all : [...all, label])) return label }
return ( <div className="flex w-full max-w-lg flex-col gap-3"> <div className="flex items-center justify-between gap-3"> <span className="font-mono text-[11px] text-fg-3">RC-128</span> <SampleBadge /> </div> <p className="text-[15px] font-semibold tracking-[-0.01em] text-balance text-fg">Fix test-fixer: fails more than half its runs</p> <div className="flex flex-wrap gap-1.5"> <StatusPicker value={status} onChange={setStatus} /> <PriorityPicker value={priority} onChange={setPriority} /> <AssigneePicker value={assignee} onChange={setAssignee} /> <LabelsPicker value={labels} onChange={setLabels} labels={allLabels} onCreate={createLabel} /> <EstimatePicker value={estimate} onChange={setEstimate} /> </div> </div> )}Press Esc to clear.
Sample value. Make a new one if the old one leaked.
One change per line. Shown on the changelog page.
Set by your workspace.
Sent at 9:00 in your time zone.
Mondays: what you finished and what slipped.
Blocked in this browser’s site settings.
Sample numbers.
Applies from the next session.
Sample code: 424242. You can paste the whole code.
Per agent, per week. Sample setting.
Type a city.
value: America/Los_Angeles
Shown next to your name in reports.
You can enter up to 160 characters
Fix test-fixer: fails more than half its runs