Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

We only use this for billing receipts.

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.

At least 12 characters.

Arrow keys step by 1.

$USD
https://.dev
0/280

Auto-grows to twelve lines, then scrolls.

Validation timing

Both fields have the same rule. Only the left one waits until the user has finished before judging them.

Validate on blur, clear on input
Validate on every keystroke

Shouting at someone one character into typing.

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.

ContactWhere we send billing and incident mail.
Organisation
https://

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.

Default
Filled
FocusBorder + 3px halo
Erroraria-invalid
Success
Warning
Disabled
Read-onlyDashed, copyable
Loading
Clearable
https://
With prefix
Search

Anatomy

Every part, every measurement, and the reason it is that number.

15/64

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.

  1. 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.

  2. 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.

  3. Control height36px (md)

    Identical to a button and a select, so a row of mixed controls shares a baseline with no per-component nudging.

  4. Horizontal padding12px

    Gives the caret room at the left edge. Below 10px the first character looks like it is touching the border.

  5. 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.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
padding-xsm / md / lg
label gapLabel to control
field gapBetween stacked fields

Radius

TokenValueUsed for
--radius-mdsm and md corners
--radius-lglg corners

Typography

TokenValueUsed for
--text-label—Field label
--text-body—Typed value
--text-caption—Help text, counter, validation

Motion

TokenValueUsed for
durationBorder and halo transition

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusIconTypeMin widthMax widthTouch targetWhen to use
Small32px0 10px8px14px13px120px—44px (padded)Toolbars, table filters, inline editing.
Medium36px0 12px8px15px15px160px40ch44px (padded)The default for every form.
Large44px0 14px12px17px17px200px40ch44px (native)Mobile forms, authentication, checkout.
Textarea88px min10px 12px8px—15px—68ch—Roughly four lines by default. Auto-grows to twelve, then scrolls.

Do

Size the field to the expected answerA three-character CVC field 400px wide tells the user they have got the format wrong. Field width is a legitimate and free affordance.
type="email" inputMode="email"
inputMode="decimal" autoComplete="cc-number"
Use the right type and inputmodeOn mobile this changes the keyboard. type="email" gets an @ key; inputMode="decimal" gets a numeric pad. It is a one-attribute usability win that almost nobody bothers with.

At least 12 characters, including a number.

Keep help text visible at all timesInstructions are needed most at the moment of failure. Replacing the help text with the error removes the guidance exactly when the user needs it.
Mark whichever set is smallerIf most fields are required, mark the optional ones. Marking every field with an asterisk is the same as marking none — it stops being information.

Don't

Do not use a placeholder as a labelIt disappears when the user types, it usually fails contrast, screen-reader support for it is inconsistent, and anyone who gets interrupted mid-form loses the question entirely.
Do not validate on every keystrokeAn email address is invalid for every character except the last. Telling the user they are wrong while they are still typing trains them to ignore your error messages.

Rejects spaces with no feedback

Do not block characters silentlyA field that refuses keystrokes with no explanation looks broken. Accept the input, then explain why it is not valid — and strip formatting yourself rather than demanding the user does.
Do not stretch every field to the containerA row of identical full-width fields gives no clue what any of them expects. It also produces a 900px-wide postcode field on a desktop layout.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA1.3.5Identify Input PurposeAA2.4.6Headings and LabelsAA3.3.1Error IdentificationA3.3.2Labels or InstructionsA3.3.3Error SuggestionAA4.1.2Name, Role, ValueA

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

TabMoves between fields in DOM order.
EnterSubmits the form when the field is inside one — expected behaviour on single-field forms.
↑ / ↓Steps a number input by its step value.
EscClears a search input that has a clear affordance.
⌘/Ctrl + ASelects 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.
AttributeApplied toNotes
<label for>Every fieldA real label element. Clicking it focuses the input, which also enlarges the effective target considerably.
aria-describedbyinputPoints at the help text and the error message. Multiple ids are allowed and are announced in order.
aria-invalidinputSet on error, removed when it clears. Screen readers announce "invalid entry" on focus.
role="alert"The error messageAnnounces the message without moving focus. Do not use aria-live="assertive" on the field itself.
autocompleteinputRequired by WCAG 1.3.5 for personal data. It is also the single biggest completion-rate win in any form.
inputmodeinputChooses the mobile keyboard. Independent of type, which controls validation and autofill.

Code

Example usage

tsx
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-password

Framework-free HTML

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

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

PropTypeDefaultDescription
labelstring—Rendered as a real <label for>.
htmlFor*string—Must match the control id, or the label does nothing.
descriptionstring—Static help. Always visible, never replaced by the error.
messagestring—Validation message. Stacks below the description.
status'default' | 'error' | 'success' | 'warning''default'Drives the message colour, the icon and the control border.
requiredboolean—Renders an asterisk plus a visually hidden "(required)".
optionalboolean—Renders "Optional" instead. Use when most fields are required.
counter{ value: number; max: number }—Right-aligned character count. Turns red past the limit.

TextInput

PropTypeDefaultDescription
size'sm' | 'md' | 'lg''md'Matches the Button and Select scale.
statusFieldStatus'default'Also sets aria-invalid on error.
prefix / suffixReactNode—Static text inside the border — "https://", "USD".
startIcon / endIconReactNode—Muted, auto-sized adornments.
loadingboolean—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

PropTypeDefaultDescription
autoResizebooleanfalseGrows with content up to maxRows, then scrolls.
maxRowsnumber12Ceiling for auto-resize.
rowsnumber4Initial height when auto-resize is off.

Notes

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.