Skip to main content
ENKO

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

ComponentPurpose
InputSingle-line text (text, email, tel, password, number, search, URL)
TextareaMulti-line text entry
SelectPick one option from a dropdown
CheckboxesPick one or more from a visible set
Radio groupPick exactly one from a visible set
Radio cardSingle-select with card-style layout (payment methods, route comparison)
Date inputStructured day/month/year fields
SwitchOn/off toggle for settings
TogglePressable button for toggling states (bold, italic, view mode)
Search selectSearchable dropdown for one option from a long list
Multi selectSearchable dropdown with chips for multiple selections
Input OTPIndividual digit fields for one-time passwords
File uploaderDrag-and-drop or browse file upload
Date pickerCalendar popover with typed date entry
Currency inputFormatted money input with symbol and code
Phone inputPhone number with country code selector
Password inputShow/hide toggle with optional strength meter
Masked inputAuto-formatted entry (card number, expiry, CVV)
Input groupInput with attached prefix, suffix, or addon control
Form fieldWraps 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.

RuleWhy
Visible label above the field, alwaysA placeholder disappears the moment a user types, leaving them unable to check what the field was for
Never use a placeholder as a labelFails 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 textRed alone fails SC 1.4.1
aria-invalid + aria-describedby on every errorThe message must be attached, not merely adjacent
Required is marked in the label, not by colorAn asterisk with a legend, or the word "optional" on the others
Validate on blur, never on keystrokeTelling someone their email is invalid after two characters is noise
A validation error never clears the user's inputLosing 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

StateFieldToken
Default1px --ds-border--ds-input-height
HoverBorder --ds-gray-400—
Focus-visibleBorder --ds-primary plus a 3px halo at 20%, no offset--ds-focus-ring
FilledSame as default—
ErrorBorder --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-textaria-disabled
Read-onlyDefault 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
PartRequiredToken
LabelYes--ds-text-sm, --ds-font-semibold, --ds-heading
HintNo--ds-text-xs, --ds-muted
ControlYes--ds-input-height, --ds-border, --ds-radius-md
ValueYes--ds-text-base, --ds-fg
PlaceholderNo--ds-placeholder — 4.50:1 light, 5.58:1 dark
ErrorNo--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

OptionsComponent
2–3, short labelsRadio group, horizontal
4–6Radio group, vertical
4–6 with detail worth comparingRadio card
7–15Select
15+Search select
Multiple from manyMulti select
One instant on/off settingSwitch
One or more, all visibleCheckboxes

Input

Single-line text entry. Each input requires a visible label above the field.

  • Supports text, email, tel, password, number, search, and url types
  • 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

VariantDimensionUse
Defaultlayoutwidth="full"
Content-widthlayoutSized 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:

StateDeviation
AutofilledThe 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 type and inputmode are chosen for the keyboard they summon, not just for validation. inputmode="numeric" for an account number saves a user four taps per field.
  • autocomplete is 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 of inputmode="decimal". Number inputs add spinner arrows, accept e, 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 input id. Not a <div> above it.
  • Helper text and errors are both referenced from aria-describedby.
  • The helper stays when an error appears. aria-describedby lists the error first, then the helper, so the actionable half is read first.

Keyboard

KeyAction
TabMove focus in and out
EscClear, when type="search"

Tokens

Universal field tokens apply.

Input anatomy
1
3
5
We will send a verification link
4
Enter a valid email address
6
  1. 1Container / border
  2. 2Label
  3. 3Placeholder text
  4. 4Helper text
  5. 5Error icon
  6. 6Error message
Input states
DefaultAs it appears on your ID
Focus
Filled
Read-only
ErrorEnter a valid email address
Disabled
Skeleton

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

VariantDimensionUse
Plainlayout7–15 options, no grouping
GroupedlayoutsuggestedOptions 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
}
DecisionContract
ElementNative <select> on every device, not only touch. The tension between "used on mobile" and "wherever possible" resolves to native everywhere
SortingThe component groups suggestedOptions first, then renders options in the order given. The caller sorts within each group
15-option ceilingA guideline, not enforced. Above 15, use Search select — the component does not police it
readOnlyA 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
disabledaria-disabled="true" on the <select>, which stays focusable; any change is reverted, as with readOnly. The native disabled attribute is never set
requiredaria-required="true" only. The native required attribute is not set — its browser bubble would compete with the error summary
Placeholder optionRendered 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"
GroupsRendered only when suggestedOptions is non-empty: an <optgroup> for suggested, then one for all, labelled from groupLabels
ChevronThe 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:

