Resolved: light

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

default — placeholder is text-faint
filled — value is text-[var(--color-text)]

The ring is applied by globals.css on :focus-visible, so keyboard focus shows it; click-focus on a control already focused won't.

focused — tab in: --fx-focus-ring + focus:border-accent
disabled — opacity-50 + cursor-not-allowed
type="email"
type="tel"
type="password"
type variants — type passes through to the native input
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.

default — first option selected
with a placeholder option (disabled + hidden)
focused — tab in: same --fx-focus-ring as Input
disabled — opacity-50 + cursor-not-allowed
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.

default — resize-y, min-h-[88px] (~3 lines)
filled
focused — tab in for the --fx-focus-ring
disabled — opacity-50 + cursor-not-allowed
taller — override min height via className (rows also works)
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.

Label + Input bound by htmlFor / id
the bare label style (labelClass)
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.

label only

Shown to other volunteers in your district.

label + hint — hint is text-muted, id={htmlFor}-hint

Enter a valid email address.

error variant — message is text-bad, aria-invalid set

ZIP must be 5 digits.

error wins over hint (pass both → error shows)

Where you'd like to help first.

Please add a sentence or two.

Field also wraps Select and Textarea — same hint/error wiring
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.

checked={true}
checked={false}
disabled, checked
disabled, unchecked
on / off / disabled — each is interactive except the disabled pair

Email notifications

District alerts and action invitations.

SMS notifications

Time-sensitive reminders by text.

settings row — accessible labelling via aria-labelledby / aria-describedby
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 — search + select filters + action
.command-bar — minimal: just a search field
.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.

We couldn't reach this address — double-check it.

Finds your representatives.

You can change this later.

A sentence or two is plenty.

Email me action alerts

District news and ways to get involved. Unsubscribe anytime.

signup form — Field + Input/Select/Textarea + Toggle + Button footer
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.