A field for text, numbers, or a single checkbox or toggle: one component, three native element modes, with correctly-typed native attributes per mode. It is uncontrolled-friendly, it holds no internal value state, value/onChange pass straight through to the native element. InputContainer is the headless sibling: the same label, error, and chrome frame, wrapped around a caller-supplied control instead of a native one.
The canon behind it. method/design-language/01-foundations.md §A2 (01-foundations.md:16, the interactive SemanticColor vocabulary that color resolves a checked accent through) and §A4 (01-foundations.md:24, scales are short and shared, with Input named alongside Button and Spinner as one of the components that share the xs..xl ladder).
When to reach for it#
Any text, number, password, or single boolean field on a form. Reach for element="textarea" for multi-line text and element="checkbox" (plus isToggle for the daisyUI toggle skin) for a single boolean, rather than a second component.
Reach for InputContainer instead of Input when the control itself is not a native element, a masked input, a custom Select trigger, anything that needs the same label/error/chrome frame but owns its own rendering. As of this pass, InputContainer has no confirmed shipped call site in either app; it exists for exactly that composition and is the correct escape hatch when it comes up, not a component to avoid because it looks unused.
Do not reach for it to build a multi-select or faceted filter control. Select is the kit's picker; a field that opens a list is a Select concern, not an Input one.
Contract#
Mode:
element:"input" | "textarea" | "checkbox", default"input".isTogglerenderselement="checkbox"as a daisyUI toggle instead of a checkbox.- The native props accepted narrow per mode: a
textareagetsrowsand notype, acheckboxnarrowstypeto"checkbox", a plain input gets every other native<input>type.
Layout inside the field frame:
before/after: content inside the field's own frame, ahead of or behind the control (an icon, most commonly).presets("email" | "password" | "search" | "url" | "numeric", one or many): the field shapes both apps keep spelling out (11type="email", 4type="password", 2 search fields, url fields, and the number-with-unit case; owner ruling 2026-08-16). Each is a partial-prop bundle (input.tsx,PresetProps):emailsets type,inputMode,autoCompleteand a placeholder;passwordsets type andautoComplete;searchsetstype="search", a leading Search glyph and the placeholder "Search" (no ellipsis);urlsets type,inputModeand "https://";numericsetstype="number", the decimal keypad, and right-aligned tabular figures. Explicit props always win over a preset's values.unit: a unit behind the value ("kg", "%", "pages") as a quiet monoafteradornment; pair it withpresets="numeric"for the number-with-unit field. An explicitafterwins.top/bottom: label-above and helper-text-below the field, string or JSX viarenderNode.required: renders the kit'sRequiredMarknext totop(a red asterisk whose tooltip says "Required", plus the hidden word for assistive tech; a string value replaces the tooltip text).RequiredMarkis exported for any label the kit does not draw itself; SchemaForm uses it. Also sets the nativerequiredattribute, so a required field actually blocks an empty submit, not just looks like it should.error:truefor a generic message, a string or node shown verbatim otherwise. Presence suppressesbottomentirely, the two are mutually exclusive by design, not by convention.changed: a distinct dirty-state highlight, separate fromerror.
Sizing and surface:
size/textSize: independent from each other,InputSize(xs..xl).textSizedefaults tosizebut can be set smaller to keep a large tap target with smaller text. Atmdthe control is 40px tall and reads 14px, the same as Select, Button and the segmented control, so a form row that mixes them sits on one scale (owner ruling D3a, 2026-08-18).surface:"outline" | "flat" | "shadow", default"outline". This is the current generalized replacement for an olderflat/noBorderboolean pair; if you seeflatornoBorderreferenced anywhere (including in old comments or a prior version of this doc), that is stale, the live prop issurface. As of this pass no shipped call site in either app setssurfaceexplicitly, every field uses the default outline.color: the checked accent forcheckbox/togglemodes only (defaultprimary). Text and textarea fields ignore it, they use the neutral field border and the brand focus ring from the--field-*tokens instead.
State and behavior:
skeleton: a field-shaped loading placeholder in the control's place.fullWidth(the frame and the native input or textarea inside it both fill the width),noAnimate.- A
font-monoclass on the control styles what the user types; the placeholder always stays in the UI font. hideIfDisabled: rendersnullentirely when disabled, a "hide, don't just gray out" escape hatch, distinct from the ordinary disabled visual state.- Slot classnames per
InputClassNames. Raw DOM passthroughs exist too:containerProps,labelProps, andlabelRef(input.types.ts:101-103, on theInputChromePropsbothInputandInputContainershare).
The accessible name, and the one case you still have to name yourself#
A string top still lifts into aria-label on the input, textarea, and checkbox/toggle control. A ReactNode top names the same controls through aria-labelledby, pointing at the rendered caption row. The browser computes the name from that caption: <span>Email <em>work</em></span> names the field "Email work". Null, undefined, and boolean captions do not create a reference.
top renders outside the <label> that wraps the control. useId() supplies the caption's id and keeps it stable between server rendering and hydration. A required mark in that row contributes its assistive text to the referenced name.
Your explicit aria-label or aria-labelledby wins. The component suppresses the generated caption reference when either is supplied, then spreads native props after its naming defaults. If you supply both, the browser gives aria-labelledby precedence.
A field with no top, carrying only a placeholder, still needs an explicit aria-label. A placeholder disappears on first keystroke and is not a reliable accessible name. A caption containing only decorative content also needs an explicit name.
Disabled reason and end action#
disabledReason: why the field cannot be edited right now. Any non-empty value disables the field, shows the reason in a tooltip on the frame, and links it througharia-describedby. Pass the prop on every render, empty when the field is editable (disabledReason={locked ? "Only admins can change the owner" : ""}): once the prop is present the tooltip wrapper stays mounted, so a field going disabled never remounts its control or loses its value. It uses the same sentence a disabled Button'sdisabledReasondoes, anduseDisabledReason'sreasoncan feed either.endAction: an action beside the field frame on the same row (a Test, Generate or Paste button). The error and helper line sit below both, so the message never lands between the field and its button. The action is vertically centred on a single-line field.- Inside a
FormModalor a<FormFreeze submitting>scope the field freezes itself with the reason "Running" while the submit is pending.
Retires#
This closes the ReactNode top caveat recorded in the 0.3.0 changelog. The console's fifteen hand-written aria-label call sites that existed only to work around that gap can be retired once the console adopts this change. Consumer edits are a separate task.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
top="..." required fullWidth | A labeled, required, full-width text field | speedway/app/routes/account/accounts.tsx:452-458 (Email) | The house shape for a profile/settings form field |
top="..." bottom="..." fullWidth (no required) | A labeled field with persistent helper text | speedway/app/routes/account/accounts.tsx:486-492 ("Only needed when changing your email.") | bottom and error are mutually exclusive; this is the steady-state (no error) helper-text case |
before={<SearchIcon .../>} | A search field with a leading icon inside the frame | speedway/app/routes/workspaces/review/review.tsx:486, speedway/app/components/FilterBar.tsx:49, 201 | The shared search-input shape across every filter toolbar in speedway |
element="checkbox" (no isToggle) | A plain checkbox | speedway/app/routes/workspaces/review/review.tsx area, walmart-mvp/frontend/src/pages/ImportParts.tsx:408 | Confirms/consent checkboxes use the plain checkbox skin, not the toggle |
element="textarea" | A multi-line field | speedway/app/routes/workspaces/review/review.tsx:1839, walmart-mvp/frontend/src/pages/ErrorManagement.tsx:405 | Free-text notes and edit fields |
after="<consent copy>" afterClassName="text-base-content/65 text-xs" size="xs" | Small print rendered inside the field frame after the control | walmart-mvp/frontend/src/pages/ErrorManagement.tsx:378-384 | Shows after used for prose, not just an icon |
Banned combinations#
flatornoBorderas prop names. These do not exist on the current component; the live equivalent issurface="flat"orsurface="outline". A caller trying to passflat/noBorderis either targeting an old version of this component or misremembering the old app'sInputV1. Neither compiles against the current types.errorandbottomtogether, expecting both to show. The component suppressesbottomwhenevererroris set (input.tsx:198-205); passing both is not a type error, but onlyerrorwill ever render. Treat them as one slot with two possible contents, not two independent ones.isTogglewithoutelement="checkbox".isToggleonly changes rendering whenelementresolves to"checkbox"(controlKind,input.tsx:10-13); on any other mode it is a silent no-op.- A
placeholderas a field's only label. A placeholder is not an accessible name: it vanishes on the first keystroke and screen readers treat it inconsistently, so the field reaches a screen reader or a password manager unnamed. A string or text-bearing ReactNodetopnames the field on its own; a field without a caption needs an explicitaria-label. The other likely wrong answer is reaching for aLabelcomponent to pair withhtmlFor, the shadcn idiom; this kit exports noLabel, andtopis that surface.
Laws it enforces#
- Machine values stay exact, human text stays human. Placeholders never end in an ellipsis, and a mono value never turns its placeholder into code.
- Show only what has something to say. A field with
fullWidthfills its frame instead of leaving a dead strip inside it. - Every action answers. A field that cannot be edited says why on hover and to assistive tech, and the disabled look comes from the one
field-disabledrule.
Before you adopt this#
Five questions to answer before reaching for Input.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, Input is a form field; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Not applicable, no local Input clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in?
presets(numeric-with-unit and the rest) landed by owner ruling 2026-08-16; confirm the pin. - Does the component derive its own accessible name and keyboard path, or must the call site supply them? A string
topsuppliesaria-label; a text-bearing ReactNodetopsupplies the name througharia-labelledby. A placeholder-only field still needs an explicitaria-label. - Which canon §A rules bind this surface, and which does the composition break? §A2 resolves the checked accent through the shared
SemanticColorvocabulary.
Travels with#
InputContainer, whenever the control is not a native element but the field needs the same label/error/chrome frame.Tooltip, for therequiredasterisk whenrequiredis given as a string rather than a plain boolean.renderNode, internally, for every string-or-JSX prop (top,bottom,before,after,error).
Snippet#
import { Input } from "@versable-git/ui";<Input type="email" name="email" top="Email" defaultValue={me.email} required fullWidth/>speedway/app/routes/account/accounts.tsx:452-458