StateDeviation
UnselectedShows the placeholder as an instruction, not a blank row. value is null, not ""
OpenThe OS picker owns the open state; the component renders no popover
Read-onlyaria-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.

KeyAction
Space / ↓Open the picker
↑ ↓Move between options
Type-aheadJump to the first option starting with the typed characters
EscClose without changing

Tokens

Universal field tokens apply.

Select states
Default
Hover
Focus
Disabled
ErrorPlease select a transfer reason

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

VariantDimensionUse
Groupselection-modeTwo or more related options under a legend
Standaloneselection-modeA 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:

StateDeviation
IndeterminateParent 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 indeterminate DOM property plus aria-checked="mixed".

Keyboard

KeyAction
TabEach checkbox is its own tab stop — unlike a radio group
SpaceToggle

Tokens

Universal field tokens apply. Adds --ds-primary for the checked fill.

Checkbox
Notifications
Checkbox states
Unchecked
Checked
Focus
Indeterminate
Disabled
Disabled checked

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:

StateDeviation
Partially filledEach 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-describedby on the group.

Keyboard

KeyAction
TabBetween day, month, year
Backspace on emptyMove back to the previous part

Tokens

Universal field tokens apply.

Date input
For example, 15 03 2026

Switch

Binary toggle for settings that take effect immediately.

  • Use switch for instant changes; use checkbox for form submissions

Sizes

SizeTrack (W x H)ThumbUse case
sm24 x 14px12pxCompact lists, settings rows
default32 x 18px16pxStandard forms
lg48 x 28px24pxHero 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

VariantDimensionTrackUse
smlayout24×14Compact settings rows
defaultlayout32×18Standard forms
lglayout48×28Prominent 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

StateVisualNotes
OffTrack --ds-gray-300, thumb inline-start
OnTrack --ds-primary, thumb inline-end
HoverTrack one step darker
Focus-visible3px solid --ds-focus outline on the track, 2px offset
Disabled--ds-disabled-bg, aria-disabled
PendingThumb shows inline loading; the track holds its previous positionApplied optimistically only after the request resolves
FailedReverts to the previous position and surfaces an errorA 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 sm gets 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" with aria-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

KeyAction
TabMove focus
SpaceToggle

Tokens

Universal field tokens apply. Adds --ds-primary for the on track and --ds-gray-300 for the off track.

Switch
Switch sizes
sm (24×14)
default (32×18)
lg (48×28)
Switch states
Off
On
Hover (off)
Hover (on)
Focus
Disabled (off)
Disabled (on)

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

VariantDimensionUse
Icon + labellayoutDefault
Icon onlylayoutRequires 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

StateVisual
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

KeyAction
TabMove focus
Space / EnterToggle

Tokens

--ds-primary-light · --ds-primary · --ds-surface · --ds-focus-ring · --ds-radius-md

Toggle states
Default
Hover
Focus
Pressed
Disabled

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:

StateDeviation
Near limitCount switches to --ds-warning-text at 90% of maxLength
Over limitCount 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

KeyAction
TabMoves focus out — it does not insert a tab character
EnterNew line; never submits the form

Tokens

Universal field tokens apply.

Textarea

Max 200 characters.

0/200
Textarea states
ErrorPlease provide at least 10 characters
Disabled

Radio 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

VariantDimensionUse
VerticallayoutDefault — four to six options
HorizontallayoutTwo 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:

StateDeviation
UnselectedPossible 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.

KeyAction
TabInto and out of the group
← → ↑ ↓Move and select — standard radio behaviour
Home / EndFirst / last option

Tokens

Universal field tokens apply. Adds --ds-primary for the selected fill.

Radio group
Delivery speed

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

VariantUse caseIcon size
compactSimple lists (payment method, ID type, coin)32px
expandedComparisons 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

StateBorderFillIndicator
Unselected1px --ds-radiocard-border--ds-radiocard-bgEmpty
Hover1px --ds-radiocard-border-hoverunchangedEmpty
Focus-visible2px --ds-radiocard-border-selected + ringunchangedEmpty
Selected2px --ds-radiocard-border-selected--ds-radiocard-bg-selectedFilled
Disabled--ds-radiocard-disabled-border--ds-radiocard-disabled-bgEmpty, 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.

