Form inputs
Components for data entry and user selection.
Forms are where Senco asks users to be precise under pressure — an account number, an amount, a one-time code — usually on a phone, often on a bad connection, frequently in a second language. Every rule on this page trades a little visual economy for a lower chance that someone sends money to the wrong place.
Components
| Component | Purpose |
|---|---|
| Input | Single-line text (text, email, tel, password, number, search, URL) |
| Textarea | Multi-line text entry |
| Select | Pick one option from a dropdown |
| Checkboxes | Pick one or more from a visible set |
| Radio group | Pick exactly one from a visible set |
| Radio card | Single-select with card-style layout (payment methods, route comparison) |
| Date input | Structured day/month/year fields |
| Switch | On/off toggle for settings |
| Toggle | Pressable button for toggling states (bold, italic, view mode) |
| Search select | Searchable dropdown for one option from a long list |
| Multi select | Searchable dropdown with chips for multiple selections |
| Input OTP | Individual digit fields for one-time passwords |
| File uploader | Drag-and-drop or browse file upload |
| Date picker | Calendar popover with typed date entry |
| Currency input | Formatted money input with symbol and code |
| Phone input | Phone number with country code selector |
| Password input | Show/hide toggle with optional strength meter |
| Masked input | Auto-formatted entry (card number, expiry, CVV) |
| Input group | Input with attached prefix, suffix, or addon control |
| Form field | Wraps label, input, helper text, and error message |
Universal rules
These apply to every component on this page. They are stated once here rather than repeated nineteen times.
| Rule | Why |
|---|---|
| Visible label above the field, always | A placeholder disappears the moment a user types, leaving them unable to check what the field was for |
| Never use a placeholder as a label | Fails SC 3.3.2, and fails anyone who was interrupted mid-form |
| 48px minimum control height | --ds-input-height — the touch standard applies to fields too |
| Errors carry color, icon, and text | Red alone fails SC 1.4.1 |
aria-invalid + aria-describedby on every error | The message must be attached, not merely adjacent |
| Required is marked in the label, not by color | An asterisk with a legend, or the word "optional" on the others |
| Validate on blur, never on keystroke | Telling someone their email is invalid after two characters is noise |
| A validation error never clears the user's input | Losing a typed account number is worse than the error |
Group related fields in <fieldset> with a <legend> | The legend is the only thing that gives a radio group a name |
Universal states
| State | Field | Token |
|---|---|---|
| Default | 1px --ds-border | --ds-input-height |
| Hover | Border --ds-gray-400 | — |
| Focus-visible | Border --ds-primary plus a 3px halo at 20%, no offset | --ds-focus-ring |
| Filled | Same as default | — |
| Error | Border --ds-red-600 and fill --ds-error-bg at rest; the error halo is added only on focus-visible | --ds-error-bg · --ds-focus-ring-error |
| Disabled | --ds-disabled-bg / --ds-disabled-text | aria-disabled |
| Read-only | Default border, --ds-surface fill, still focusable and selectable | — |
Read-only is not disabled. A read-only field can be focused, read by a screen reader, and copied — which matters for a reference number the user needs to paste into their banking app.
Universal anatomy
Every control on this page is assembled from the same parts, in the same vertical order. A component's own Anatomy section lists only what it adds.
Label * --ds-text-sm / 600, --ds-heading, required marker in the text
Hint --ds-text-xs, --ds-muted — read before typing
┌──────────────────────────────┐
│ Value │ --ds-input-height (48px), --ds-text-base, --ds-border, --ds-radius-md
└──────────────────────────────┘
⚠ Error --ds-text-sm, --ds-error-text — below the control; the hint stays
| Part | Required | Token |
|---|---|---|
| Label | Yes | --ds-text-sm, --ds-font-semibold, --ds-heading |
| Hint | No | --ds-text-xs, --ds-muted |
| Control | Yes | --ds-input-height, --ds-border, --ds-radius-md |
| Value | Yes | --ds-text-base, --ds-fg |
| Placeholder | No | --ds-placeholder — 4.50:1 light, 5.58:1 dark |
| Error | No | --ds-text-sm, --ds-error-text, --ds-error-icon 16px |
Gaps are --ds-space-2 between each part.
The hint sits above the control and stays when an error appears. A format hint below the field is read after the mistake is made, and hiding it at the moment of an error removes the one line that explains how to fix it. The error sits below the control, because it refers to what is now in the field. aria-describedby lists the error first, then the hint, so the actionable half is read first.
The error is not a live region. It is read when the user returns to the field. Announcing on every blur interrupts someone still working through the form — see Validation.
Universal tokens
--ds-input-height · --ds-input-height-sm · --ds-border · --ds-radius-md · --ds-heading · --ds-fg · --ds-text-base · --ds-focus-ring · --ds-focus-ring-error · --ds-disabled-bg · --ds-disabled-text · --ds-muted · --ds-error-bg · --ds-error-text · --ds-error-icon · --ds-text-sm · --ds-text-xs · --ds-space-2 · --ds-space-4
A component's own Tokens section lists only what it adds.
Choosing a selection component
| Options | Component |
|---|---|
| 2–3, short labels | Radio group, horizontal |
| 4–6 | Radio group, vertical |
| 4–6 with detail worth comparing | Radio card |
| 7–15 | Select |
| 15+ | Search select |
| Multiple from many | Multi select |
| One instant on/off setting | Switch |
| One or more, all visible | Checkboxes |
Input
Single-line text entry. Each input requires a visible label above the field.
- Supports
text,email,tel,password,number,search, andurltypes - Error messages appear below the input with
aria-describedby - Size inputs to match expected content length
Anatomy
Label * ← --ds-text-sm / 600, required marker
Helper text ← --ds-text-xs, --ds-muted
┌──────────────────────────────┐
│ Value │ ← 48px, --ds-border, --ds-radius-md
└──────────────────────────────┘
⚠ Error message ← --ds-text-sm, --ds-error-text; helper stays
Variants
| Variant | Dimension | Use |
|---|---|---|
| Default | layout | width="full" |
| Content-width | layout | Sized to the expected length — a 3-digit CVV is not full width |
API
Every control on this page extends a shared base. It is stated once here and referenced by each component's own interface.
interface FieldBase {
id?: string; // generated when omitted
label: string; // visible, above the field — required, never a placeholder
hint?: string; // format guidance, rendered between the label and the control
error?: string; // rendered below the control; the hint stays; sets aria-invalid and aria-describedby
required?: boolean; // marked in the label text, never by colour alone
disabled?: boolean; // aria-disabled; the control stays focusable
readOnly?: boolean; // focusable and selectable — not the same as disabled
name?: string;
onBlur?: () => void; // validation runs on blur — every field needs the hook
}
interface InputProps extends FieldBase,
Omit<React.InputHTMLAttributes<HTMLInputElement>, "id" | "required" | "disabled" | "readOnly"> {
type?: "text" | "email" | "tel" | "url" | "search"; // "number" is never used — see Behavior
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
autoComplete?: string; // required wherever a standard token applies
value: string;
onValueChange: (value: string) => void;
width?: "full" | "content"; // default: "full" — "content" sizes to the expected length
}
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Autofilled | The browser's own background wins; the field keeps its border and focus ring. Never suppressed with an -webkit-autofill override |
| Read-only | --ds-surface fill, still focusable and selectable — a reference number a user must copy |
Behavior
- The
typeandinputmodeare chosen for the keyboard they summon, not just for validation.inputmode="numeric"for an account number saves a user four taps per field. autocompleteis set on every field that maps to a standard token (name,tel,email,postal-code). Autofill is an accessibility feature, not a convenience.- Field width hints at expected length — a 3-digit CVV field is not full-width. A field sized to its content prevents an entire class of entry error.
type="number"is avoided in favor ofinputmode="decimal". Number inputs add spinner arrows, accepte, and silently drop leading zeros.
Content
The label names the value. The hint states format constraints before the user types, not after.
Accessibility
<label for>bound to the inputid. Not a<div>above it.- Helper text and errors are both referenced from
aria-describedby. - The helper stays when an error appears.
aria-describedbylists the error first, then the helper, so the actionable half is read first.
Keyboard
| Key | Action |
|---|---|
Tab | Move focus in and out |
Esc | Clear, when type="search" |
Tokens
Universal field tokens apply.
- 1Container / border
- 2Label
- 3Placeholder text
- 4Helper text
- 5Error icon
- 6Error message
Select
Pick one option from a dropdown.
- Default empty option prompts selection ("Select a reason")
- If more than 15 options, use Search select instead
Anatomy
Universal field anatomy applies. Adds a 16px chevron inline-end in --ds-muted, inset --ds-space-4, with --ds-space-8 end padding on the control to clear it.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Plain | layout | 7–15 options, no grouping |
| Grouped | layout | suggestedOptions renders a leading <optgroup> |
API
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends FieldBase {
options: SelectOption[];
suggestedOptions?: SelectOption[]; // rendered first in their own optgroup; the component does the grouping
groupLabels?: { suggested: string; all: string }; // default: { suggested: "Suggested", all: "All options" }
value: string | null;
onValueChange: (value: string) => void;
placeholder?: string; // default: "Select an option" — an instruction, not a blank row
}
| Decision | Contract |
|---|---|
| Element | Native <select> on every device, not only touch. The tension between "used on mobile" and "wherever possible" resolves to native everywhere |
| Sorting | The component groups suggestedOptions first, then renders options in the order given. The caller sorts within each group |
| 15-option ceiling | A guideline, not enforced. Above 15, use Search select — the component does not police it |
readOnly | A native <select> has no readonly attribute. Implemented as aria-readonly plus reverting any change, which is the only way the required state is expressible on this element |
disabled | aria-disabled="true" on the <select>, which stays focusable; any change is reverted, as with readOnly. The native disabled attribute is never set |
required | aria-required="true" only. The native required attribute is not set — its browser bubble would compete with the error summary |
| Placeholder option | Rendered as <option value="" disabled>, selected while value is null. It cannot be re-selected, so a Select cannot be cleared; where clearing is meaningful, include an explicit option such as "None" |
| Groups | Rendered only when suggestedOptions is non-empty: an <optgroup> for suggested, then one for all, labelled from groupLabels |
| Chevron | The native indicator is suppressed with appearance: none; the replacement is 16px in --ds-muted, inset --ds-space-4 from the inline end, with --ds-space-8 end padding on the field to clear it |
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Unselected | Shows the placeholder as an instruction, not a blank row. value is null, not "" |
| Open | The OS picker owns the open state; the component renders no popover |
| Read-only | aria-readonly plus reverting any change — the native element has no readonly attribute |
Behavior
- The empty option reads as an instruction, not as a blank. "Select a reason" is a prompt; an empty first row looks like a valid choice.
- Options are ordered by likely use, then alphabetically. A country list puts the user's suggested countries first.
- The native
<select>is used on mobile. The OS picker is faster, familiar, and accessible for free — a custom dropdown on touch is almost always worse.
Content
The placeholder is an instruction — "Select a reason" — and is never a valid value.
Accessibility
Native <select> wherever possible. A custom listbox has to reimplement typeahead, arrow navigation, Home/End, and screen reader announcement, and typeahead is the one that is always missing.
Keyboard
Native <select> keyboard behaviour, which is the reason for choosing it.
| Key | Action |
|---|---|
Space / ↓ | Open the picker |
↑ ↓ | Move between options |
| Type-ahead | Jump to the first option starting with the typed characters |
Esc | Close without changing |
Tokens
Universal field tokens apply.
Checkboxes
Pick one or more from a visible set.
- Group related checkboxes with a
<fieldset>and legend - Supports indeterminate state for "select all" patterns
Anatomy
Fieldset, legend, then one row per option: 16px box inline-start, label filling the 48px row.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Group | selection-mode | Two or more related options under a legend |
| Standalone | selection-mode | A single consent or acknowledgement |
API
interface CheckboxGroupProps {
legend: string; // the question the group answers — required
options: SelectOption[];
value: string[];
onValueChange: (value: string[]) => void;
selectAll?: boolean; // renders a parent with indeterminate support
}
interface CheckboxProps extends Omit<FieldBase, "hint"> {
checked: boolean | "indeterminate";
onCheckedChange: (checked: boolean) => void;
}
"indeterminate" is a parent-only display state and is never a value the user can set.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Indeterminate | Parent only, via the DOM property plus aria-checked="mixed". Never a value the user can set |
Behavior
- Each checkbox has its own 48px target, achieved through the label — the whole label row is clickable, not just the 16px box.
- Indeterminate is a visual state on the parent only. It is never a value the user can set.
- A single standalone checkbox (consent, terms) still carries the full statement as its label, not "I agree".
Content
A standalone checkbox's label is the full statement being agreed to, never "I agree".
Accessibility
- The
<legend>names the group. Without it, a screen reader announces four unrelated checkboxes. - Indeterminate state uses the
indeterminateDOM property plusaria-checked="mixed".
Keyboard
| Key | Action |
|---|---|
Tab | Each checkbox is its own tab stop — unlike a radio group |
Space | Toggle |
Tokens
Universal field tokens apply. Adds --ds-primary for the checked fill.
Date input
Date entry using three separate fields for day, month, and year.
- Hint text shows expected format (e.g., "15 03 2026")
- Validates each field independently
- Preferred over date picker for birth dates and ID documents
Why three fields for a birth date
A calendar picker for a date of birth requires roughly forty taps to reach 1987. Three numeric fields require eight, and they map directly to how the date is printed on the document the user is copying from. Date picker is for choosing a date; date input is for transcribing one.
Anatomy
Universal field anatomy applies. The control is three numeric fields with their own visible sub-labels — Day, Month, Year.
Variants
One.
API
interface DateInputProps extends Omit<FieldBase, "label"> {
legend: string; // "Date of birth"
value: { day: string; month: string; year: string };
onValueChange: (value: { day: string; month: string; year: string }) => void;
autoAdvance?: boolean; // default: true — a convenience, never a requirement
}
Each part carries its own visible label. Backspace from an empty part moves focus back.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Partially filled | Each part validates independently; the group errors only on blur of the last part |
Behavior
Each part validates independently on blur; the group validates on blur of the last part. Auto-advance moves forward on a complete part and backspace on an empty part moves back.
Content
The hint shows a filled example in the expected order: "15 03 2026".
Accessibility
- Wrapped in a
<fieldset>whose<legend>is the field name ("Date of birth"). - Each part has its own visible label ("Day", "Month", "Year") — not placeholders.
- Auto-advance between parts is a convenience, never a requirement; backspace from an empty field moves back.
- The format hint is referenced via
aria-describedbyon the group.
Keyboard
| Key | Action |
|---|---|
Tab | Between day, month, year |
Backspace on empty | Move back to the previous part |
Tokens
Universal field tokens apply.
Switch
Binary toggle for settings that take effect immediately.
- Use switch for instant changes; use checkbox for form submissions
Sizes
| Size | Track (W x H) | Thumb | Use case |
|---|---|---|---|
sm | 24 x 14px | 12px | Compact lists, settings rows |
default | 32 x 18px | 16px | Standard forms |
lg | 48 x 28px | 24px | Hero toggles, meets WCAG 2.2 AA target size |
Anatomy
Label inline-start, track and thumb inline-end. The visible track is smaller than the 48px row, which is the hit area.
Variants
| Variant | Dimension | Track | Use |
|---|---|---|---|
sm | layout | 24×14 | Compact settings rows |
default | layout | 32×18 | Standard forms |
lg | layout | 48×28 | Prominent toggles — meets the AA target size unaided |
API
interface SwitchProps extends Omit<FieldBase, "hint"> {
checked: boolean;
onCheckedChange: (checked: boolean) => Promise<void> | void;
size?: "sm" | "default" | "lg"; // default: "default"
onError?: (error: Error) => void; // a failed toggle reverts *and* reports
}
A Switch applies immediately and has no Save step. A control that waits for submit is a Checkbox. The label names the setting, not its current state.
States
| State | Visual | Notes |
|---|---|---|
| Off | Track --ds-gray-300, thumb inline-start | |
| On | Track --ds-primary, thumb inline-end | |
| Hover | Track one step darker | |
| Focus-visible | 3px solid --ds-focus outline on the track, 2px offset | |
| Disabled | --ds-disabled-bg, aria-disabled | |
| Pending | Thumb shows inline loading; the track holds its previous position | Applied optimistically only after the request resolves |
| Failed | Reverts to the previous position and surfaces an error | A switch that silently flips back has told the user nothing |
Behavior
- A switch applies immediately and has no Save button. If a Save step exists, the control is a checkbox.
- Failure must be surfaced. A switch that flips back after a failed request, with no message, tells the user nothing. On failure it reverts and shows an error.
- The track's visual size is smaller than its hit area at every size. Even
smgets a 48px target through its row.
Content
The label names the setting, not its state: "Email notifications", not "Email notifications are on".
Accessibility
role="switch"witharia-checked, not a checkbox role. A screen reader announces "on"/"off" rather than "checked".- The label states what the setting does, not its current state: "Email notifications", not "Email notifications are on".
Keyboard
| Key | Action |
|---|---|
Tab | Move focus |
Space | Toggle |
Tokens
Universal field tokens apply. Adds --ds-primary for the on track and --ds-gray-300 for the off track.
Toggle
Pressable button that switches between on and off states. Used for formatting options or view toggles. Supports icon-only or icon+text.
Anatomy
A single button: optional icon inline-start, optional label. 48px hit area regardless of content.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Icon + label | layout | Default |
| Icon only | layout | Requires label as the accessible name |
API
interface ToggleProps {
pressed: boolean;
onPressedChange: (pressed: boolean) => void;
label: string; // names the function ("Bold"); unchanged between states
icon?: React.ReactNode;
showLabel?: boolean; // default: true
}
States
| State | Visual |
|---|---|
| Unpressed | --ds-bg, --ds-muted |
| Pressed | --ds-primary-light fill, --ds-primary, weight 600 |
| Hover | --ds-surface on unpressed only |
| Focus-visible | --ds-focus-ring |
| Disabled | --ds-disabled-text |
Behavior
Applies immediately. A toggle whose effect waits for a submit is a Checkbox.
Content
The label names the function and does not change between states.
Accessibility
aria-pressed on a <button>. Icon-only toggles require an aria-label that names the function ("Bold"), and the pressed state carries the on/off meaning — the label does not change between states.
Keyboard
| Key | Action |
|---|---|
Tab | Move focus |
Space / Enter | Toggle |
Tokens
--ds-primary-light · --ds-primary · --ds-surface · --ds-focus-ring · --ds-radius-md
Textarea
Multi-line text entry for notes, messages, and descriptions.
- Vertical resize by default
- Character count indicator for max-length fields
Anatomy
Universal field anatomy applies. Adds a character count inline-end below the control.
Variants
One; rows and resize are props.
API
interface TextareaProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
rows?: number; // default: 3 — a one-row textarea invites a one-line answer
maxLength?: number; // renders a live count; overflow is allowed and blocks submit
resize?: "vertical" | "none"; // default: "vertical"
}
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Near limit | Count switches to --ds-warning-text at 90% of maxLength |
| Over limit | Count is --ds-error-text, input is still accepted, submit is blocked |
Behavior
- Character count is a live count that becomes a warning near the limit, and it is announced politely — never on every keystroke.
- Hard truncation at the limit is avoided. Blocking input silently is indistinguishable from a broken keyboard; allowing overflow with a visible count and a blocked submit is recoverable.
- Minimum three rows. A one-row textarea reads as a text input and invites a one-line answer.
Content
The hint states what the text is used for, since a free-text field with no stated purpose gets left blank.
Accessibility
The character count is referenced from aria-describedby and announced politely when it crosses into the warning range, not on every keystroke.
Keyboard
| Key | Action |
|---|---|
Tab | Moves focus out — it does not insert a tab character |
Enter | New line; never submits the form |
Tokens
Universal field tokens apply.
Max 200 characters.
0/200Radio group
Pick exactly one from a visible set.
- Vertical layout by default; horizontal for 2-3 short options
- Pre-select a default when a common choice exists
Anatomy
Fieldset, legend, then one row per option: 16px circle inline-start, label filling the 48px row.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Vertical | layout | Default — four to six options |
| Horizontal | layout | Two or three short options |
API
interface RadioGroupProps extends Omit<FieldBase, "label"> {
legend: string;
options: SelectOption[];
value: string | null;
onValueChange: (value: string) => void;
orientation?: "vertical" | "horizontal"; // default: "vertical"; horizontal for 2–3 short options
}
There is no way to clear a radio group once set. Where clearing is meaningful, include an explicit "None" option.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Unselected | Possible only before first interaction. There is no way to clear the group afterwards — add a "None" option where clearing is meaningful |
Behavior
- Pre-select only when one option is genuinely the common case. A pre-selected option in a fee-bearing choice is a decision made on the user's behalf.
- There is no way to clear a radio group once set, so "None" is an explicit option where clearing is meaningful.
Content
Options are mutually exclusive and parallel in form. If two options can both be true, this is a checkbox group.
Accessibility
- One Tab stop for the group; arrow keys move between options and change selection.
<fieldset>+<legend>supplies the group's name.- Each label's 48px row is clickable, not just the 16px circle.
Keyboard
One tab stop for the whole group.
| Key | Action |
|---|---|
Tab | Into and out of the group |
← → ↑ ↓ | Move and select — standard radio behaviour |
Home / End | First / last option |
Tokens
Universal field tokens apply. Adds --ds-primary for the selected fill.
Radio card
Single-select where each option carries more visual weight than a standard radio button. Built on RadioGroup keyboard semantics.
Anatomy
Card container, indicator inline-start, icon, label, description, optional badge, optional detail rows below a separator.
Variants
| Variant | Use case | Icon size |
|---|---|---|
compact | Simple lists (payment method, ID type, coin) | 32px |
expanded | Comparisons with detail rows (transfer routes, plans) | 40px |
API
interface RadioCardOption {
value: string;
label: string;
description?: string;
icon?: React.ReactNode;
badge?: "Cheapest" | "Fastest" | "Recommended"; // computed, never assigned by hand
details?: Array<{ label: string; value: string }>; // expanded variant only
disabled?: boolean;
}
interface RadioCardGroupProps {
legend: string;
variant?: "compact" | "expanded"; // default: "compact"; never mixed within a group
options: RadioCardOption[]; // 6 maximum
value: string | null;
onValueChange: (value: string) => void;
}
badge is part of the option's accessible name, so a screen reader user gets the same shortcut a sighted user does. No interactive elements may appear inside a card.
States
| State | Border | Fill | Indicator |
|---|---|---|---|
| Unselected | 1px --ds-radiocard-border | --ds-radiocard-bg | Empty |
| Hover | 1px --ds-radiocard-border-hover | unchanged | Empty |
| Focus-visible | 2px --ds-radiocard-border-selected + ring | unchanged | Empty |
| Selected | 2px --ds-radiocard-border-selected | --ds-radiocard-bg-selected | Filled |
| Disabled | --ds-radiocard-disabled-border | --ds-radiocard-disabled-bg | Empty, dimmed |
Selection changes border weight, fill, and indicator together. Under a colour vision deficiency the fill is nearly invisible and the border hue barely shifts — the weight and the indicator are what survive.
Behavior
Radio cards are how Senco presents bank versus crypto — as a comparison rather than a configuration choice. Each card shows the two numbers that actually decide it, total cost and arrival time, so a user does not need to understand settlement rails to choose well. See Design principles.
Content
Labels are parallel and name the option, not the mechanism: "Bank transfer", not "SWIFT settlement".
Accessibility
RadioGroup semantics: one tab stop, arrow keys move and select. The badge is part of the option's accessible name. No focusable element may appear inside a card.
Keyboard
One tab stop for the group, as Radio group.
| Key | Action |
|---|---|
Tab | Into and out of the group |
← → ↑ ↓ | Move and select |
Home / End | First / last card |
Tokens
--ds-radiocard-bg · -border · -border-hover · -border-selected · -bg-selected · -indicator · -indicator-on · -disabled-bg · -disabled-border · -disabled-text
Radio card variants
Expanded cards include a separator, key-value detail rows, and an optional badge (Cheapest, Fastest, Recommended).
States
| State | Visual treatment |
|---|---|
| Unselected | 1px border, white background |
| Hover | Darker border, subtle shadow |
| Focus-visible | 2px primary border, focus ring |
| Selected | 2px primary border, tinted background, filled indicator |
| Disabled | Dimmed, not-allowed cursor |
Rules
- Maximum 6 options per group
- Don't mix compact and expanded in the same group
- No interactive elements inside a card
- Pre-select only when paired with a badge explaining why
Why selection uses three signals
Border weight, background tint, and indicator fill all change together. Under a color vision deficiency the tint is nearly invisible and the border color shift is weak — the border weight and the filled indicator are what survive. For a control that decides where money goes, one signal is not enough.
Accessibility
- RadioGroup semantics: one Tab stop, arrow keys move and select.
- The badge ("Cheapest") is part of the option's accessible name, so a screen reader user gets the same shortcut a sighted user does.
- No interactive elements inside a card, because a focusable control inside a radio option has undefined keyboard behavior.
Search select
Searchable dropdown for picking one option from a long list. Opens a popover with auto-focused search, powered by cmdk.
- Selected item shows a check mark; selecting again deselects
- Escape clears search first, then closes on second press
- Hidden input for native form submission
Anatomy
Universal field anatomy applies. The control is a combobox trigger; the popover holds a search field and the option list.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Plain | layout | A flat list |
| Grouped | layout | A "Suggested" group pinned above the rest |
API
interface SearchSelectProps extends FieldBase {
options: SelectOption[];
value: string | null;
onValueChange: (value: string | null) => void; // selecting the current value deselects it
searchKeys?: (option: SelectOption) => string[]; // match on more than the label — code, dial code
emptyMessage?: string; // default: "No results" plus a next step
}
Escape has two stages: clear the search, then close. A mistyped query does not cost the popover.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Open | Popover with the search input auto-focused |
| Filtering | Result count announced politely |
| No results | An informative state with a next step, never an empty popover |
Behavior
- Search matches on more than the visible label. A country search matches "NG", "Nigeria", and the dial code "+234", because users type whichever they know.
- No results is an informative state with a next step, not an empty popover.
- The hidden native input means the control participates in normal form submission and validation.
Content
The empty state names what was searched and offers a next step.
Accessibility
- Combobox pattern:
role="combobox"witharia-expanded,aria-controls, andaria-activedescendant. - Result count is announced politely on filter.
Eschas two stages — clear, then close — so a mistyped search does not lose the popover.
Keyboard
| Key | Action |
|---|---|
Enter / ↓ | Open |
↑ ↓ | Move the active option |
Enter | Select the active option; selecting the current value deselects it |
Esc | Clear the search, then close on a second press |
Tokens
Universal field tokens apply. Adds --ds-searchselect-item-selected-icon, --ds-searchselect-search-bg, --ds-z-popover.
Multi select
Searchable dropdown for picking multiple options. Selected items appear as removable chips.
- Popover stays open after each selection
- Backspace removes the last chip
maxDisplaycontrols chip count before "+N more"maxlimits total selections
Anatomy
Universal field anatomy applies. The control holds chips and a search input; the popover holds the option list.
Variants
One.
API
interface MultiSelectProps extends FieldBase {
options: SelectOption[];
value: string[];
onValueChange: (value: string[]) => void;
maxDisplay?: number; // chips shown before "+N more"
max?: number; // selection ceiling
}
Each chip's remove control is its own button with a specific name ("Remove Nigeria"). Backspace-to-remove is a shortcut, never the only path.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Open | Stays open across selections |
At max | Remaining options are aria-disabled; the limit is stated, not silent |
| Overflow | Chips beyond maxDisplay collapse to "+N more" |
Behavior
The popover stays open across selections. Chips render in selection order, not option order, so the most recent choice is visible. At max the remaining options become aria-disabled with the limit stated.
Content
The trigger shows selections, not a count, until maxDisplay is exceeded.
Accessibility
- Each chip's remove control is its own button with an accessible name ("Remove Nigeria"), not a bare ×.
- Backspace-to-remove is a shortcut, never the only removal path (SC 2.1.1).
- Selection and removal are announced politely, with the running count.
Keyboard
| Key | Action |
|---|---|
↑ ↓ | Move the active option |
Enter | Toggle the active option |
Backspace on empty search | Remove the last chip — a shortcut, never the only path |
Esc | Clear the search, then close |
Tokens
Universal field tokens apply. Adds --ds-multiselect-chip-bg, -chip-border, -chip-text, -chip-remove-hover, -search-bg, -item-selected-icon.
2 selected
Input OTP
Separate fields for each digit of a one-time password.
- Auto-focuses the next field on input
- Paste fills all fields at once
Paste must work
WCAG 2.2 SC 3.3.8 requires that authentication not depend on a cognitive-function test, and blocking paste on a one-time code forces the user to memorize and re-key six digits under a timer. Paste fills every field from a single event, whichever field receives it.
Anatomy
Universal field anatomy applies. The control is length single-character fields with --ds-space-2 between them, plus a countdown and a resend action below.
Variants
By length. Six is the default.
API
interface InputOTPProps extends Omit<FieldBase, "label"> {
legend: string; // "One-time code"
length: number; // default: 6
value: string;
onValueChange: (value: string) => void;
onComplete?: (value: string) => void;
expiresAt?: Date; // renders a role="timer" countdown
onResend?: () => void; // always available
}
Paste fills every field from a single event on any field. Blocking paste forces the user to memorise a code under a timer and fails SC 3.3.8.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Partially filled | Focus sits on the first empty field |
| Complete | onComplete fires; fields stay editable |
| Expired | Fields clear, the resend action takes focus |
Behavior
Typing a digit advances; backspace on an empty field moves back and clears. Paste fills every field from one event regardless of which field received it. On expiry the fields clear and focus moves to resend.
Content
The legend says where the code came from — "Enter the code we sent to +234 ••• 4821" — so a user who did not receive it knows which channel failed.
Accessibility
- The group has one accessible name ("One-time code"), and each field announces its position ("digit 3 of 6").
- Backspace from an empty field moves focus back and clears the previous digit.
- The code's expiry countdown uses
role="timer"with polite announcements, not a silent visual clock. - A resend path is always available and is not on a hidden timer.
Keyboard
| Key | Action |
|---|---|
| Digit | Fill and advance |
Backspace | Clear the current field, or move back and clear when already empty |
← → | Move between fields |
Ctrl/Cmd+V | Fill every field from one paste, on any field |
Tokens
Universal field tokens apply. Adds --ds-text-xl for the digits.
File uploader
Compound component for file upload. Primary use: KYC verification (ID documents, proof of address, selfie).
Four parts: FileUpload (root state manager), FileUploadDropzone (drag-and-drop area), FileUploadItem (file row with progress), and FileUploadItemList (renders all items).
Anatomy
Dropzone with an icon, instruction, and a browse button; below it a list of file rows, each with name, size, progress, and a remove control.
Variants
| Variant | Dimension | Use |
|---|---|---|
| Single | selection-mode | ID document, selfie |
| Multiple | selection-mode | Proof-of-address bundles |
API
interface UploadFile {
id: string;
name: string;
size: number;
status: "pending" | "uploading" | "complete" | "error";
progress?: number;
error?: string;
}
interface FileUploaderProps extends Omit<FieldBase, "readOnly"> {
accept: string; // stated to the user before they pick, not after failure
maxSize: number; // bytes, stated up front
multiple?: boolean; // default: false
files: UploadFile[];
onFilesAdd: (files: File[]) => void;
onFileRemove: (id: string) => void;
onFileRetry: (id: string) => void;
onFileCancel: (id: string) => void;
timeoutMs?: number; // default: 30000 — see Tokens > Timing and threshold values
}
The dropzone is always also a button. Drag is pointer-only and fails SC 2.5.7 as a sole path.
States
| Status | Visual treatment |
|---|---|
| Pending | Muted icon, file size |
| Uploading | Primary icon, progress bar, percentage |
| Complete | Green check, success border |
| Error | Red alert, error border, message |
Behavior
- Drag-and-drop is an enhancement. The dropzone is always also a button that opens the file picker — drag is a pointer-only interaction and fails SC 2.5.7 as a sole path.
- Accepted formats and the size limit are stated before the user picks a file, not after the upload fails.
- A failed upload keeps the file in the list with a retry action. Removing it forces the user to find the document again.
- On a slow connection, progress is real and the upload is cancellable. This is the longest-running operation in the product.
Content
Accepted formats and the size limit are stated in the dropzone, before the user picks a file.
Accessibility
- The dropzone is a labelled
<button>wrapping a visually hidden<input type="file">. - Each file's state change is announced politely; failures are announced assertively.
- Progress uses
role="progressbar"with a visible percentage. - Each file row's remove control has a specific name ("Remove passport.jpg").
Keyboard
| Key | Action |
|---|---|
Tab | Dropzone, then each file's actions |
Enter / Space | Open the file picker |
Tokens
--ds-fileupload-dropzone-border · -border-active · -bg-active · --ds-green-600 · --ds-red-600
Drag and drop or browse
PDF, JPG, PNG up to 10 MB
Date picker
Single date selection with typed input and calendar popover.
- Typing-first: dashes insert automatically as users type
DD-MM-YYYY - Month/year dropdown navigation for fast jumping (ideal for DOB)
- Invalid input reverts to previous valid date on blur
Anatomy
Universal field anatomy applies. Adds a calendar trigger inline-end and a popover containing a month grid with month and year selects.
Variants
One. Range selection is a separate component.
API
interface DatePickerProps extends FieldBase {
value: Date | null;
onValueChange: (value: Date | null) => void;
min?: Date;
max?: Date;
isDateDisabled?: (date: Date) => string | false; // returns the reason, not just true
}
The text input is the primary control; the calendar is the alternative. isDateDisabled returns a reason because "unavailable" without a why is a dead end.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Open | Calendar popover; the text input remains the primary control |
| Invalid typed value | Reverts to the last valid date on blur, and says so |
Behavior
Typing is primary — dashes insert automatically as the user types. An unparseable value reverts to the last valid date on blur and says so. The calendar never opens on focus, only on its trigger.
Content
Disabled dates state a reason on focus, not merely that they are unavailable.
Accessibility
- The text input is the primary control; the calendar is an alternative. A calendar-only date field is unusable with a screen reader and slow for everyone.
- Grid pattern in the popover: arrow keys move by day,
PageUp/PageDownby month,Esccloses and returns focus to the input. - The focused date is announced in full ("Tuesday, 12 March 2026"), not as a bare number.
- Disabled dates say why ("Weekends unavailable"), not merely that they are unavailable.
Keyboard
| Key | Action |
|---|---|
↑ ↓ ← → | Move by week and day |
PageUp / PageDown | Previous / next month |
Enter | Select the focused date |
Esc | Close and return focus to the input |
Tokens
Universal field tokens apply. Adds --ds-white, --ds-shadow-lg, --ds-z-popover, --ds-primary-light for the selected day.
Currency input
Formatted money input with currency symbol prefix and code suffix. Uses inputMode="decimal" for the right mobile keyboard without native spinners.
- Focus shows raw number (
1250.00), blur applies locale formatting (1,250.00) - Fraction digits adapt to currency (KRW = 0, USD = 2)
- Currency symbol from
Intl.NumberFormat-- no hardcoded table
Anatomy
Universal field anatomy applies. Adds a symbol prefix and a currency-code suffix, which is a Select in the selectable variant.
Variants
| Variant | Suffix | When to use |
|---|---|---|
| Static | InputGroupText with currency code | Single-currency fields (receive amount) |
| Selectable | Select dropdown | Multi-currency fields (send amount) |
Sizes
| Size | Height | Font | Use case |
|---|---|---|---|
default | 48px (--ds-input-height) | --ds-text-base | Standard form fields |
hero | 64px min, --ds-space-2 block padding | --ds-text-display, weight 700, line height 1.2 | Single-currency amount entry — identical to Amount input's hero |
API
interface CurrencyInputProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
currency: CurrencyOption;
currencies?: CurrencyOption[]; // presence selects the selectable variant
onCurrencyChange?: (currency: CurrencyOption) => void;
size?: "default" | "hero"; // default: "default"
locale?: string; // default: the app locale
}
Formats on blur, shows the raw number on focus. Precision comes from currency.minorUnits, never from the component. For the send screen's primary amount field use Amount input instead — it adds the conversion line and the rate quote.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Focused | Shows the raw number — grouping separators are removed while editing |
| Blurred | Locale formatting applied at currency.minorUnits precision |
Over max | Errors inline and keeps the entered value |
Behavior
- Formatting on blur, raw on focus. Editing a value with grouping separators in place is where caret-position bugs live.
- Precision comes from the currency, never from the component. KRW has no minor unit; BTC has eight.
- The currency code is always visible. A symbol alone is ambiguous —
$is at least six currencies.
Content
The label says what the amount is for. The currency code is always visible alongside the symbol.
Accessibility
Requires an aria-label where no visible label is rendered. The formatted value is what gets announced, so "1,250.00 USD" reads as an amount rather than as a digit string.
Keyboard
| Key | Action |
|---|---|
Tab | Field, then the currency control when selectable |
| Digits and the locale decimal mark | Accepted (. is always accepted as an alias); everything else is rejected without clearing |
Tokens
Universal field tokens apply.
Phone input
Phone number entry with country code selector. Uses a SearchSelect dropdown showing flag and dial code alongside a tel input.
- Accepts raw digits only -- paste strips non-numeric characters
suggestedCountriesprop shows a "Suggested" group at the top
Anatomy
Universal field anatomy applies. The control is an Input group: country selector inline-start, tel field filling the rest.
Variants
One. The suggested-countries group is a prop, not a variant.
API
interface PhoneInputProps extends Omit<FieldBase, "label"> {
legend: string;
country: string; // ISO 3166-1 alpha-2
onCountryChange: (code: string) => void;
value: string; // digits only
onValueChange: (value: string) => void;
suggestedCountries?: string[];
}
Paste strips spaces, dashes, and parentheses so a number copied from a contact card works unchanged.
States
Universal states apply to the number field. The country selector carries Search select's states.
Behavior
Paste strips spaces, dashes, and parentheses. Changing the country does not clear the number — a user correcting the country prefix has not changed their phone number.
Content
The legend names the whole field ("Phone number"); the two parts are not separately labelled in the visible text.
Accessibility
- The country selector and the number field are two controls under one group label.
- The selector's accessible name includes the dial code ("Country code, Nigeria, plus 234"); the flag alone carries nothing.
- Paste normalization strips spaces, dashes, and parentheses so a number copied from a contact card works unchanged.
Keyboard
| Key | Action |
|---|---|
Tab | Country selector, then the number field |
| Type-ahead in the selector | Matches country name and dial code |
Tokens
Universal field tokens apply. Adds --ds-size-icon-sm for the flag.
Password input
Password entry with show/hide toggle. Eye icon toggles type between password and text, then returns focus to the input.
Strength indicator
When showStrength is true and value is non-empty, a strength bar appears below.
| Score | Label | Width | Color |
|---|---|---|---|
| 1 | Weak | 25% | red-500 |
| 2 | Fair | 50% | yellow-500 |
| 3 | Good | 75% | accent-500 |
| 4 | Strong | 100% | green-600 |
Scoring: +1 for 8+ characters, +1 for a digit, +1 for a special character, +1 for 12+ characters with all above met.
Anatomy
Universal field anatomy applies. Adds a visibility toggle inline-end and, when enabled, a strength bar and label below.
Variants
By autoComplete: current-password omits the strength bar, new-password shows it.
API
interface PasswordInputProps extends FieldBase {
value: string;
onValueChange: (value: string) => void;
showStrength?: boolean; // default: false
requirements?: string[]; // listed before typing, ticked off as met
autoComplete?: "current-password" | "new-password";
}
Paste is never blocked — blocking it breaks password managers. The visibility toggle returns focus to the input with the caret preserved. Strength announcements are debounced 500ms.
States
All rows of the universal states table apply. Deviations and additions:
| State | Deviation |
|---|---|
| Hidden / visible | Toggling preserves the caret position and returns focus to the input |
| Strength shown | Only when showStrength and the value is non-empty; announced on a 500ms debounce |
Behavior
- Show/hide returns focus to the input with the caret preserved. Losing the caret mid-password is why users start over.
- Paste is never blocked. Blocking paste breaks password managers, which is the single biggest practical security regression a form can cause.
- Requirements are listed before typing, and each is ticked off as it is met — not revealed one at a time through repeated failures.
Content
Requirements are listed before typing and ticked off as met, never revealed one failure at a time.
Accessibility
- The toggle is a
<button>witharia-pressedand a name that changes with state ("Show password" / "Hide password"). - Strength uses
role="status"witharia-live="polite", and the label is text — the colored bar alone is meaningless to a colorblind user. - The strength announcement is debounced so it does not fire on every character.
Keyboard
| Key | Action |
|---|---|
Tab | Field, then the visibility toggle |
Ctrl/Cmd+V | Always permitted |
Tokens
Universal field tokens apply. Adds --ds-red-500, --ds-yellow-500, --ds-accent-500, --ds-green-600 for the strength bar.
Masked input
Auto-formatted text entry. Mask characters: 0 = digit, A = letter, * = alphanumeric. Everything else is a literal separator inserted automatically.
Presets
| Preset | Pattern | Use case |
|---|---|---|
MASKS.CARD_NUMBER | 0000 0000 0000 0000 | Credit/debit card |
MASKS.CARD_EXPIRY | 00/00 | Card expiry (MM/YY) |
MASKS.CVV_3 | 000 | 3-digit CVV |
MASKS.CVV_4 | 0000 | 4-digit CVV (Amex) |
MASKS.DATE | 00/00/0000 | Date (DD/MM/YYYY) |
For expiry masks, typing a first digit > 1 auto-prepends "0" (typing "5" becomes "05"). detectCardBrand(digits) returns "visa", "mastercard", "amex", or "unknown" for dynamic card icons.
Anatomy
Universal field anatomy applies. Adds an optional brand mark inline-end, driven by detectCardBrand.
Variants
By mask preset — card number, expiry, CVV, date. The mask is the variant dimension.
API
type Mask = string; // "0" digit, "A" letter, "*" alphanumeric, anything else a literal
declare const MASKS: {
CARD_NUMBER: Mask; CARD_EXPIRY: Mask; CVV_3: Mask; CVV_4: Mask; DATE: Mask;
};
interface MaskedInputProps extends FieldBase {
mask: Mask;
value: string; // unmasked; the component owns the separators
onValueChange: (value: string) => void;
}
declare function detectCardBrand(digits: string): "visa" | "mastercard" | "amex" | "unknown";
Backspace removes a digit, not a separator. Pasted values are normalised through the mask.
States
Universal states apply. The masked value never partially clears: an invalid character is rejected, not accepted and then stripped.
Behavior
- Pasted values are normalized through the mask. A card number copied with dashes formats correctly rather than failing validation.
- Separators are inserted, never required. The user types digits; the mask does the rest.
- Backspace removes a digit, not a separator — deleting a space the user did not type is the classic masked-input frustration.
Content
The hint states the expected format in words, not only as a mask: "16 digits, on the front of your card".
Accessibility
- Requires an
aria-labelwhere no visible label is rendered. - The expected format is in helper text, referenced via
aria-describedby, not only implied by the mask. - The masked value is announced in grouped chunks rather than as a run of digits.
Keyboard
| Key | Action |
|---|---|
| Digit | Fills the next mask slot; separators are inserted automatically |
Backspace | Removes a digit, never a separator |
Ctrl/Cmd+V | Normalised through the mask |
Tokens
Universal field tokens apply. Adds --ds-size-icon-sm for the brand mark.
Input group
An input with an attached prefix, suffix, or addon control — a currency code, a unit, a dial code, a search icon, or a trailing button.
Anatomy
┌──────┬────────────────────────┬───────┐
│ $ │ 1,250.00 │ USD ▾│
└──────┴────────────────────────┴───────┘
prefix input suffix
(text) (control)
| Part | Type | Focusable |
|---|---|---|
| Prefix | Text or icon | No |
| Input | The control | Yes |
| Suffix (text) | Unit, currency code | No |
| Suffix (control) | Select, button | Yes |
Variants
| Variant | Dimension | Suffix |
|---|---|---|
| Static | behavior | Text or icon, aria-hidden when redundant |
| Interactive | behavior | A control with its own name and 48px target |
API
interface InputGroupProps {
prefix?: React.ReactNode; // text or icon; aria-hidden when its content is already in the label
suffix?: React.ReactNode;
suffixInteractive?: boolean; // default: false — when true the suffix owns its own name and 48px target
children: React.ReactElement; // the control
}
One focus ring around the whole assembly, not one per part. Two rings inside one control read as two controls.
States
The group renders one focus ring around the whole assembly whenever any part inside it has focus. Individual parts show no ring of their own.
| State | Rendering |
|---|---|
| Default | Single border around the assembly |
| Focus-visible | One --ds-focus-ring on the group |
| Error | Border --ds-red-600 and fill --ds-error-bg on the group, --ds-focus-ring-error |
| Disabled | Whole group, including an interactive suffix |
Behavior
- The group renders one focus ring around the whole assembly, not one per part. Two rings inside one control read as two controls.
- Non-interactive prefixes and suffixes are
aria-hiddenwhere their content is already in the label or the value. - An interactive suffix is a real control with its own accessible name and its own 48px target — it does not inherit the input's label.
- The group's height is
--ds-input-height; addons never make the control taller than 48px.
Content
A unit suffix is the currency code or the unit, never a symbol alone — $ is at least six currencies.
Accessibility
- The
<label>points at the input, not at the group wrapper. - A unit suffix that carries meaning ("USD") is referenced from
aria-describedbyso it is announced with the value. - Where the suffix is a control, the two parts are announced as separate controls under a shared group label.
Keyboard
| Key | Action |
|---|---|
Tab | The control, then an interactive suffix — a static suffix is skipped |
Tokens
Universal field tokens apply.
Form field
Wraps a label, input, helper text, and error into one accessible unit. You provide the input as children -- FormField doesn't render its own.
FormLabellinks to the input viahtmlForFormDescriptionshows helper text between the label and the control, and stays visible when an error is activeFormMessageshows the error below the control. It is not a live region and carries norole="alert"— it is read througharia-describedbywhen the user returns to the field- Required fields get an asterisk in the label
Why the wrapper exists
Label association, aria-describedby wiring, aria-invalid, id generation, and error precedence are five things that must be correct on every field and are five things that get missed when each form wires them by hand. FormField makes correct the default and incorrect the deviation.
Composition rules
- One input per FormField. Two controls that belong together are an Input group inside one FormField.
- The description stays when an error appears.
aria-describedbyreferences the error first and the description second. - The required marker is in the label text, with a legend explaining it — never conveyed by color alone.
Anatomy
The wrapper itself renders the universal anatomy — label, slot for the control, hint, error. It contributes no visual of its own.
Variants
One. A wrapper with variants would be a second layout system.
API
interface FormFieldProps {
children: React.ReactElement; // exactly one control; two related controls are an Input group
}
FormField owns id generation, label association, aria-describedby composition, aria-invalid, and error-before-hint ordering. It renders no control of its own. Wiring these by hand is where five of them get done and the sixth gets missed.
States
FormField has no visual states of its own. It owns state composition:
| Condition | Effect |
|---|---|
error present | Renders below the control, sets aria-invalid, points aria-describedby at the error then the hint |
hint only | aria-describedby points at the hint |
| Both absent | No aria-describedby at all — an empty reference is worse than none |
required | Marker in the label text; the legend explaining it belongs to the form |
Behavior
Generates an id when none is given, binds the label, composes aria-describedby from hint and error, sets aria-invalid, and orders the error before the hint.
Content
The label is a noun phrase naming the value, not an instruction. "Recipient's account number", not "Enter the account number".
Accessibility
Binds <label for> to the control's id, composes aria-describedby from hint and error, and sets aria-invalid. When neither hint nor error is present it emits no aria-describedby at all — an empty reference is worse than none.
Keyboard
Transparent — the wrapped control owns all keyboard behaviour.
Tokens
Universal field tokens apply.
As it appears on your ID
Auto-generated, cannot be changed
We will send a verification link
Enter a valid email address
Use letters, numbers, and symbols
Must be at least 8 characters
You can change this later
Design and accessibility
Every input needs a visible label above the field. Error states use color, icon, and text together -- never color alone. Group related inputs with a <fieldset> and legend.
All error inputs must include aria-invalid="true" and aria-describedby pointing to the error message. Switch uses role="switch" with aria-checked. Radio cards convey selection through three independent signals: border weight, background tint, and indicator fill.
Every component on this page, including Currency, Phone, Password, and Masked input, extends FieldBase and therefore renders a visible label. None relies on aria-label. Password strength uses role="status" with aria-live="polite".
Do and don't
| Do | Don't |
|---|---|
| Put the label above the field | Use the placeholder as the label |
| Validate on blur | Validate on every keystroke |
| Keep the user's input when validation fails | Clear the field so they retype it |
| Let OTP and password fields accept paste | Block paste "for security" |
| Offer a button beside every dropzone | Ship drag-and-drop as the only upload path |
| Use three fields for a date of birth | Make someone tap back to 1987 in a calendar |
| Size the field to its content | Make a 3-digit CVV field full width |
Set autocomplete on standard fields | Disable autofill and make users retype their address |
Known gaps
- No component on this page documents its offline or slow-connection behavior except File uploader.
- Password strength scoring is length-and-character based, which overstates the strength of predictable passwords.
Input grouphas a preview in the documentation site but was previously missing from this spec.- No spec covers async validation (checking an account number against a bank) or its loading state.