Text Field
The control that asks a human to type something. Every decision here — label position, validation timing, helper text — is about reducing the chance they get it wrong.
Also called Text Input, Input, Textbox, Email Field, URL Field — in this system all of them are Text Field.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Every type
Nine variations on the same control. The differences that matter are the keyboard on mobile, the autofill behaviour, and the adornments — not the visual style.
Validation timing
Both fields have the same rule. Only the left one waits until the user has finished before judging them.
Grouping and layout
A fieldset with a real legend, fields paired only where the values are genuinely related, and a 20px gap between rows against a 32px gap between groups.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
We only use this for billing receipts.
Label, optional counter, control with an adornment, and static help text. The message slot sits below the description and never replaces it.
- Label13px / 540, 6px above
Above the field for the shortest eye path and the most robust behaviour under translation. Muted weight so it never competes with the value the user typed.
- Required markerAsterisk + sr-only text
The asterisk is aria-hidden and paired with a visually hidden "(required)". A red star alone is meaningless to a screen reader and to anyone who has not learned the convention.
- Control height36px (md)
Identical to a button and a select, so a row of mixed controls shares a baseline with no per-component nudging.
- Horizontal padding12px
Gives the caret room at the left edge. Below 10px the first character looks like it is touching the border.
- Focus treatmentBorder + 3px halo
The halo is what makes focus visible at a glance; the border change is what makes it precise. A 1px border change alone is not enough for low-vision users.
- Help text12px, muted, always present
Static guidance that never disappears. Validation messages stack below it rather than replacing it — losing the instructions at the moment of failure is exactly backwards.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-field | — | Field background — a step LIGHTER than the surface it sits on |
| --ds-field-hover | — | Hover lift, so the field answers the pointer |
| --ds-border-interactive | — | Resting border |
| --ds-border-strong | — | Hover border |
| --ds-accent | — | Focused border |
| --ds-accent-subtle | — | Focus halo |
| --ds-danger-border | — | Error border |
| --ds-fg-muted | — | Placeholder, adornments, help text |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-x | sm / md / lg | |
| label gap | Label to control | |
| field gap | Between stacked fields |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | sm and md corners | |
| --radius-lg | lg corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | — | Field label |
| --text-body | — | Typed value |
| --text-caption | — | Help text, counter, validation |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Border and halo transition |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Icon | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|---|
| Small | 32px | 0 10px | 8px | 14px | 13px | 120px | — | 44px (padded) | Toolbars, table filters, inline editing. |
| Medium | 36px | 0 12px | 8px | 15px | 15px | 160px | 40ch | 44px (padded) | The default for every form. |
| Large | 44px | 0 14px | 12px | 17px | 17px | 200px | 40ch | 44px (native) | Mobile forms, authentication, checkout. |
| Textarea | 88px min | 10px 12px | 8px | — | 15px | — | 68ch | — | Roughly four lines by default. Auto-grows to twelve, then scrolls. |
type="email" inputMode="email"
inputMode="decimal" autoComplete="cc-number"At least 12 characters, including a number.
This password is too short.
Please enter a valid email
Rejects spaces with no feedback
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The typed value must reach 4.5:1. Placeholder text also counts as text — ours is --ds-fg-muted at 4.6:1, which is the floor, not a target.
- The field border is the boundary of a control and must reach 3:1 against the surrounding surface, per WCAG 1.4.11.
- Error state is never colour alone: the border changes, an icon appears, and the message is text.
Keyboard
| Tab | Moves between fields in DOM order. |
| Enter | Submits the form when the field is inside one — expected behaviour on single-field forms. |
| ↑ / ↓ | Steps a number input by its step value. |
| Esc | Clears a search input that has a clear affordance. |
| ⌘/Ctrl + A | Selects the field contents, not the page. |
Screen readers
- Never rely on placeholder text for the accessible name. Support is inconsistent and it disappears on input.
- Announce the character counter with aria-live only when the user is close to the limit — announcing every keystroke floods the queue.
- On submit failure, move focus to the first invalid field and announce a summary of how many errors there are.
Focus & touch
- A 2px accent border plus a 3px halo. The halo is deliberately larger than the standard focus ring because the field already has a border — a 2px ring at 2px offset would read as a double border rather than as focus.
- 44px targets on coarse pointers. Set autocapitalize and autocorrect appropriately — an email field that capitalises the first letter is a real source of failed logins.
| Attribute | Applied to | Notes |
|---|---|---|
| <label for> | Every field | A real label element. Clicking it focuses the input, which also enlarges the effective target considerably. |
| aria-describedby | input | Points at the help text and the error message. Multiple ids are allowed and are announced in order. |
| aria-invalid | input | Set on error, removed when it clears. Screen readers announce "invalid entry" on focus. |
| role="alert" | The error message | Announces the message without moving focus. Do not use aria-live="assertive" on the field itself. |
| autocomplete | input | Required by WCAG 1.3.5 for personal data. It is also the single biggest completion-rate win in any form. |
| inputmode | input | Chooses the mobile keyboard. Independent of type, which controls validation and autofill. |
Example usage
1import { Field, TextInput, PasswordInput, Textarea } from '@/ui/Input'23// The standard shape: label above, help text below, error stacked under it4<Field5 label="Work email"6 htmlFor="email"7 required8 description="We only use this for billing receipts."9 status={error ? 'error' : 'default'}10 message={error}11>12 <TextInput13 id="email"14 type="email"15 inputMode="email"16 autoComplete="email"17 aria-describedby="email-help"18 status={error ? 'error' : 'default'}19 value={value}20 onChange={(e) => {21 setValue(e.target.value)22 if (error) setError(undefined) // clear as they fix it23 }}24 onBlur={() => setError(validate(value))} // judge only when done25 />26</Field>2728// Character counter29<Field label="Bio" counter={{ value: bio.length, max: 280 }}>30 <Textarea autoResize maxRows={12} value={bio} onChange={onBio} />31</Field>3233// Autofill tokens that matter most34// name · email · tel · organization · street-address · postal-code35// cc-number · cc-exp · cc-csc · new-password · current-passwordFramework-free HTML
<div class="ds-field">
<label class="ds-field__label" for="email">
Work email
<span aria-hidden="true">*</span>
<span class="sr-only">(required)</span>
</label>
<input
class="ds-input"
id="email"
name="email"
type="email"
inputmode="email"
autocomplete="email"
required
aria-describedby="email-help email-error"
aria-invalid="true"
/>
<p class="ds-field__help" id="email-help">
We only use this for billing receipts.
</p>
<p class="ds-field__error" id="email-error" role="alert">
Enter an email that includes an @ — for example, ada@example.com
</p>
</div>CSS
.ds-input {
inline-size: 100%;
block-size: 36px;
padding-inline: 12px;
/* Material 3 fills a text field with the lightest container in the ramp,
never a darker one: a control is a surface standing on the page, not a
hole cut into it. --ds-surface-inset is for wells nobody clicks. */
background: var(--ds-field);
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
color: var(--ds-fg);
transition:
border-color 120ms var(--ease-standard),
box-shadow 120ms var(--ease-standard);
}
.ds-input::placeholder { color: var(--ds-fg-muted); }
.ds-input:hover { border-color: var(--ds-border-strong); }
/* Border change for precision, halo for visibility */
.ds-input:focus {
outline: none;
border-color: var(--ds-accent);
box-shadow: 0 0 0 3px var(--ds-accent-subtle);
}
.ds-input[aria-invalid='true'] { border-color: var(--ds-danger-border); }
.ds-input[aria-invalid='true']:focus {
border-color: var(--ds-danger);
box-shadow: 0 0 0 3px var(--ds-danger-subtle);
}
.ds-input:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--ds-layer-hover);
}
/* Autofill: browsers force their own background. Paint over it. */
.ds-input:-webkit-autofill {
-webkit-text-fill-color: var(--ds-fg);
box-shadow: 0 0 0 1000px var(--ds-field) inset;
}
/* iOS zooms in on focus when the font is under 16px */
@media (pointer: coarse) {
.ds-input { font-size: max(16px, 1em); }
}Component API
Field
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Rendered as a real <label for>. |
| htmlFor* | string | — | Must match the control id, or the label does nothing. |
| description | string | — | Static help. Always visible, never replaced by the error. |
| message | string | — | Validation message. Stacks below the description. |
| status | 'default' | 'error' | 'success' | 'warning' | 'default' | Drives the message colour, the icon and the control border. |
| required | boolean | — | Renders an asterisk plus a visually hidden "(required)". |
| optional | boolean | — | Renders "Optional" instead. Use when most fields are required. |
| counter | { value: number; max: number } | — | Right-aligned character count. Turns red past the limit. |
TextInput
| Prop | Type | Default | Description |
|---|---|---|---|
| size | 'sm' | 'md' | 'lg' | 'md' | Matches the Button and Select scale. |
| status | FieldStatus | 'default' | Also sets aria-invalid on error. |
| prefix / suffix | ReactNode | — | Static text inside the border — "https://", "USD". |
| startIcon / endIcon | ReactNode | — | Muted, auto-sized adornments. |
| loading | boolean | — | Replaces the end adornment with a spinner in place, so nothing reflows. |
| onClear | () => void | — | Adds a clear button once the field has a value. |
Textarea
| Prop | Type | Default | Description |
|---|---|---|---|
| autoResize | boolean | false | Grows with content up to maxRows, then scrolls. |
| maxRows | number | 12 | Ceiling for auto-resize. |
| rows | number | 4 | Initial height when auto-resize is off. |
Professional tips
- Set font-size to at least 16px on touch devices. iOS Safari zooms the viewport on focus for anything smaller, and the user has to pinch back out.
- Strip formatting yourself. Accept "4242 4242 4242 4242" and "+44 1632 960" — refusing spaces is the interface being lazy at the user’s expense.
- For a single-field form, Enter should submit. For a multi-field form, Enter should submit only from the last field or a designated one.
- A read-only field that holds an ID or a key should be selectable and have a copy button. Disabled is wrong: the user needs the value.
Performance
- Debounce async validation by 300–500ms. Validating on every keystroke means one request per character and a race between responses.
- Controlled inputs re-render the whole form on every keystroke. For large forms, keep state local to the field or use an uncontrolled form library.
- Auto-resizing a textarea reads scrollHeight, which forces a synchronous layout. Do it on input rather than on every render.
- Never run a regex with catastrophic backtracking on every keystroke — email validation regexes are a classic source of this.
Common mistakes
- Omitting autocomplete. It fails WCAG 1.3.5 and it measurably reduces form completion.
- Using type="number" for phone numbers, card numbers and postcodes. It strips leading zeros, allows exponent notation, and shows spinners nobody wants.
- Putting the error message above the field. Screen readers announce it before the label, and sighted users read it before they know which field it belongs to.
- Disabling paste on password or confirmation fields. It breaks password managers and makes people choose weaker passwords.
- Trimming whitespace on blur without telling the user, so the value they see is not the value they typed.
Real-world recommendations
- Every field you remove increases completion more than any field you improve. Audit the form before styling it.
- Ask for one thing per field, but do not split things users think of as one — a single "Full name" field beats first/middle/last for most of the world.
- On submit failure, move focus to the first invalid field and announce the total count. Users should never have to hunt for what went wrong.
- Log which fields cause the most correction events in production. It is the fastest way to find the label that is unclear.