KeyAction
TabInto and out of the group
← → ↑ ↓Move and select
Home / EndFirst / last card

Tokens

--ds-radiocard-bg · -border · -border-hover · -border-selected · -bg-selected · -indicator · -indicator-on · -disabled-bg · -disabled-border · -disabled-text

Radio card (compact)
Credit cardVisa, Mastercard, Amex
Bank transferDirect from your bank account
Crypto walletUSDC, USDT, BTC, ETH
Mobile moneyNot available in your region

Radio card variants

Expanded cards include a separator, key-value detail rows, and an optional badge (Cheapest, Fastest, Recommended).

States

StateVisual treatment
Unselected1px border, white background
HoverDarker border, subtle shadow
Focus-visible2px primary border, focus ring
Selected2px primary border, tinted background, filled indicator
DisabledDimmed, 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.
Radio card (expanded)
Bank transferArrives in 1-2 business days
Fee$2.99
Exchange rate1 USD = 1,380 KRW
Recipient gets$247.01
Cheapest
Crypto transferArrives in 5-30 minutes
Fee$4.50
NetworkUSDC on Polygon
Recipient gets$245.50
Fastest

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

VariantDimensionUse
PlainlayoutA flat list
GroupedlayoutA "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:

StateDeviation
OpenPopover with the search input auto-focused
FilteringResult count announced politely
No resultsAn 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" with aria-expanded, aria-controls, and aria-activedescendant.
  • Result count is announced politely on filter.
  • Esc has two stages — clear, then close — so a mistyped search does not lose the popover.

Keyboard

KeyAction
Enter / ↓Open
↑ ↓Move the active option
EnterSelect the active option; selecting the current value deselects it
EscClear 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.

Search select

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
  • maxDisplay controls chip count before "+N more"
  • max limits 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:

StateDeviation
OpenStays open across selections
At maxRemaining options are aria-disabled; the limit is stated, not silent
OverflowChips 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

KeyAction
↑ ↓Move the active option
EnterToggle the active option
Backspace on empty searchRemove the last chip — a shortcut, never the only path
EscClear 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.

Multi select

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:

StateDeviation
Partially filledFocus sits on the first empty field
CompleteonComplete fires; fields stay editable
ExpiredFields 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

KeyAction
DigitFill and advance
BackspaceClear the current field, or move back and clear when already empty
← →Move between fields
Ctrl/Cmd+VFill every field from one paste, on any field

Tokens

Universal field tokens apply. Adds --ds-text-xl for the digits.

OTP input
Enter the 6-digit code sent to your phone.

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

VariantDimensionUse
Singleselection-modeID document, selfie
Multipleselection-modeProof-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

StatusVisual treatment
PendingMuted icon, file size
UploadingPrimary icon, progress bar, percentage
CompleteGreen check, success border
ErrorRed 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

KeyAction
TabDropzone, then each file's actions
Enter / SpaceOpen the file picker

Tokens

--ds-fileupload-dropzone-border · -border-active · -bg-active · --ds-green-600 · --ds-red-600

File uploader — interactive

Drag and drop or browse

PDF, JPG, PNG up to 10 MB

File uploader — status states
passport.pdf65%
x
proof_of_address.pdf
selfie.png
File exceeds 10 MB limit

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:

StateDeviation
OpenCalendar popover; the text input remains the primary control
Invalid typed valueReverts 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/PageDown by month, Esc closes 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

KeyAction
↑ ↓ ← →Move by week and day
PageUp / PageDownPrevious / next month
EnterSelect the focused date
EscClose 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.

Date picker — interactive
Default
Disabled
Error
Please select a valid date

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

VariantSuffixWhen to use
StaticInputGroupText with currency codeSingle-currency fields (receive amount)
SelectableSelect dropdownMulti-currency fields (send amount)

Sizes

SizeHeightFontUse case
default48px (--ds-input-height)--ds-text-baseStandard form fields
hero64px min, --ds-space-2 block padding--ds-text-display, weight 700, line height 1.2Single-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:

StateDeviation
FocusedShows the raw number — grouping separators are removed while editing
BlurredLocale formatting applied at currency.minorUnits precision
Over maxErrors 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

