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.
pnpm add @tessinaui/uiWhen to use
- Short, free-form, single-line text: a name, an email, a URL, a search term, a coupon code. If the answer is not a fixed set of options, a field is the right control.
- A visible label on every field. Pass
label; when the design hides it, usevisuallyHiddenLabelso the name survives. Placeholder text is a hint, never the label. - Guidance before and failure after:
supportingTextexplains the format up front,errorMessage(orvalidate) replaces it when the value is wrong. Keep both to one short sentence. - Format and unit cues that belong to the value:
prefixTextandsuffixTextforhttps://orUSD, and theprefixandsuffixslots when the cue is itself a control such as a country code or a Search button. - Hard limits stated up front: set
maxLengthwithshowCharacterCounterso nobody discovers the limit by being truncated. - A mobile keyboard that matches the data: forward
type,inputMode,enterKeyHintandautoCompleteso people type less.
When not to use
- For answers that span several lines. Use Textarea.
- For a number people step or adjust. Use NumberField, which owns increment, decrement and number formatting.
- For choosing from a known list. Use Select for a short list, or Combobox when people need to filter a long one.
- For a search box with results or suggestions. Use Search, or SearchSuggest when suggestions appear as you type.
- For a yes/no or one-of-few choice. Use Checkbox, Radio or Switch.
- For a group of related fields. Wrap them in a Fieldset so the group has one name and one error; do not repeat a label across fields.
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
Variants
Three shells: the default outline box, a filled well, and an underline bottom rule for dense forms. Filled is a tinted well with no outline at rest or on hover: the focus ring is its outline, and an error or other intent draws the border back. It matches the filled Textarea, and it reads best on a filled Form, where the panel sets the well apart. Prefer outline where a field sits straight on the page — the outline is what marks it as a place to type. Underline is square whatever rounded says, trades the focus ring for an inset 2px bottom band in the intent colour, and never gains a fill — disabled and read-only dim the rule instead. A floated inside label sits above the rule with no notch to cut. prefix / suffix slots keep their own boxes; pass variant to a FieldDropdown to match.
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. The character counter stays pinned to the input's trailing edge, never under the suffix button.
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 swaps a decorative trailing icon in place; an interactive trailing icon stays mounted and disabled, with the spinner on its input side; the clear button stays available; edits blocked, aria-busy), readOnly (value selectable, state paint suppressed), and FieldSkeleton.
Disabled
RTL
dir="rtl" aligns the label, helper and error rows right, moves the leading icon and prefixText to the right edge, and moves the clear button, trailing icon and suffixText to the left. An inside label floats from the right. Leave dir unset and the field follows the page's direction.
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, and sit at the column's end, 12px from the field. Only applies with labelPlacement="start": the label stays beside the field while the field keeps at least 212px of its own row, and moves above it below that, whatever the screen width |
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 |
variant | "outline" | "filled" | "underline" | "outline" | Shell: bordered box, tinted well or bottom rule. Filled draws no border until focus (the ring) or an intent, keeps its own fill when disabled, and cuts no notch for a floated inside label until an intent draws the border. Underline ignores rounded, focuses with an inset 2px bottom band instead of the ring, paints no fill in any state, and cuts no notch for a floated inside label |
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 is seated at the field's height automatically — any size you pass it is replaced, since the two families run different height ladders (fieldSuffixButtonProps(size) does the same for a Button placed beside a field outside the slot). 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: aria-busy, edits blocked. The spinner swaps a decorative trailing icon in place; an interactive trailing icon stays mounted (disabled) with the spinner on its input side. Clear (button and Esc) stays available |
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 |
strings | Partial<FieldStrings> | English, Arabic under dir="rtl" | Override the clear button's name — clear, or clearField ("Clear {label}") when the field has a string label — the optional marker, and the character counter's description and announcements (upToCharacters, charactersRemaining, limitReached). A count takes plural variants, key_<category> (charactersRemaining_few), picked by Intl.PluralRules; overriding a bare key drops the Arabic table's variants of it |
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 |
variant | "outline" | "underline" | "outline" | Pass the parent Field's. Underline draws only the bottom rule and focuses with the band; the flag box still follows rounded |
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 |
variant | "outline" | "filled" | "underline" | "outline" | Squares the control bar for an underline Field; prefix and suffix bars stay boxed, like the real slots |
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 leavesizeoff a suffixButton— Field seats it at its own height. The button family's ladder is 32/40/48/56/96 against the field family's 32/36/40/48/56, so a Button placed beside a field outside the slot takesfieldSuffixButtonProps(size), never the Field's ownsize. 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 outline is soft on purpose (1.48:1 light, 1.91:1 dark): the label marks the field. Hover draws it in the text colour (19.80/18.97), focus hands over to the ring, and every intent draws its own border.
- AT matrix: VoiceOver + Safari passed 2026-09-14 — an invalid field reads "Username, Spaces are not allowed, invalid data, edit text"; a helper reads "Email, We only use it for receipts, edit text". NVDA and JAWS are untested (§5.6).