Forms
Token-styled, accessible form primitives — core fields, pickers, entry and selection controls, a Tiptap rich-text editor, and six form demo pages with a pure validate() pattern.
The form primitives live in src/components/ui/ and are re-exported from the barrel, so you import them
from one place:
import {
Input, Textarea, Select, Combobox, Label, Checkbox, Radio, Field,
DatePicker, TimePicker, DateRangePicker, ColorPicker,
PasswordInput, TagsInput, PhoneInput, OtpInput, FileUpload,
ToggleGroup, OptionCard, Rating, Slider, Stepper,
} from '@/components/ui'Two deliberate exceptions live outside the barrel:
RichTextEditoris imported directly from@/components/ui/RichTextEditor— it pulls in Tiptap/ProseMirror (~340 kB) and must stay out of the eagerly-loaded barrel.Editable(click-to-edit, x-editable style) is a feature component insrc/components/editable/Editable.tsx.
They share one design language: one shared field style (fieldBase), colors from tokens only,
accessible by construction (visually-hidden native inputs, aria-invalid wiring, htmlFor
association, keyboard support), and i18n-agnostic (user-facing strings arrive as props). Field ties
a Label, a control, and a hint/error line together.
Validation is plain functions, not a form library
A pure validate(values) helper returns a per-field error map of i18n keys, rendered via t(). See
the validated-form example.
Architecture & files
Primitives (all in src/components/ui/, barrel-exported unless noted):
| File | Exports |
|---|---|
Input.tsx | Input, fieldBase, InputSize, INPUT_SIZE |
Textarea.tsx | Textarea |
Select.tsx | Select, SelectOption (type) |
Combobox.tsx | Combobox, ComboboxOption (type) |
Label.tsx / Checkbox.tsx / Radio.tsx / Field.tsx | Label / Checkbox / Radio / Field |
DatePicker.tsx | DatePicker, PickerTrigger, DATEPICKER_PORTAL |
TimePicker.tsx / DateRangePicker.tsx / ColorPicker.tsx | TimePicker / DateRangePicker, DateRange / ColorPicker |
PasswordInput.tsx | PasswordInput, PasswordRequirementLabels (type) |
TagsInput.tsx / PhoneInput.tsx / OtpInput.tsx / FileUpload.tsx | those four components |
ToggleGroup.tsx / OptionCard.tsx / Rating.tsx / Slider.tsx / Stepper.tsx | those five components |
RichTextEditor.tsx | RichTextEditor — not in the barrel, import by path |
Supporting modules: src/components/editable/Editable.tsx (click-to-edit), src/lib/masks.ts
(format-on-type helpers), src/lib/password.ts (passwordScore), src/lib/datetime.ts
(local-timezone string↔Date bridges), src/data/countries.ts (dial codes for PhoneInput),
src/styles/_datepicker.scss and src/styles/_editor.scss (third-party theming through raw token vars).
Third-party libraries (all MIT): react-select (+ creatable for Combobox), react-datepicker,
react-colorful (ColorPicker), and Tiptap (RichTextEditor).
Usage
Wrap each control in a Field for a consistent label + hint/error row:
<Field label="Email" htmlFor="email" hint="We'll never share your email.">
<Input id="email" type="email" placeholder="jane@example.com" />
</Field>Field renders the Label (with an optional required asterisk) above your control and a hint/error line
below. Pass error to switch the line to danger styling; set invalid on the control to turn its border
and ring red. Every control accepts invalid and an id, so they all drop into Field the same way.
Core field primitives
Input
Text-like input. Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'>; forwards its ref. When
any addon prop is set, it renders as an input group — a focus-within wrapper carries the
border/ring and the inner input goes borderless.
| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | — | Sets aria-invalid → danger border + ring (via fieldBase). |
inputSize | 'sm' | 'md' | 'lg' | 'md' | Height/padding/text size (h-8/h-10/h-12). |
prefix | ReactNode | — | Text/element addon flush left, inside the border (e.g. https://). |
suffix | ReactNode | — | Addon flush right (e.g. .00, a copy button). |
leadingIcon | ReactNode | — | Icon inside the field at the start. |
trailingIcon | ReactNode | — | Icon inside the field at the end (e.g. a live-validation check). |
className | string | — | Extra classes (merged after the size classes). |
| …rest | native input attributes | — | All native input attributes (minus prefix). |
Textarea
Multi-line input on the same fieldBase. Props: invalid, inputSize ('sm' | 'md' | 'lg'), rows
(default 4), className, plus native textarea attributes.
Select
A react-select wrapper (not a native <select>). Fully themed via classNames + unstyled; the
menu is portaled to <body> so it isn't clipped. Single-select by default; pass isMulti for a
tag-style multi-select — the props are a discriminated union, so value/defaultValue/onChange
become string[]-typed when isMulti is set.
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectOption[] | — | {value, label, isDisabled?}[]. |
isMulti | boolean | false | Multi-select; values render as removable chips. |
value / defaultValue | string (or string[] with isMulti) | — | Controlled / uncontrolled value(s). |
onChange | (v: string) => void (or string[]) | — | Called with the value(s) ('' when cleared, single). |
placeholder | string | — | Placeholder text. |
invalid / disabled / isSearchable | boolean | — | Danger state / disable / allow typing to filter. |
size | 'sm' | 'md' | 'lg' | 'md' | Control min-height + font size (matches InputSize). |
id / name / className / aria-label | string | — | Standard field props. |
Font-size note
The component sets the per-size font size on the control and menu via the styles prop (not classes) —
Tailwind v4 emits text-sm in an @layer, which react-select's unlayered Emotion classes override on
the portaled menu, so the styles value wins. The styles prop also sets the menu z-index
(menuPortal.zIndex: 60). Don't remove it.
Combobox
Searchable single-select that can also create a new option on the fly — built on
react-select/creatable, themed identically to Select, isClearable, menu portaled to <body>. Props:
options (ComboboxOption[]), value, onChange ('' when cleared; also fires with the typed label on
create), createLabel, placeholder, invalid, disabled, plus id/name/className/aria-label.
Label / Checkbox / Radio / Field
Label— extendsLabelHTMLAttributes;requiredshows a danger*after the text.Checkbox/Radio— token-styled; the native input is visually hidden as apeer, the box + indicator are sibling layers driven bypeer-checked:. Props:label,disabled,className, plus native input attrs (minustype).namegroups radios.Field— the standard label + control + hint/error row:
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Renders a Label above the control (omit for label-less rows). |
htmlFor | string | — | Associates the label with a control id. |
required | boolean | — | Passes through to the Label (danger asterisk). |
hint | ReactNode | — | Helper text below the control (hidden when error is set). |
error | ReactNode | — | Error text; replaces the hint and colors the line danger. |
className | string | — | Extra classes on the row wrapper. |
children | ReactNode | — | The control. |
Date, time & color pickers
The three date/time pickers wrap react-datepicker with a shared fieldBase-styled button trigger
(PickerTrigger) and a body-level portal (DATEPICKER_PORTAL = 'datepicker-portal') so the popper
escapes panel/modal overflow: hidden. Values are Date | null — bridge string state with
src/lib/datetime.ts.
Props: value / onChange (Date | null), min/max, dateFormat (date-fns; defaults 'PP',
'PP p' with showTime, 'MMM yyyy' with monthYear), showTime, monthYear, inline (no
trigger/portal), timeIntervals (default 15), placeholder, invalid/disabled, id/className.
Entry controls
PasswordInput— show/hide toggle, an optional strength meter (a single danger→success gradient bar revealed byclip-path) and a requirements checklist. Scoring comes from the purepasswordScore(). Props:value/onChange(plain string),strength,strengthLabels([string×4]),requirements+requirementLabels({length, case, number, symbol}),showLabel/hideLabel, plus standard field props.TagsInput— tag/token input onfieldBase— type + Enter to add, Backspace or × to remove; de-dupes and respectsmax. Props:value(string[]),onChange,max,removeLabel, plus standard field props.PhoneInput— a dial-code country picker (portaledPopover, data fromsrc/data/countries.ts) + a masked national number (maskPhone). Props:value,onChange(masked value),onCountryChange,defaultCountry(default'US'),placeholder, plus standard field props.OtpInput— segmented one-time-code / PIN input with auto-advance, backspace-to-previous, arrow-key nav, paste-to-fill. Props:length(default6),value,onChange,onComplete,type('number' | 'text', default'number'), plus group attrs.FileUpload— click-to-browse + drag-and-drop dashed drop zone (keyboard-activatable), with a selected-file list. Controlled (files+onFiles) or uncontrolled. Props:multiple,accept,files,onFiles,browseLabel,dropLabel,hint,removeLabel, plus standard props.
Selection & step controls
ToggleGroup— segmented / toggle button group, single-select by default ormultiple(a discriminated union). Buttons carryaria-pressed. Props:options(ToggleOption[], label may be a node),multiple,value,onChange,size('sm' | 'md'),className/aria-label.OptionCard— selectable card/tile with no visible checkbox or radio — the card itself is the control (aria-pressed). Four variants:default,horizontal,filled,plain. Props:selected,onSelect,title,description,icon,variant, plus standard props.Rating— star rating, interactive (with hover preview) whenonChangeis passed and notreadOnly. Stars use thewarningtoken. Props:value,onChange,max(default5),readOnly,size,clearable,className/aria-label.Slider— a native<input type="range">with a token-filled track and a styled thumb. Props:value,onChange(number),min/max(0/100),step(default1),showValue,formatValue, plus standard props.Stepper— horizontal step indicator (done / current / upcoming) with connector lines. Props:steps(StepperStep[]),current(zero-based),onStepClick(if set, completed/active steps become clickable — no jumping ahead),className.
RichTextEditor
Rich-text editor built on Tiptap (MIT, headless) with a token-styled toolbar. Controlled via an
HTML string value. Extensions: StarterKit + Underline + Link + Placeholder + TextAlign. The full
toolbar covers bold/italic/underline/strike, H1–H3, lists, quote, code block, link popover, text align,
clear formatting, and undo/redo; minimal trims it to bold/italic/underline/bullet-list/link.
Not barrel-exported — import directly:
import {RichTextEditor} from '@/components/ui/RichTextEditor'| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Current value as an HTML string. |
onChange | (html: string) => void | — | Fired with editor.getHTML() on every update. |
placeholder | string | — | Empty-document placeholder. |
minimal | boolean | — | Compact toolbar. |
readOnly | boolean | — | View-only: hides the toolbar, keeps content crisp. |
disabled | boolean | — | Locks editing and dims the whole control. |
invalid | boolean | — | Danger border (pairs with Field error). |
linkLabels | {add, remove, url, apply} strings | English fallbacks | Labels for the link popover — pass t() values. |
className / aria-label | string | — | Wrapper class / textbox aria-label. |
Content styling lives in src/styles/_editor.scss, and the same rules style the .rte-preview class,
so rendering saved HTML read-only looks identical to the editing surface.
Keep RichTextEditor out of the barrel
The barrel is imported by the eager app entry; routing Tiptap/ProseMirror (~340 kB) through it would
hoist the editor into the main bundle. Import it by path from lazy pages only — its two consumers
(EditorsPage, FormPluginsPage) are both React.lazy-loaded, so Tiptap stays in their async chunks.
Editable (inline edit)
src/components/editable/Editable.tsx — a click-to-edit value shown as an x-editable-style
dashed-underline link. Two modes: popup (default — a portaled Popover with the control +
Save/Cancel) and inline (swaps the control in place). Four control types: text, textarea,
select (uses Select, optionally searchable), and date (an inline DatePicker; string values
bridge via parseLocalDate/formatLocalDate). Props: type (default 'text'), value, onCommit,
mode, options, searchable, side/align, emptyLabel, saveLabel/cancelLabel,
className/aria-label.
Configuration & customization
fieldBase— exported fromInput.tsx(and re-exported from the barrel) as the single source of truth for text-field styling (border, background, padding, placeholder color, focus ring, disabled state,aria-[invalid=true]danger ring). Reuse it for any new field-like control:<input className={cn(fieldBase, 'h-10')} />.- Input sizes —
InputSize = 'sm' | 'md' | 'lg'is shared across the text controls. The class map is exported asINPUT_SIZE. invalidanderrorstates — two coordinated flags:invalidon the control setsaria-invalidand/or turns the border + ring danger;erroron theFieldreplaces the hint with a danger message. Use them together.- Input masks —
src/lib/masks.tsholds pure format-on-type helpers:maskCardNumber,maskExpiry,maskCvc,maskPhone, and thedigitsstripper. Use them inline in a controlledonChange. - Password scoring —
src/lib/password.tsexportspasswordScore(pw)→{score: 0–4, tone}. - Local date/time helpers —
src/lib/datetime.tsbridges string state with the Date-based pickers. A barenew Date('YYYY-MM-DD')parses as UTC midnight and shifts a day in western timezones — always useparseLocalDate/formatLocalDate(and the time equivalents) when persisting picker values as strings. - Picker/editor theming — both third-party surfaces are themed only through raw token vars (no
@apply, no hex, no--color-*) in_datepicker.scssand_editor.scss.
A complete validated form
The FormValidationPage pattern — a pure validate() returns a per-field map of i18n keys;
validation runs on blur (for touched fields) and on submit. No form library.
interface Values { name: string; email: string; plan: string; terms: boolean }
type Errors = Partial<Record<keyof Values, string>>
/** Pure validation — returns a per-field error-KEY map (empty = valid). */
function validate(v: Values): Errors {
const e: Errors = {}
if (!v.name.trim()) e.name = 'validationErrNameRequired'
if (!v.email.trim()) e.email = 'validationErrEmailRequired'
else if (!isEmail(v.email)) e.email = 'validationErrEmailInvalid'
if (!v.plan) e.plan = 'validationErrPlanRequired'
if (!v.terms) e.terms = 'validationErrTermsRequired'
return e
}
function CreateAccount() {
const {t} = useTranslation('forms')
const [values, setValues] = useState<Values>({name: '', email: '', plan: '', terms: false})
const [errors, setErrors] = useState<Errors>({})
const err = (k: keyof Values) => (errors[k] ? t(errors[k]!) : undefined)
function submit(e: React.FormEvent) {
e.preventDefault()
const found = validate(values)
setErrors(found)
if (Object.keys(found).length === 0) toast({title: t('validationSubmitToastTitle'), tone: 'success'})
}
return (
<form onSubmit={submit} noValidate className="space-y-4">
<Field label={t('validationFullName')} htmlFor="name" required error={err('name')}>
<Input id="name" value={values.name} invalid={!!errors.name}
onChange={(e) => setValues({...values, name: e.target.value})} />
</Field>
<Field error={err('terms')}>
<Checkbox label={t('validationTerms')} checked={values.terms}
onChange={(e) => setValues({...values, terms: e.target.checked})} />
</Field>
<Button type="submit">{t('validationSubmit')}</Button>
</form>
)
}Input group with addons + a masked value:
<Input id="card" inputMode="numeric" value={card}
onChange={(e) => setCard(maskCardNumber(e.target.value))}
leadingIcon={<CreditCard className="h-4 w-4" />} placeholder="4242 4242 4242 4242" />Demo pages
Six pages in src/pages/forms/, wired via appRoutes in src/routes.tsx (two are lazy). All page copy
lives in the forms i18n namespace (~590 keys).
| Page | Route | Lazy | Shows |
|---|---|---|---|
| Form Elements | /forms/elements | no | Every control: types, sizes, addons, tiles, pickers, upload, states. |
| Form Layouts | /forms/layouts | no | Layout patterns (vertical/horizontal/inline/two-col/floating) + complete forms (settings, checkout, sticky footer, wizards). |
| Form Validation | /forms/validation | no | Pure validate() — submit/blur/live timing, async check, error summary. |
| Form Plugins | /forms/plugins | yes | The "plugin-alternatives" controls gallery: pickers, multi-select, tags, masks, rich text. |
| Inline Editable | /forms/inline-editable | no | Editable — popup/inline modes, text/select/textarea/date, placements. |
| Editors | /forms/editors | yes | RichTextEditor — validated article form, live preview, count/autosave/read-only variants. |
Highlights: Form Validation demos submit / blur (with a touched map) / live as-you-type timing plus
special cases (password + confirm, a debounced async availability check with a spinner, constraint
validation, and an error summary Alert whose entries focus their field via refs). Editors
validates the rich-text body on the stripped plain text via a plainText() HTML-stripper (an "empty"
Tiptap document is <p></p>, which is truthy).
Best practices
- Import from the barrel
@/components/ui— exceptRichTextEditor(by path, lazy pages only) andEditable(@/components/editable/Editable). - Wrap every control in a
Fieldand associate the label withhtmlFor+ controlid. - Reuse
fieldBase(andINPUT_SIZE) for any new field-like control instead of re-declaring styling. - Keep validation pure: a
validate(values) => Errorsreturning i18n keys, called on blur/submit. - Pair
invalid(on the control) witherror(on theField) so the visual and the message agree. - For
Select/Combobox, drive them withvalue/onChange+ anoptionsarray — never<option>. - Keep picker state as
Date | null; convert withsrc/lib/datetime.ts(nevernew Date('YYYY-MM-DD')). - Validate rich-text emptiness on the stripped plain text, not the raw HTML.
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
Select/Combobox menu is clipped inside a modal | It's portaled to <body> by design; keep menuPortalTarget={document.body}. |
Select options render at the wrong font size | The styles prop's per-size fontSize beats react-select's unlayered Emotion — don't remove it. |
| A picked date shifts by one day after saving | You round-tripped through new Date('YYYY-MM-DD') (UTC). Use parseLocalDate/formatLocalDate. |
| Rich text ballooned the main bundle | RichTextEditor was imported from an eager module. Import it by path from a React.lazy page only. |
| Saved rich-text HTML renders unstyled | Wrap the rendered HTML in className="rte-preview" so _editor.scss styles it. |
| Rich-text "required" check never fails | An empty Tiptap doc is <p></p>, which is truthy — strip tags first (the plainText() pattern). |
| Error message shows but the field looks normal | You set error on the Field but forgot invalid on the control (or vice-versa). Set both. |
| Label doesn't focus the control on click | Missing htmlFor/id pairing (Select/Combobox map id to inputId). |
Related
Components
Buttons, cards, toasts, progress, and the overlays forms compose with.
Design tokens & dark mode
The token system behind fieldBase and the SCSS picker/editor theming.
Motion & effects
Stagger/StaggerItem used on the form pages, and the reduced-motion policy.
Architecture & routing
appRoutes in src/routes.tsx — the six form routes, two lazy.
Was this page helpful?