KeyAction
TabField, then the currency control when selectable
Digits and the locale decimal markAccepted (. is always accepted as an alias); everything else is rejected without clearing

Tokens

Universal field tokens apply.

Currency input
Default
$
C$10 CAD — C$10,000 CAD
Read-only
$
USD
Error
$
CAD
Maximum transfer amount is $10,000.00 CAD
Disabled
$
CAD

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
  • suggestedCountries prop 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

KeyAction
TabCountry selector, then the number field
Type-ahead in the selectorMatches country name and dial code

Tokens

Universal field tokens apply. Adds --ds-size-icon-sm for the flag.

Phone input
Default
We will send a verification code
Suggested countries
Error
Enter a valid phone number
Disabled

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.

ScoreLabelWidthColor
1Weak25%red-500
2Fair50%yellow-500
3Good75%accent-500
4Strong100%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:

StateDeviation
Hidden / visibleToggling preserves the caret position and returns focus to the input
Strength shownOnly 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> with aria-pressed and a name that changes with state ("Show password" / "Hide password").
  • Strength uses role="status" with aria-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

KeyAction
TabField, then the visibility toggle
Ctrl/Cmd+VAlways permitted

Tokens

Universal field tokens apply. Adds --ds-red-500, --ds-yellow-500, --ds-accent-500, --ds-green-600 for the strength bar.

Password input
Default
With strength indicator
Use letters, numbers, and symbols
Error
Must be at least 8 characters
Disabled

Masked input

Auto-formatted text entry. Mask characters: 0 = digit, A = letter, * = alphanumeric. Everything else is a literal separator inserted automatically.

Presets

PresetPatternUse case
MASKS.CARD_NUMBER0000 0000 0000 0000Credit/debit card
MASKS.CARD_EXPIRY00/00Card expiry (MM/YY)
MASKS.CVV_30003-digit CVV
MASKS.CVV_400004-digit CVV (Amex)
MASKS.DATE00/00/0000Date (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-label where 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

KeyAction
DigitFills the next mask slot; separators are inserted automatically
BackspaceRemoves a digit, never a separator
Ctrl/Cmd+VNormalised through the mask

Tokens

Universal field tokens apply. Adds --ds-size-icon-sm for the brand mark.

Masked input — Card payment
Card number
Expiry date
CVV
Masked input — States
Error
Enter a complete card number
Disabled
Read-only
Visa

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)
PartTypeFocusable
PrefixText or iconNo
InputThe controlYes
Suffix (text)Unit, currency codeNo
Suffix (control)Select, buttonYes

Variants

VariantDimensionSuffix
StaticbehaviorText or icon, aria-hidden when redundant
InteractivebehaviorA 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.

StateRendering
DefaultSingle border around the assembly
Focus-visibleOne --ds-focus-ring on the group
ErrorBorder --ds-red-600 and fill --ds-error-bg on the group, --ds-focus-ring-error
DisabledWhole 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-hidden where 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-describedby so 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

KeyAction
TabThe control, then an interactive suffix — a static suffix is skipped

Tokens

Universal field tokens apply.

Input group
Left addon (prefix)
$
Right addon (suffix)
USD
Button addon
Both sides
https://
Disabled
#
Prefix + suffix (exchange)
1 CAD =
NGN

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.

  • FormLabel links to the input via htmlFor
  • FormDescription shows helper text between the label and the control, and stays visible when an error is active
  • FormMessage shows the error below the control. It is not a live region and carries no role="alert" — it is read through aria-describedby when 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-describedby references 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:

ConditionEffect
error presentRenders below the control, sets aria-invalid, points aria-describedby at the error then the hint
hint onlyaria-describedby points at the hint
Both absentNo aria-describedby at all — an empty reference is worse than none
requiredMarker 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.

Form field — default

As it appears on your ID

Auto-generated, cannot be changed

Form field — error

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

DoDon't
Put the label above the fieldUse the placeholder as the label
Validate on blurValidate on every keystroke
Keep the user's input when validation failsClear the field so they retype it
Let OTP and password fields accept pasteBlock paste "for security"
Offer a button beside every dropzoneShip drag-and-drop as the only upload path
Use three fields for a date of birthMake someone tap back to 1987 in a calendar
Size the field to its contentMake a 3-digit CVV field full width
Set autocomplete on standard fieldsDisable 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 group has 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.