A reusable input field component following Material Design 3 guidelines — with full state behavior, token mapping, animation specification, and accessibility requirements.
Try the live fields above — click any field to see the label animate from inside the input to above it. The label stays floated as long as a value is present, even after losing focus.
surface-variant color with top-left and top-right radius of 4dp. Height is always 56dp. Bottom edge has a 1dp indicator line.on-surface-variant → primary on focus.on-surface. Left padding 16dp, right padding 16dp. Sits in lower portion of the container when label is floating.outline color), 2dp when focused (primary color) or in error state (error color).on-surface-variant or error. Used for hints, character count, or validation messages.| Token | Role | Hex |
|---|---|---|
| md.sys.color.primary | Label (focused), active indicator (focused) | #1A6EBD |
| md.sys.color.on-surface-variant | Label (resting), supporting text, placeholder | #42474E |
| md.sys.color.on-surface | Input text (typed value) | #1A1C20 |
| md.sys.color.surface-variant | Container fill (default, focused) | #DDE3EA |
| md.sys.color.outline | Active indicator (default state, 1dp) | #72787E |
| md.sys.color.error | Label (error), active indicator (error), error text | #BA1A1A |
| md.sys.color.on-surface @38% | All elements (disabled state, opacity overlay) | 38% opacity |
| Token | Applied to | Value |
|---|---|---|
| md.sys.typescale.body-large | Input text, label (resting state) | 16sp / 400 / Roboto |
| md.sys.typescale.body-small | Label (floating state), supporting text | 12sp / 500 / Roboto |
| md.sys.typescale.body-medium | Placeholder text | 14sp / 400 / Roboto |
| Token | Applied to | Value |
|---|---|---|
| Container height | Text field container | 56dp |
| Horizontal padding | Label, input text left/right | 16dp |
| Label top (floating) | Floated label top offset | 6dp from top |
| Support text gap | Container bottom → support text | 4dp |
| Shape (top corners) | Container top-left, top-right radius | 4dp |
| Active indicator (default) | Bottom border, inactive | 1dp |
| Active indicator (focused) | Bottom border, focused/error | 2dp |
| State | Label position | Label color | Label size | Container fill | Indicator | Support text |
|---|---|---|---|---|---|---|
| Default | Inside field, vertically centered | on-surface-variant | 16sp / 400 | surface-variant | 1dp / outline | on-surface-variant |
| Focused | Above field · top: 6dp | primary | 12sp / 500 | surface-variant + state | 2dp / primary | on-surface-variant |
| Filled | Above field · top: 6dp | on-surface-variant | 12sp / 500 | surface-variant | 1dp / outline | on-surface-variant |
| Error | Above field · top: 6dp | error | 12sp / 500 | surface-variant | 2dp / error | error |
| Disabled | Inside field, vertically centered | on-surface @38% | 16sp / 400 | on-surface @4% | 1dp @38% | on-surface @38% |
The label animates above the container boundary when focused or filled. The typed value is fully visible with no overlap. The label uses the reserved space above the input created by the wrapper's top padding.
Never leave the label at its resting position when a value has been entered. The label sits on top of the typed text, making it unreadable and creating an inaccessible, visually broken state.
<input type="text"
placeholder=" "
id="name-field" />
input:not(:placeholder-shown) + label {
top: 6dp;
font-size: 12sp;
}
A single space placeholder enables the :not(:placeholder-shown) CSS selector — the label stays floated when the field has a value, even after losing focus. No JavaScript needed.
/* ✗ Never do this */ <span class="label">Name</span> <input type="text" /> /* ✓ Always use this */ <label for="name-field">Name</label> <input type="text" id="name-field" />
A <span> is not announced by screen readers as a form label. Always use a semantic <label> with a matching for/id pair. This is a WCAG 2.1 Level A requirement.
/* ── Wrapper — reserves space above input ─────────────── */ .field-wrap { position: relative; padding-top: 20px; /* reserved zone for floated label */ } /* ── Container ────────────────────────────────────────── */ .field-wrap input { width: 100%; height: 56px; /* MD3 fixed container height */ background: var(--md-surface-variant); border: none; border-bottom: 1px solid var(--md-outline); border-radius: 4px 4px 0 0; /* top corners only per MD3 */ padding: 8px 16px; font: var(--md-typescale-body-large); color: var(--md-on-surface); outline: none; transition: border-color 200ms cubic-bezier(.2, 0, 0, 1); } .field-wrap input:focus { border-bottom: 2px solid var(--md-primary); } /* ── Label ────────────────────────────────────────────── */ .field-wrap label { position: absolute; left: 16px; top: 50%; /* vertically centered in field */ transform: translateY(-50%); font-size: 16px; /* body-large */ color: var(--md-on-surface-variant); pointer-events: none; transition: top 200ms cubic-bezier(.2, 0, 0, 1), font-size 200ms cubic-bezier(.2, 0, 0, 1), color 200ms cubic-bezier(.2, 0, 0, 1), transform 200ms cubic-bezier(.2, 0, 0, 1); } /* ── Floating state — :focus OR has a value ───────────── */ .field-wrap input:focus + label, .field-wrap input:not(:placeholder-shown) + label { top: 0; /* floats into wrapper top zone */ transform: none; font-size: 12px; /* body-small */ font-weight: 500; color: var(--md-on-surface-variant); left: 2px; /* slight left adjust when small */ } .field-wrap input:focus + label { color: var(--md-primary); /* primary color only when focused */ } /* ── Error state ──────────────────────────────────────── */ .field-wrap.error input { border-bottom: 2px solid var(--md-error); } .field-wrap.error label { color: var(--md-error); } /* ── Disabled state ───────────────────────────────────── */ .field-wrap input:disabled { background: rgba(28,27,31,.04); border-bottom-color: rgba(28,27,31,.38); color: rgba(28,27,31,.38); } .field-wrap input:disabled + label { color: rgba(28,27,31,.38); } /* ── Accessibility: reduced motion ────────────────────── */ @media (prefers-reduced-motion: reduce) { .field-wrap label { transition: none; } .field-wrap input { transition: none; } }
Floated label color primary (#1A6EBD) on surface-variant background yields 4.8:1. Resting label yields 4.6:1. Both pass AA for normal text.
Always use a native <label for="..."> element paired with the input's id. Screen readers announce the label when the input receives focus, regardless of its visual position.
Wrap all transitions in @media (prefers-reduced-motion: reduce) and set transition: none. Required for WCAG 2.3.3 (Animation from Interactions).
The active indicator (2dp border-bottom) plus label color change together serve as the focus indicator. Ensure the indicator color primary has 3:1 contrast against adjacent surfaces per WCAG 1.4.11.
Do not use visible placeholder text when a floating label is present — this doubles the label and fails 1.3.1 (Info and Relationships). Use placeholder=" " (single space) only as a CSS selector hook.
Add aria-describedby on the input pointing to the supporting text element. Add aria-invalid="true" in error state so screen readers surface the validation message automatically.