Forms & inputs
Six controls (src/components/ui/) share one look. Input, Select and Textarea all build on the same inputClass string from Input.tsx — a token-bordered surface field — so they line up pixel-for-pixel; Label and Field handle the label/hint/error rhythm and the aria wiring; Toggle is the only stateful one (a controlled role=switch). All are thin wrappers over native elements, so HTML attributes (name, required, value, onChange, type, rows, aria-*) pass straight through. Flip the theme in the top bar to confirm every state re-skins via the semantic tokens; tab into any field to see the focus ring.
- inputClass
- Exported from Input.tsx and reused by Select + Textarea: w-full, rounded-md, border-[var(--color-border)], bg-surface, px-3 py-2.5, text-sm, placeholder:text-faint, focus:border-accent, disabled:opacity-50 disabled:cursor-not-allowed.
- focus ring
- Not a component class — globals.css applies box-shadow: var(--fx-focus-ring) on input/select/textarea:focus-visible (the two-layer Save the Dogs ring), and inputClass adds focus:border-accent. Outline is removed in favour of the ring.
- labelClass
- Exported from Label.tsx: block, text-xs, font-medium, uppercase, tracking-wider, mb-1 — the small caps field label used everywhere.
- Field
- label + control + hint OR error wrapper (mb-4). Pass htmlFor and it derives a {htmlFor}-hint / {htmlFor}-error id, clones the child to add aria-describedby, and on error adds aria-invalid.
- native passthrough
- Input/Select/Textarea spread their HTML attribute types — name, required, value/defaultValue, onChange, placeholder, disabled, type, rows, aria-* all land on the underlying element.
- className
- Merged last via cn() (tailwind-merge), so a passed utility overrides the base — e.g. a fixed width or a taller textarea.
import { Field, Input } from "@/components/ui";
<Field htmlFor="email" label="Email" hint="We only email about rescues.">
<Input id="email" name="email" type="email" placeholder="you@example.org" />
</Field>Input
<Input> — a native <input> styled with inputClass. It carries no internal state; pass any input attribute (type, placeholder, value/defaultValue, required, disabled). The four states below — default, focused, disabled, error — are the canonical lifecycle of every field.
The ring is applied by globals.css on :focus-visible, so keyboard focus shows it; click-focus on a control already focused won't.
- placeholder
- Rendered in text-faint. Guidance only — never a substitute for a real <Label> / Field label.
- type
- Any native input type (text default, email, tel, password, number, date…) passes straight through.
- disabled
- opacity-50 + cursor-not-allowed (baked into inputClass). For invalid state, use the Field error variant rather than recolouring the Input.
- className
- Override the full-width default for inline fields, e.g. className="w-32" for a short numeric input.
Select
<Select> — a native <select> styled to match Input (inputClass + cursor-pointer pr-8 for the chevron gutter). Native is deliberate: keyboard navigation and the mobile picker come for free with zero client JS. Use it for bounded option sets (here: focus area). Richer menus use Radix elsewhere.
- options
- Pass native <option> children. The demo set is Animal welfare / Policy & advocacy / Research.
- placeholder pattern
- defaultValue="" plus a first <option value="" disabled hidden> gives a non-selectable prompt — pair with required so an empty submit is blocked.
- cursor-pointer pr-8
- Added on top of inputClass: a pointer cursor and right padding so text never collides with the native chevron.
- why native
- Keyboard + mobile native picker for free, zero client JS. Reach for Radix only when you need a rich, searchable or multi-select menu.
Textarea
<Textarea> — a native <textarea> on the same inputClass, plus resize-y and min-h-[88px]. It grows vertically only (so it never breaks the column grid) and starts ~3 lines tall. Same default / focused / disabled / error states as Input.
- resize-y
- Users can drag to grow/shrink height only — width stays locked to the column.
- min-h-[88px]
- The starting height (~3 lines). Override with a taller min-h-[…] via className, or set rows.
- rows
- Native attribute, passes through — a quick way to set the initial line count.
Label
<Label> — a native <label> with labelClass: small (text-xs), medium-weight, UPPERCASE with tracking-wider, mb-1. Set htmlFor to bind it to a control's id (or wrap the control). Field renders a Label for you; use Label directly only for bespoke layouts.
- labelClass
- block text-xs font-medium uppercase tracking-wider mb-1 — the small-caps field label.
- htmlFor
- Bind to the control's id for click-to-focus and AT association. Field wires this automatically when you pass it htmlFor.
- vs Field
- Field already renders a Label from its label prop — only reach for <Label> directly in custom layouts (e.g. a Toggle row, or inline controls).
Field
<Field> — the label + control + hint/error wrapper that gives every form its rhythm (mb-4). Pass label, the control as the single child, and either hint OR error (error wins). With htmlFor it derives the message id, clones the child to add aria-describedby, and on error adds aria-invalid — so screen readers announce the helper text as the field's description and flag invalid fields. This is the default way to lay out a field; raw Label + Input is the exception.
Shown to other volunteers in your district.
Enter a valid email address.
ZIP must be 5 digits.
Where you'd like to help first.
Please add a sentence or two.
- label
- Optional string → rendered through <Label htmlFor>. Omit it for an unlabelled wrapper.
- htmlFor
- Binds the Label, and is the base for the derived message id ({htmlFor}-hint / {htmlFor}-error). Set the same value as the control's id.
- hint
- Muted helper text below the control (mt-1 text-xs text-muted). Hidden when error is present.
- error
- Error message in text-bad. Takes precedence over hint, and triggers aria-invalid + aria-describedby on the cloned child.
- aria wiring
- Best-effort: only when the single child is a valid element. A caller's own aria-describedby is preserved (merged); an explicit child id wins over the derived one. Never throws.
Toggle
<Toggle> — a controlled switch (role=switch, aria-checked). It is the one stateful control: it takes checked + onChange(next) and renders nothing on its own, so the live demos below are driven by a tiny colocated 'use client' wrapper (ToggleDemo). On = bg-accent with the knob translated right; off = bg-surface-3. Never rely on the colour alone — always pair a switch with a visible label.
Email notifications
District alerts and action invitations.
SMS notifications
Time-sensitive reminders by text.
- checked
- Required boolean — the current state. Toggle is controlled; you own the value (here via useState in ToggleDemo).
- onChange
- (next: boolean) => void. Fires with the flipped value on click. Omit it (or set disabled) for a read-only switch.
- disabled
- opacity-50 + cursor-not-allowed; onClick is a no-op. Default false.
- label
- Fallback string → aria-label, BUT only used when ariaLabelledby is absent (a referenced visible label wins).
- ariaLabelledby / ariaDescribedby
- Reference the ids of visible label / hint text. Preferred over label when the copy is on screen, so AT reads the real text instead of a hidden duplicate.
- on vs off
- On = bg-accent + knob translate-x-5; off = bg-surface-3 + knob translate-x-0.5. Colour never carries meaning alone — the label does.
Filter toolbar (.command-bar)
A globals.css component class (not a React component) for the compact filter / search strip above tables and lists. It's a flex row (gap-8px, padding 8/12, rounded-lg, bg-surface, token border, font-size 13). Its descendant input / select get a denser 32px-tall treatment with the same focus accent + ring as the form controls. Drop plain <input>/<select>/<Button> inside a div.command-bar.
- .command-bar
- The wrapper: flex, align-items center, gap 8px, padding 8px 12px, bg-surface, 1px token border, radius-lg, font-size 13px.
- .command-bar input / select
- Scoped descendant rule: height 32px, padding 0 10px, bg-bg, token border + radius-control, font-size 13 — denser than the standalone Input.
- focus
- .command-bar input/select:focus → border-color: accent + box-shadow: --fx-focus-ring, matching the rest of the form system.
- use it for
- The filter strip above a table or list. Compose with plain native inputs/selects and a small secondary Button — not the bigger standalone form controls.
Putting it together — a volunteer sign-up form
A realistic composition: Fields stacked for rhythm, a two-up row on wider screens, a Select for the bounded focus area, a Textarea for free text, a Toggle row for a preference, an inline error, and a primary/secondary footer. This is the shape most forms in the app should take.
- rhythm
- Each Field's mb-4 gives uniform vertical spacing — no manual margins between rows.
- two-up rows
- Group related short fields in a grid grid-cols-1 sm:grid-cols-2 gap-x-4; the Field margins still handle vertical gaps.
- footer
- Right-aligned, one primary (submit) + a ghost escape (cancel) — see the Buttons page for the pairing rules.
- validation
- Surface errors through the Field error prop (text-bad + aria-invalid), not by recolouring the control itself.