Form Components
Token-styled, accessible form primitives — the core set (Input, Textarea, Select, Combobox, Label, Checkbox, Radio, Field), date/time & color pickers, entry controls (PasswordInput, TagsInput, PhoneInput, OtpInput, FileUpload), selection controls (ToggleGroup, OptionCard, Rating, Slider, Stepper), the Tiptap-based RichTextEditor, and the click-to-edit Editable — plus the six form demo pages and the pure validate() pattern.
Overview
The form primitives live in src/components/ui/ and are re-exported from the barrel
src/components/ui/index.ts, 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 (see RichTextEditor bundle isolation).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 — text-like controls (
Input,Textarea,TagsInput,PasswordInput, the picker triggers) use the exportedfieldBaseclass string, so they look and focus identically. - Colors from tokens only —
border-border,bg-surface,text-foreground,ring-ring, anddangerfor errors — so every skin and dark mode work automatically. See Design tokens & dark mode. Even the third-party pieces (react-select, react-datepicker, react-colorful, Tiptap) are re-themed through raw token vars in the SCSS layer. - Accessible by construction — native inputs (visually hidden for
Checkbox/Radio),aria-invalidwiring, label association viahtmlFor, visible focus rings, and keyboard support on the composite controls (OTP arrow/backspace/paste nav, file-upload Enter/Space, slider = a native<input type=range>). - i18n-agnostic components — user-facing strings come in as props (
browseLabel,removeLabel,strengthLabels,linkLabels, …); pages passt('forms:…')keys. Fieldties aLabel, a control, and ahint/errorline together in a consistent row.
Validation in this template 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
FormValidationPage.
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 | Label |
Checkbox.tsx | Checkbox |
Radio.tsx | Radio |
Field.tsx | Field |
DatePicker.tsx | DatePicker, PickerTrigger, DATEPICKER_PORTAL |
TimePicker.tsx | TimePicker |
DateRangePicker.tsx | DateRangePicker, DateRange (type) |
ColorPicker.tsx | ColorPicker |
PasswordInput.tsx | PasswordInput, PasswordRequirementLabels (type) |
TagsInput.tsx | TagsInput |
PhoneInput.tsx | PhoneInput |
OtpInput.tsx | OtpInput |
FileUpload.tsx | FileUpload |
ToggleGroup.tsx | ToggleGroup, ToggleOption (type) |
OptionCard.tsx | OptionCard, OptionCardVariant (type) |
Rating.tsx | Rating |
Slider.tsx | Slider |
Stepper.tsx | Stepper, StepperStep (type) |
RichTextEditor.tsx | RichTextEditor — not in the barrel, import by path |
index.ts | Barrel — re-exports all of the above except RichTextEditor |
Feature components & supporting modules:
| File | Purpose |
|---|---|
src/components/editable/Editable.tsx | Click-to-edit value (Editable, EditableOption/EditableType types) |
src/lib/masks.ts | Pure format-on-type helpers: digits, maskCardNumber, maskExpiry, maskCvc, maskPhone |
src/lib/password.ts | passwordScore(pw) → {score: 0–4, tone} (pure, no i18n) |
src/lib/datetime.ts | parseLocalDate/formatLocalDate/parseLocalTime/formatLocalTime — local-timezone string↔Date bridges |
src/data/countries.ts | COUNTRIES/Country — dial-code list (emoji flags) for PhoneInput |
src/styles/_datepicker.scss | react-datepicker themed via raw token vars (.dp-popper) |
src/styles/_editor.scss | Tiptap content styling (.rte .ProseMirror + read-only .rte-preview) |
Third-party libraries (all MIT): react-select (+ react-select/creatable for Combobox),
react-datepicker (date/time/range pickers), react-colorful (ColorPicker), Tiptap
(@tiptap/react + StarterKit/Underline/Link/Placeholder/TextAlign for RichTextEditor).
Demo pages (in src/pages/forms/ — six routes):
| Page | Route | File | Lazy | Shows |
|---|---|---|---|---|
| Form Elements | /forms/elements | FormElementsPage.tsx | no | Every control: types, sizes, addons, tiles, pickers, upload, states. |
| Form Layouts | /forms/layouts | FormLayoutsPage.tsx | no | Layout patterns (vertical/horizontal/inline/two-col/floating) + complete forms (settings, checkout, sticky, wizards). |
| Form Validation | /forms/validation | FormValidationPage.tsx | no | Pure validate() — submit/blur/live timing, async check, error summary. |
| Form Plugins | /forms/plugins | FormPluginsPage.tsx | yes | The "plugin" controls gallery: pickers, multi-select, tags, masks, rich text. |
| Inline Editable | /forms/inline-editable | InlineEditablePage.tsx | no | Editable — popup/inline modes, text/select/textarea/date, placements. |
| Editors | /forms/editors | EditorsPage.tsx | yes | RichTextEditor — validated article form, live preview, count/autosave/read-only variants. |
All page copy lives in the forms i18n namespace (src/locales/en/forms.json, ~590 keys,
grouped by prefix: elements*, layouts*, validation*, plugins*, inline*, editors*).
Usage
Wrap each control in a Field for a consistent label + hint/error row:
import {Field, Input} from '@/components/ui'
<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 in this guide accepts invalid and an id for label association, so they
all drop into Field the same way.
API / Props
Core field primitives
Input
Text-like input. Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> (so type, value,
onChange, placeholder, disabled, etc. all pass through). Forwards its ref. When any addon prop
(prefix/suffix/leadingIcon/trailingIcon) is set, it renders as an input group — a
focus-within wrapper carries the border/ring and the inner input goes borderless; with no addons it
renders a plain <input> identical to before.
| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | — | Sets aria-invalid → danger border + ring (via fieldBase). |
inputSize | 'sm' | 'md' | 'lg' | 'md' | Height/padding/text size (see Input sizes). |
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. Extends TextareaHTMLAttributes<HTMLTextAreaElement>; uses the same fieldBase.
Forwards its ref.
| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | — | Sets aria-invalid → danger border + ring. |
inputSize | 'sm' | 'md' | 'lg' | 'md' | Min-height / vertical padding / text size per size. |
rows | number | 4 | Initial visible rows (still user-resizable vertically). |
className | string | — | Extra classes (e.g. resize-none). |
| …rest | native textarea attrs | — | All native textarea attributes. |
Select
A react-select wrapper (not a native <select>). Fully themed with the design tokens via
react-select's classNames + unstyled, so it re-skins and dark-modes automatically. The menu is
portaled to <body> so it isn't clipped inside scrollable containers (e.g. a Modal).
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 | string (or string[] with isMulti) | — | Controlled selected value(s). |
defaultValue | string (or string[] with isMulti) | — | Uncontrolled initial value(s). |
onChange | (v: string) => void (or string[]) | — | Called with the value(s) ('' when cleared, single). |
placeholder | string | — | Placeholder text. |
invalid | boolean | — | Danger border + ring; sets aria-invalid. |
disabled | boolean | — | Disable the control. |
isSearchable | boolean | false | Allow typing to filter options. |
size | 'sm' | 'md' | 'lg' | 'md' | Control min-height + font size (matches InputSize). |
id | string | — | inputId (for label association). |
name | string | — | Field name. |
className | string | — | Extra classes on the container. |
aria-label | string | — | Accessible label when there's no visible one. |
SelectOption: { value: string; label: string; isDisabled?: boolean }.
Info
Font-size note: the component sets the per-size font size (0.75/0.875/1rem) 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 — the styles value wins so
control + menu match the theme's field text. The styles prop also sets the portaled menu z-index
(menuPortal.zIndex: 60).
Combobox
Searchable single-select that can also create a new option on the fly (autocomplete + add) —
built on react-select/creatable, themed identically to Select, isClearable, menu portaled to
<body>. Options created at runtime are kept in internal state and merged with options.
| Prop | Type | Default | Description |
|---|---|---|---|
options | ComboboxOption[] | — | {value, label}[]. |
value | string | — | Controlled selected value. |
onChange | (value: string) => void | — | Called with the value ('' when cleared); also fires with the typed label on create. |
createLabel | string | — | Prefix on the "create new" row (e.g. "Create"); renders Create "input". |
placeholder | string | — | Placeholder text. |
invalid | boolean | — | Danger border + ring. |
disabled | boolean | — | Disable the control. |
id / name / className / aria-label | string | — | Same as Select. |
Label
Field label with an optional required asterisk. Extends LabelHTMLAttributes<HTMLLabelElement> (so
htmlFor passes through).
| Prop | Type | Default | Description |
|---|---|---|---|
required | boolean | — | Shows a danger * after the text. |
className | string | — | Extra classes. |
Checkbox
Token-styled checkbox. The native input is visually hidden as a peer; the box + checkmark are
sibling layers driven by peer-checked: (the check fades/scales in). Extends
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>; forwards its ref.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Text shown next to the box. |
disabled | boolean | — | Disables and dims the row. |
className | string | — | Extra classes on the wrapping label. |
| …rest | native input attrs (minus type) | — | checked, onChange, name, etc. |
Radio
Same peer pattern as Checkbox, with a filled dot indicator. Extends
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>; forwards its ref. Same props as Checkbox
(name groups radios).
Field
Label + control + hint/error wrapper for consistent form rows.
| 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 (Input, Select, Checkbox, …). |
Date, time & color pickers
The three date/time pickers wrap react-datepicker with a shared fieldBase-styled button trigger
(PickerTrigger, exported from DatePicker.tsx) and a body-level portal (DATEPICKER_PORTAL = 'datepicker-portal') so the popper escapes panel/modal overflow: hidden. Theming lives in
src/styles/_datepicker.scss (raw token vars, .dp-popper z-index 60). Values are Date | null
— bridge string state with src/lib/datetime.ts.
DatePicker
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | null | — | Selected date. |
onChange | (date: Date | null) => void | — | Selection callback. |
min / max | Date | — | Selectable range bounds. |
dateFormat | string | adapts | date-fns format; defaults to 'PP', 'PP p' with showTime, 'MMM yyyy' with monthYear. |
showTime | boolean | — | Also select a time (date + time). |
monthYear | boolean | — | Month/year-only picker. |
inline | boolean | — | Render the calendar inline (no trigger input, no portal). |
timeIntervals | number | 15 | Minutes between time options (with showTime). |
placeholder | string | — | Trigger placeholder. |
invalid / disabled | boolean | — | Danger border / disabled trigger. |
id / className | string | — | Trigger id / wrapper classes. |
TimePicker
Time-only picker (react-datepicker showTimeSelectOnly).
| Prop | Type | Default | Description |
|---|---|---|---|
value | Date | null | — | Selected time (a Date). |
onChange | (date: Date | null) => void | — | Selection callback. |
minuteStep | number | 15 | Minutes between options. |
timeFormat | string | 'p' | date-fns time format (localized, e.g. "2:30 PM"). |
placeholder | string | '--:--' | Trigger placeholder. |
invalid / disabled / id / className | — | As DatePicker. |
DateRangePicker
Start–end range picker (react-datepicker selectsRange). DateRange = [Date | null, Date | null].
| Prop | Type | Default | Description |
|---|---|---|---|
value | DateRange | — | [start, end]. |
onChange | (range: DateRange) => void | — | Selection callback. |
min / max | Date | — | Selectable bounds. |
dateFormat | string | 'PP' | date-fns format. |
monthsShown | number | 2 | Calendar months shown side-by-side. |
placeholder / invalid / disabled / id / className | — | As DatePicker. |
ColorPicker
Built on react-colorful: a saturation/hue area + hex field + preset swatches, inside a portaled
Popover. The trigger is a token-styled button showing the current swatch + hex.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Current hex color. |
onChange | (hex: string) => void | — | Fired from the picker, hex field, or a preset. |
presets | string[] | 10-color set | Swatch palette (literal hex values are intentional here — this is a color value input, not themable chrome). |
disabled / id / aria-label / className | — | Trigger attributes. |
Entry controls
PasswordInput
Password field with show/hide toggle, an optional strength meter (single danger→success gradient bar
revealed by clip-path) and a requirements checklist. Scoring comes from the pure
passwordScore() in src/lib/password.ts. All labels are props (i18n-agnostic).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value. |
onChange | (value: string) => void | — | Change callback (plain string, not an event). |
strength | boolean | — | Show the strength meter below the field. |
strengthLabels | [string, string, string, string] | — | Labels for scores 1–4 (weak/fair/good/strong). |
requirements | boolean | — | Show the checklist (needs requirementLabels). |
requirementLabels | {length, case, number, symbol} strings | — | Checklist row labels. |
showLabel / hideLabel | string | — | aria-labels for the eye toggle. |
placeholder / invalid / disabled / id / autoComplete / className | — | Standard field props. |
TagsInput
Tag/token input on the fieldBase shell — type + Enter to add, Backspace (on empty draft) or
the ✕ to remove. De-dupes and respects max.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string[] | — | Current tags. |
onChange | (tags: string[]) => void | — | Fired on add/remove. |
max | number | — | Maximum tag count (further adds are ignored). |
removeLabel | string | — | aria-label prefix for a tag's remove button. |
placeholder / invalid / disabled / id / className | — | Standard field props (placeholder hides once tags exist). |
PhoneInput
Phone field: a dial-code country picker (portaled Popover with emoji flags, data from
src/data/countries.ts) + a masked national number (maskPhone from src/lib/masks.ts).
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | The national number (already masked). |
onChange | (value: string) => void | — | Receives the masked value. |
onCountryChange | (country: Country) => void | — | Fired when the dial code changes. |
defaultCountry | string | 'US' | ISO code of the initial country. |
placeholder | string | '(555) 000-0000' | Number placeholder. |
invalid / disabled / id / className | — | Standard field props. |
OtpInput
Segmented one-time-code / PIN input with auto-advance, backspace-to-previous, arrow-key nav, paste-to-fill, and select-on-focus.
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of boxes. |
value | string | — | Joined value. |
onChange | (value: string) => void | — | Fired on every keystroke. |
onComplete | (value: string) => void | — | Fired once every box is filled. |
type | 'number' | 'text' | 'number' | number restricts to digits (numeric inputMode). |
invalid / disabled / className / aria-label | — | Group attributes (role="group"). |
FileUpload
Token-styled file upload: click-to-browse + drag-and-drop dashed drop zone (keyboard-activatable),
with a selected-file list (name, formatted size, remove). Controlled (files + onFiles) or
uncontrolled. All text comes from props so the component stays i18n-agnostic.
| Prop | Type | Default | Description |
|---|---|---|---|
multiple | boolean | — | Allow multiple files (single mode keeps only the first). |
accept | string | — | Native accept filter (e.g. image/*). |
files | File[] | — | Controlled selection; omit to let it track its own. |
onFiles | (files: File[]) => void | — | Fired with the current list whenever it changes. |
browseLabel | ReactNode | — | Primary CTA text (e.g. "Click to upload"). |
dropLabel | ReactNode | — | Secondary line (e.g. "or drag and drop"). |
hint | ReactNode | — | Small helper (accepted types / max size). |
removeLabel | string | — | aria-label for the per-file remove button. |
invalid / disabled / id / className | — | Standard props. |
Selection & step controls
ToggleGroup
Segmented / toggle button group — single-select by default or multiple (a discriminated union, so
value/onChange are string/string[] accordingly). Buttons carry aria-pressed. This is the
general-purpose primitive; the customizer-bound OptionPills is a separate component.
| Prop | Type | Default | Description |
|---|---|---|---|
options | ToggleOption[] | — | {value, label, icon?}[] (label may be a node — e.g. an icon-only group). |
multiple | boolean | false | Multi-select mode. |
value | string (or string[] with multiple) | — | Active value(s). |
onChange | (v: string) => void (or string[]) | — | Selection callback. |
size | 'sm' | 'md' | 'md' | Button padding/text size. |
className / aria-label | string | — | Group attributes (role="group"). |
OptionCard
Selectable card/tile with no visible checkbox or radio — the card itself is the control
(aria-pressed). The parent drives single- or multi-select via selected/onSelect. Four variants:
default (vertical, icon tile, corner check), horizontal (icon left, check-circle right),
filled (whole tile fills bg-primary when selected), plain (text-forward, left accent bar +
corner check).
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | — | Selected state. |
onSelect | () => void | — | Click handler (parent toggles). |
title | ReactNode | — | Tile title. |
description | ReactNode | — | Optional secondary line. |
icon | ReactNode | — | Optional leading icon. |
variant | OptionCardVariant | 'default' | default | horizontal | filled | plain. |
disabled / className / aria-label | — | Standard props. |
Rating
Star rating — interactive (with hover preview) when onChange is passed and not readOnly
(role="radiogroup"), otherwise a read-only role="img". Stars use the warning token.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Current rating. |
onChange | (value: number) => void | — | Makes it interactive. |
max | number | 5 | Number of stars. |
readOnly | boolean | — | Force display-only. |
size | 'sm' | 'md' | 'lg' | 'md' | Star size. |
clearable | boolean | — | Clicking the current value again resets to 0. |
className / aria-label | string | — | Wrapper attributes. |
Slider
Range slider — a native <input type="range"> (keyboard + a11y for free) with a token-filled track
(computed linear-gradient from the raw --primary/--border vars) and a styled thumb.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | Current value. |
onChange | (value: number) => void | — | Change callback (already a number). |
min / max | number | 0/100 | Range bounds. |
step | number | 1 | Step increment. |
showValue | boolean | — | Show the current value right of the track. |
formatValue | (value: number) => string | — | Format the shown value (e.g. (v) => `${v}%`). |
disabled / id / className / aria-label | — | Standard props. |
Stepper
Horizontal step indicator (done / current / upcoming) with connector lines — drives a wizard
(see FormLayoutsPage's WizardForm). Labels/descriptions hide on sm breakpoints down.
| Prop | Type | Default | Description |
|---|---|---|---|
steps | StepperStep[] | — | {label, description?}[]. |
current | number | — | Zero-based index of the active step. |
onStepClick | (index: number) => void | — | If set, completed/active steps become clickable (no jumping ahead). |
className | string | — | Extra classes. |
RichTextEditor
Rich-text editor built on Tiptap (MIT, headless) with a token-styled toolbar so it re-skins and
dark-modes like the rest of the app. Controlled via an HTML string value. Extensions:
StarterKit + Underline + Link (autolink, rel="noopener noreferrer") + Placeholder + TextAlign.
The full toolbar covers bold/italic/underline/strike, H1–H3, bullet/numbered lists, quote, code
block, link popover (add/remove/apply), text align, clear formatting, and undo/redo; minimal
trims it to bold/italic/underline/bullet-list/link for comments & notes. 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 (see EditorsPage's preview tab and comments).
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, locks editing, keeps content crisp (no dim). |
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. |
External value changes are synced in with setContent (skipped while it matches getHTML(), so
your own typing doesn't loop).
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 buttons) and inline (swaps the control in place). Four control types: text
(Enter commits, Escape reverts; blur commits in inline mode), textarea (Ctrl/Cmd+Enter commits),
select (uses Select, optionally searchable; commits immediately on change in inline mode), and
date (an inline DatePicker in the popup; commits on pick in inline mode — string values bridge
via parseLocalDate/formatLocalDate from src/lib/datetime.ts).
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'text' | 'select' | 'textarea' | 'date' | 'text' | The editing control. |
value | string | — | Current value (dates as 'YYYY-MM-DD'). |
onCommit | (value: string) => void | — | Fired with the trimmed value on save. |
mode | 'popup' | 'inline' | 'popup' | Popover with Save/Cancel vs. in-place swap. |
options | EditableOption[] | — | For select (also resolves the display label). |
searchable | boolean | — | Searchable select ("select2" style). |
side | 'top' | 'bottom' | 'bottom' | Popup placement. |
align | 'start' | 'end' | 'start' | Popup alignment. |
emptyLabel | string | — | Muted italic label when the value is empty. |
saveLabel / cancelLabel | string | 'Save'/'Cancel' | Popup button labels — pass t() values. |
className / aria-label | string | — | Trigger attributes. |
Configuration & customization
fieldBase — the shared field style
fieldBase is exported from Input.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, and
the aria-[invalid=true] danger ring. Input, Textarea, TagsInput, and PasswordInput build on it,
and you should reuse it for any new field-like control so it matches the theme automatically:
import {fieldBase} from '@/components/ui'
import {cn} from '@/lib/cn'
<input className={cn(fieldBase, 'h-10')} />The demo pages use it directly for one-off composites — e.g. the native <select> sample and the
floating-label fields on FormElementsPage/FormLayoutsPage.
Input sizes
InputSize = 'sm' | 'md' | 'lg' is shared across the text controls: Input inputSize (h-8/h-10/h-12),
Textarea inputSize (min-height + text size), and Select size (min-height + Emotion font size). The
class map is exported as INPUT_SIZE from Input.tsx if a custom control needs to match.
The Field wrapper
Field is the standard row layout: it renders Label (with required) + your control + a single
hint/error paragraph. Use it for every form control so labels, spacing, and error messaging stay
consistent. It works with any control passed as children — including Checkbox (pass only error, no
label, when the checkbox already has its own label) and the pickers/editors above.
invalid and error states
There are two coordinated flags:
invalidon the control (every primitive in this guide accepts it) setsaria-invalidand/or turns the border + focus ring danger.erroron theFieldreplaces the hint with a danger-colored message.
They're used together: invalid={!!errors.email} on the input, error={errors.email} on the field.
<Field label="Email" htmlFor="email" required error={errors.email}>
<Input id="email" invalid={!!errors.email} value={email} onChange={onChange} onBlur={onBlur} />
</Field>The react-select-based Select API
Because Select wraps react-select, treat it as a controlled component driven by string values, not a
DOM <select>:
- You can't use
<option>children — pass anoptionsarray of{value, label}. onChangegives you the value string directly (react-select's option object is unwrapped for you); withisMultiit's a string array.- Use
valuefor controlled ordefaultValuefor uncontrolled. - Set
isSearchableto allow typing; disable individual options withisDisabledon the option. - The menu is portaled to
document.body— it renders above modals and won't clip in scroll containers. - Need create-on-the-fly? Use
Comboboxinstead of extendingSelect.
Input masks
src/lib/masks.ts holds pure format-on-type helpers for controlled inputs: maskCardNumber
(4242 4242 4242 4242), maskExpiry (MM/YY), maskCvc (max 4 digits), maskPhone
((555) 123-4567, used internally by PhoneInput), and the digits stripper. Use them inline:
<Input inputMode="numeric" value={card} onChange={(e) => setCard(maskCardNumber(e.target.value))} />Password strength scoring
src/lib/password.ts exports passwordScore(pw) → {score: 0|1|2|3|4, tone: PasswordTone} — one
point each for length ≥ 8, mixed case, a digit, and a symbol; tones map danger → warning → info →
success. PasswordInput consumes it; reuse it anywhere you need the same rules (the checklist in
PasswordInput mirrors the same four checks).
Local date/time string helpers
src/lib/datetime.ts bridges string state ('YYYY-MM-DD', 'HH:mm') with the Date-based pickers:
parseLocalDate/formatLocalDate and parseLocalTime/formatLocalTime. They build/read local
dates — a bare new Date('YYYY-MM-DD') parses as UTC midnight and shifts a day in western timezones,
so always use these when persisting picker values as strings (as Editable type="date" does).
Picker & editor theming (SCSS)
Both third-party surfaces are themed only through raw token vars (no @apply, no hex, no
--color-* — the same approach as _calendar.scss for FullCalendar):
src/styles/_datepicker.scssrecolors/rounds react-datepicker (.dp-popper, z-index 60), makes the wrapper span full width so triggers fill theirField, and keeps the library's own layout so date+time, multi-month range, and month pickers lay out correctly. The popper renders into the body-level#datepicker-portalnode (DATEPICKER_PORTAL) so it escapesoverflow: hiddenancestors (e.g. a collapsedPanelbody).src/styles/_editor.scssstyles Tiptap content under.rte .ProseMirrorand the read-only.rte-previewwith the same rules, so edited and rendered documents look identical.
RichTextEditor bundle isolation
RichTextEditor is deliberately not re-exported from src/components/ui/index.ts: the barrel is
imported by the eager app entry, and routing Tiptap/ProseMirror (~340 kB) through it would hoist the
editor into the main bundle. Import it by path (@/components/ui/RichTextEditor) from lazy pages
only — its two consumers (EditorsPage, FormPluginsPage) are both code-split,
so Tiptap stays in their async chunks. Keep it that way when reusing it.
Examples
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:
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 {toast} = useToast()
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 label={t('validationPlan')} htmlFor="plan" required error={err('plan')}>
<Select
id="plan"
value={values.plan}
invalid={!!errors.plan}
onChange={(plan) => setValues({...values, plan})}
placeholder={t('validationPlanPlaceholder')}
options={[
{value: 'starter', label: t('validationPlanStarter')},
{value: 'pro', label: t('validationPlanPro')},
{value: 'enterprise', label: t('validationPlanEnterprise')},
]}
/>
</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>
)
}An input group with addons and a masked value:
<Field label="Website" htmlFor="web">
<Input id="web" prefix="https://" suffix=".com" placeholder="yoursite" />
</Field>
<Field label="Card number" htmlFor="card">
<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"
/>
</Field>Date + time pickers (values are Date | null):
const [when, setWhen] = useState<Date | null>(null)
const [range, setRange] = useState<DateRange>([null, null])
<Field label="Starts" htmlFor="starts">
<DatePicker id="starts" value={when} onChange={setWhen} showTime min={new Date()} />
</Field>
<Field label="Duration" htmlFor="dates">
<DateRangePicker id="dates" value={range} onChange={setRange} />
</Field>A rich-text field inside a validated Field (emptiness checked on the stripped plain text — the
EditorsPage pattern):
import {RichTextEditor} from '@/components/ui/RichTextEditor'
<Field label={t('editorsFieldBody')} required error={err('body')}>
<RichTextEditor
value={body}
onChange={setBody}
invalid={!!errors.body}
placeholder={t('editorsBodyPlaceholder')}
linkLabels={{add: t('editorsLinkAdd'), remove: t('editorsLinkRemove'), url: t('editorsLinkUrl'), apply: t('editorsLinkApply')}}
/>
</Field>A radio group:
<Field label="Notifications">
<div className="space-y-2">
<Radio name="notify" value="all" label="All activity" defaultChecked />
<Radio name="notify" value="mentions" label="Only mentions" />
<Radio name="notify" value="none" label="Nothing" />
</div>
</Field>Demo pages
- Form Elements (
src/pages/forms/FormElementsPage.tsx,/forms/elements) — the full catalog inSectionHeading-groupedPanels: input types (text/email/password/number/tel/url/search), sizes (inputSizesm/md/lg), floating labels, input groups & addons (prefix/suffix/leading/trailing), selects (searchable,size="sm", error state, a native<select>onfieldBase), textareas; checkboxes/radios plain + card-styles + all fourOptionCardtile variants (multi-select rows for checkboxes, single-select for radios), a segmented control andSwitches; date & time (basic/min-max/month-year, date+time, range, time-only, an inline calendar,ColorPicker, a quantity stepper); advanced (OTP, masked card/expiry/CVC,PhoneInput,Rating+Slider,Select isMulti+ToggleGroupsingle/multiple,TagsInput+Combobox,PasswordInputwith strength + requirements); and upload & states (single/multipleFileUpload, default/error/success/disabled/read-only states, a button row). - Form Layouts (
src/pages/forms/FormLayoutsPage.tsx,/forms/layouts) — layout patterns (vertical, horizontal label-left rows, inline invite bar, two-column grid, floating labels) and complete forms: a settings page (row-per-setting withSwitch/Select, danger zone), a checkout/billing form (masked card fields + a sticky order summary usingformatMoney), a scrollable form with a sticky action footer, a 3-step wizard driven byStepper, and a second wizard driven byProgress+ a "Step X of N" counter. - Form Validation (
src/pages/forms/FormValidationPage.tsx,/forms/validation) — validation timing (on submit; on blur with atouchedmap, re-validating touched fields as they change; live as-you-type withtrailingIconcheck/cross feedback) and special cases: password + confirm (PasswordInputstrength meter), an async availability check (debounced fake lookup with a spinner trailing icon), constraint validation (URL, number range,maxLengthchar counter), and an error summaryAlertwhose entries focus their field via refs. All validators are pure and return i18n keys (isEmailis shared fromsrc/components/auth/validators.ts). No form library. - Form Plugins (
src/pages/forms/FormPluginsPage.tsx,/forms/plugins, lazy) — the "plugin-alternatives" gallery, entirely in-house primitives: pickers (date/min-max/month, date+time/range/time, inline calendar,ColorPickerwith a live swatch), selection & entry (Selectsingle +isMulti, creatableCombobox,TagsInputwithmax, a character-counterTextarea,Slider+Rating,PasswordInput+OtpInput, masked card/phone inputs, a read-only API-key field with a copy-buttonsuffix, imageFileUpload), and rich text (full +minimalRichTextEditorside by side). - Inline Editable (
src/pages/forms/InlineEditablePage.tsx,/forms/inline-editable) — theEditablecomponent in the x-editable idiom: popup groups for text, select, searchable select, textarea, and date fields; inline mode (in-place swap; select/date commit on change); placement (all fourside/aligncombinations); and two composed examples (an editable user profile card and an order-details card). - Editors (
src/pages/forms/EditorsPage.tsx,/forms/editors, lazy) —RichTextEditorin anger: a validated article form (title/category/tags/body/visibility/cover-upload/agree, with the rich-text body validated via aplainText()HTML-stripper), a live Preview / HTML tab pair (preview renders through.rte-preview), aminimalcomment composer with a posted-comments list, and three variants — live word/character count, a debounced autosave indicator, and a read-only document.
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) => Errorsfunction returning i18n keys, called on blur/submit; render witht(). - Pair
invalid(on the control) witherror(on theField) so the visual and the message agree. - For
Select/Combobox, drive them withvalue/onChangeand anoptionsarray — never<option>children. - Keep picker state as
Date | null; when you must persist strings, convert withsrc/lib/datetime.ts(nevernew Date('YYYY-MM-DD')). - Reuse
src/lib/masks.tsfor card/expiry/CVC/phone formatting — don't re-write the regexes. - Pass all user-facing labels, hints, errors, and component label-props (
browseLabel,strengthLabels,linkLabels,saveLabel, …) through i18nt(). - Validate rich-text emptiness on the stripped plain text, not the raw HTML (an "empty" Tiptap
document is
<p></p>).
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
Select/Combobox menu is clipped inside a modal | It's portaled to <body> by design; if you re-styled it, keep menuPortalTarget={document.body}. |
Select options render at the wrong font size | The styles prop's per-size fontSize on control/menu is required to beat react-select's unlayered Emotion — don't remove it. |
Passing <option> to Select does nothing | It's not a native select. Use the options prop. |
| Date/time popper is clipped or hidden | The pickers render into the body-level #datepicker-portal (DATEPICKER_PORTAL); keep portalId if you fork them (inline skips it). |
| A picked date shifts by one day after saving | You round-tripped through new Date('YYYY-MM-DD') (UTC). Use parseLocalDate/formatLocalDate from src/lib/datetime.ts. |
| Date field is narrower than the other inputs | _datepicker.scss forces the wrapper to display: block; width: 100% — make sure the SCSS layer is loaded / not overridden. |
| Rich text ballooned the main bundle | RichTextEditor was imported from an eager module. Import it by path from a code-split page only (see bundle isolation above). |
| 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 on EditorsPage). |
| 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. |
| Checkbox/radio look unstyled | The native input is hidden as a peer; the box is a sibling. Don't remove the peer/sibling structure or add a second wrapping label. |
| Label doesn't focus the control on click | Missing htmlFor/id pairing. Match Field htmlFor to the control id (Select/Combobox map it to inputId). |
| Field colors wrong in dark mode / a skin | A hardcoded color slipped in — use tokens / fieldBase (the SCSS partials only use raw --* vars). |
FAQ
Which validation library does this use? None — validation is plain functions returning an error-key
map. See FormValidationPage. You can bolt on a library, but the primitives don't require one.
How do I make a multi-select? Pass isMulti to Select — value/onChange become string[] and
selections render as removable chips. For free-text tokens use TagsInput; for search-and-create use
Combobox.
Can I use a native <select> instead? You can, styled with fieldBase (there's a sample on
FormElementsPage), but the Select component gives you themed, portaled menus, searchable options,
and multi-select for free.
How do I show a required marker? Pass required to Field (it forwards to Label) for the danger
asterisk. This is visual only — enforce it in your validate().
Why isn't RichTextEditor in the barrel? Bundle size — Tiptap/ProseMirror would land in the main
chunk. Import it by path from lazy pages.
What's the difference between Editable and the editors page? Editable is click-to-edit for
single values (x-editable style, /forms/inline-editable); RichTextEditor is a document editor
(/forms/editors).
Where do the phone country codes come from? src/data/countries.ts (COUNTRIES) — a curated list
with emoji flags (no image assets). Country names are proper nouns and stay literal.
Notes for designers & content editors
- All labels, hints, and errors are translatable — they're i18n keys in the
formsnamespace, not literals; components receive their strings as props. - Error tone is always
danger(red). Hints are muted. Required fields get a red asterisk. The password meter runs danger → warning → info → success; rating stars usewarning. - Field spacing and focus rings are consistent across all controls because of
fieldBaseandField— avoid one-off spacing so forms stay uniform. The three sizes (sm/md/lg) are the only sanctioned height variants. - Even the third-party pickers/editor re-skin automatically — they're themed via design tokens, so a new skin needs no picker/editor work.
- Keep hint text short; it sits directly under the control and is replaced by the error when present.
OptionCardgives four tile looks (default/horizontal/filled/plain) — pick one per selection group, don't mix variants within a group.
Related
Core & feedback components
buttons, cards, toasts (validation success uses useToast), Progress (the second wizard), Alert (the error summary)
Overlays
Modal, Popover (Editable, ColorPicker, PhoneInput build on Popover; Select portals above modals), Tabs (EditorsPage preview)
Panel
every form demo section is a Panel with title + subtitle
Design tokens & dark mode
the token system behind fieldBase and the SCSS picker/editor theming
Animation & effects
Stagger/StaggerItem used on the form pages
Architecture & routing
route registration and code splitting · Getting started · Docs home
Was this page helpful?
