Field
Single-line text input with label, helper and error rows, icons, clear button, in-field affixes, character counter, prefix/suffix slots and built-in validation wiring. Built on Base UI Field.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Field, FieldDropdown, FieldDropdownItem, FieldSkeleton } from "@tessinaui/ui";<Field label="Email" placeholder="you@example.com" supportingText="We never share it" />Usage rules
- Always pass
label. A field without a visible label needsvisuallyHiddenLabelso the name survives for assistive tech — placeholder text is never a label. supportingTextis for guidance;errorMessageis for failure. When both are set the error replaces the supporting text (no layout shift next to an open keyboard).- Don't set
intent="error"by hand to signal a validation failure — passerrorMessage(orvalidate) and let the field derive the error state,aria-invalidand the announcement. - Prefer
prefixText/suffixTextfor static in-field text (https://,USD) and theprefix/suffixslots for real controls (FieldDropdown,Button). - Sizes below
lgrender 16px input text on phones so iOS Safari never zooms the page on focus; don't override the input font below 16px on mobile.
Examples
Default
Intents
Five visual intents — none, error, warning, success, info — recoloring border, focus ring and supporting text. Intent is presentation; validation state comes from errorMessage/validate.
Validation
errorMessage for consumer-owned errors, validate (+ validationMode) for field-owned checks. Both render the same announced error row, join aria-describedby and set aria-invalid.
Sizes
Icons & Clearable
Decorative icons render as plain glyphs (no button semantics); pass …IconOnClick + …IconLabel to make one an actual named button. The clear button appears only while the field has a value, sits in the tab order as "Clear ‹label›", and Esc clears from the input.
In-field affixes
Static text inside the container: prefixText / suffixText, plus monospace for codes and IDs. Affix text is exposed to screen readers through the input's description.
Prefix & Suffix slots
<FieldDropdown> before the container and a <Button> after it, sharing the field's size and rounded.
On-color
On a coloured plate, tone resolves from the surrounding Surface automatically (explicit prop wins, like Button): the well, text, icons, clear button and FieldDropdown derive from the surface ink contract; intent text uses the on-ink palette.
On an error, info or success plate a field has one text tier: supporting text, the counter,
the "(optional)" marker, affixes, the placeholder and a resting inside label all render at full
ink, because a dimmed copy of that ink cannot reach 4.5:1 against the well (see
Surface › Muted inks on intent plates).
Put the hint in the label or supporting text there, not in the placeholder. A placeholder still
renders, at full ink, but it reads like a value.
States
optional, isLoading (spinner replaces the trailing icon in place, edits blocked, aria-busy), readOnly (value selectable, state paint suppressed), and FieldSkeleton.
Disabled
API Reference
Field
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Visible label. Names the input via Base UI's id pairing |
labelPlacement | "top" | "inside" | "start" | "top" | Logical label position: above, floating inside, or on the reading-start side (left in LTR, right in RTL) |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column so sibling fields share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
required | boolean | false | Native required + red * (aria-hidden — the star stays out of the accessible name) |
optional | boolean | false | Muted "(optional)" marker after the label. Mutually exclusive with required |
visuallyHiddenLabel | boolean | false | Keeps the label for assistive tech, removes it visually |
infoText | string | — | Small info text at the label row's end. Joins the input's description |
supportingText | ReactNode | — | Helper text below the field. Color and icon follow the intent; replaced by the error row while invalid |
errorMessage | ReactNode | — | Error text below the field. Marks the field invalid, joins aria-describedby, announced politely. Implies intent="error" unless another non-none intent is set |
validate | (value, formValues) => string | string[] | null | Promise | — | Base UI Field validation callback — return message(s) to fail |
validationMode | "onSubmit" | "onBlur" | "onChange" | "onSubmit" | When validate runs. Inside a Base UI Form, submit also focuses the first invalid field |
validationDebounceTime | number | 0 | Debounce for onChange validation, ms |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Visual intent: border, focus ring, supporting-text color |
tone | "default" | "on-color" | surrounding Surface's tone | on-color derives the well, text, icons and clear button from the surface ink contract for coloured/dark plates — same resolution as Button (explicit prop wins over the inherited Surface tone) |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Heights 32/36/40/48/56px; ≥44px touch floor on phones. Control text follows the Button family's height-matched ladder (14px through lg, 16px at xl; 16px floor on phones) — input, prefix and a matched suffix Button always share one font. Meta text (label, supporting/error, info, counter) runs one step below: 12px through md, 14px at lg/xl. Icons, spinner and the clear button step the family square scale, identical whether decorative or interactive |
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "full" | Radius cascade — container, clear button and FieldDropdown follow together |
leadingIcon / trailingIcon | ReactNode | — | Icon inside the container. Decorative by default (aria-hidden, not a button) |
leadingIconLabel / trailingIconLabel | string | — | Accessible name — required when the icon is interactive |
leadingIconOnClick / trailingIconOnClick | () => void | — | Makes the icon a real IconButton |
clearable | boolean | false | Clear (×) button while the field has a value — tab-reachable, Esc also clears |
onClear | () => void | — | Called after clearing. Clearing also fires onChange with the empty value |
prefix / suffix | ReactNode | — | Slots outside the container. FieldDropdown takes the Field's own size; a suffix Button takes fieldSuffixButtonProps(size) (the two families run different height ladders). rounded matches on all three |
prefixText / suffixText | string | — | Static text inside the container; exposed via the input's description |
monospace | boolean | false | Monospaced value + affixes (codes, tokens, IDs) |
isLoading | boolean | false | Pending state: in-place spinner, aria-busy, edits blocked |
readOnly | boolean | false | Native read-only; suppresses intent paint, error row and clear (state precedence: readOnly → disabled → error) |
showCharacterCounter | boolean | false | "n / max" counter (needs maxLength). Limit is described to AT; remaining count announced politely near/at the limit |
onValueChange | (value: string) => void | — | Value-first convenience callback — fires alongside onChange, including with "" on clear |
dir | "ltr" | "rtl" | — | Direction. All layout is logical — start placement, icons, affixes and float origin mirror |
wrapperClassName / containerClassName / className | string | — | Root / bordered container / <input> class hooks |
labelPosition | — | — | Deprecated alias of labelPlacement (outside-top→top, outside-left→start); removal next minor |
All other native <input> attributes (type, name, autoComplete, inputMode, enterKeyHint, …) are forwarded to the input. The root forwards wrapperClassName; the input carries className and the rest — the documented form-control exception shape.
FieldDropdown
| Prop | Type | Default | Description |
|---|---|---|---|
flagIcon | ReactNode | — | Icon/flag element, clipped to a square box that follows rounded |
text | string | — | Visible text (doubles as the accessible name) |
label | string | — | Accessible name — required for an interactive flag-only trigger |
showChevron | boolean | true | With children, false makes it display-only (renders a <span>, no button for any modality) |
children | ReactNode | — | <FieldDropdownItem> items — opens a Base UI dropdown menu |
size / rounded | same as Field | "md" / "full" | Must match the parent Field |
onClick | MouseEventHandler | — | Click handler when no children menu is used |
menuZoom | number | — | Visual scale for the body-portaled menu — pass the trigger's rendered scale inside transformed/scaled previews so popup and trigger keep one ratio |
tone | "default" | "on-color" | surrounding Surface's tone | On-color well/text/chevron/focus ring from the surface ink contract. The menu PANEL deliberately stays the standard popover surface in both tones — a popup floats above the plate as its own surface (same panel as every other menu in the library) |
disabled | boolean | false | Matches the Field's disabled surface; renders non-interactive |
FieldSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size / rounded / labelPlacement | same as Field | "md" / "full" / "top" | The placeholder is the Field's own box — touch floor included |
showLabel / showHelper | boolean | true / false | Label / helper bars |
showPrefix / showSuffix / showCounter | boolean | false | Prefix-, suffix- and counter-shaped bars — the input-row boxes come from the Field's own cva, so heights and floors track every size |
labelChars / helperChars / prefixChars / suffixChars | number | 14 / 24 / 12 / 9 | Real content lengths — bars are sized in characters |
label | string | — | Opt-in sr-only loading announcement. Silent by default — one announcement per loading region |
Controlled Usage
const [value, setValue] = useState("");
<Field
label="Search"
value={value}
onChange={(e) => setValue(e.target.value)}
clearable
onClear={() => setValue("")}
showCharacterCounter
maxLength={100}
/>Clearing (button or Esc) fires a real onChange with the empty value, so form libraries stay in sync — in uncontrolled mode the DOM input is cleared too.
Migrating from labelPosition
| Old | New |
|---|---|
labelPosition="outside-top" | labelPlacement="top" |
labelPosition="inside" | labelPlacement="inside" |
labelPosition="outside-left" | labelPlacement="start" |
The old prop still works for one minor and warns in development. Values are logical: start follows the reading direction instead of lying in RTL.
Do / Don't
- Do pair every field with a visible label; don't rely on placeholder text.
- Do use
errorMessage/validatefor failures; don't hand-setintent="error"as the only error signal. - Do match
sizeandroundedacross Field andFieldDropdown, and seat a suffixButtonwithfieldSuffixButtonProps(size); don't hand the Button the Field's ownsize— the button family's height ladder is 32/40/48/56/96 against the field family's 32/36/40/48/56, sosize="md"on both puts a 48px button beside a 40px field. Don't mix radii inside one composition. - Do use
readOnlyfor values the user may copy; don't usedisabledfor that — disabled leaves the focus order and drops contrast. - Do keep
maxLength+ counter for hard limits; don't surprise users with silent truncation.
Keyboard input hints
Forwarded native attributes drive the right mobile keyboard: type="email", inputMode="decimal", enterKeyHint="search", and autoComplete (WCAG 1.3.5 — set it on personal-data fields: email, name, tel, postal-code, …).
Accessibility
Pattern: native labeled text input (no APG composite widget — the input, its label and its descriptions are the contract). Plumbing is Base UI Field: generated ids pair label↔input, and description, error, info text, affixes and the counter limit all join the input's aria-describedby.
| Key | Action |
|---|---|
| Tab / Shift+Tab | Move through input, interactive icons, clear button, prefix/suffix controls |
| Esc | Clear the value (when clearable and non-empty; the event stops there) |
| Enter | Submits the surrounding form (native); commits validation in Base UI Field |
- The error row lives in a pre-mounted polite live region, so inline errors are announced without moving focus;
aria-invalidis set only while actually invalid — never on a pristine required field. requiredis the native attribute (announced by AT); the*is aria-hidden so the name stays clean.- The clear button is named "Clear ‹label›" and is keyboard-reachable; decorative icons carry no role at all.
isLoadingsetsaria-busyon the input; the spinner itself is decorative (one announcement per state, owned by the control).- Character counter: the limit is a static description ("Up to N characters"); remaining count is announced politely at ~90% and at the limit, never per keystroke.
- Touch: container and interactive icons keep a ≥44px floor on phones; input text is ≥16px on phones so iOS never auto-zooms.
- Forced colors / high contrast ride the token system; the focus ring is the hard-gated
--focus-ringpair. - Contrast (audit-gated, both themes): supporting text none 4.74/7.66 · error 4.77/5.20 · warning 7.14/13.68 (tinted foreground — raw amber fails) · success 4.95/11.13 · info 5.20/7.51 · floated label focus 17.93/15.72. The resting field outline is deliberately below the 3:1 boundary floor (§9 ledger 2026-08-24) — hover lifts it, focus hands over to the ring.
- AT matrix: VoiceOver + NVDA passes pending — status stays
betauntil both are on record (§5.6).