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. docs/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 "Search…";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,noAnimate.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:89-91, on theInputChromePropsbothInputandInputContainershare).
The accessible name, and the one case you still have to name yourself#
A string top is the field's accessible name. The component derives aria-label from it (input.tsx:304) and sets it on all three control modes (input.tsx:309, :319, :337).
The structure is why this needs saying. top renders through TopRow (input.tsx:202-220) as a sibling <div> ABOVE the <label> (input.tsx:358), and that <label> wraps only before, the control, and trailing. So the visible caption is not part of the label's accessible-name computation and never has been. Lifting it into aria-label is what closes that, rather than an htmlFor/id pairing: this file takes no hooks so it stays server-renderable, which leaves it no id to generate.
Two boundaries follow from that, and the second one is a real gap:
- A ReactNode
tophas no text to lift, so it names nothing. Passaria-labelyourself. - A field with no
topat all, carrying only aplaceholder, has no accessible name. A placeholder is not a name; it disappears on first keystroke and screen readers treat it inconsistently. Passaria-label.
The aria-label sits before the prop spread, so your own aria-label or aria-labelledby still wins.
Fixed 2026-08-20, reported from the forge console where a login field rendered with no name in the DOM. Measured on the rendered /components/input page after the fix: of 42 fields, 29 now take their name from top, and the 7 that remain unnamed all have no visible caption at all, the placeholder-only size demos. Those 7 are the second boundary above, not a miss.
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 stringtopnames the field on its own; a field without one 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. - A ReactNode
topon an accessibility-critical field, with noaria-label. Only a stringtopbecomes the accessible name, because JSX has no text for the component to lift. Passaria-labelalongside it.
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?
topis NOT the field's accessible name; pair it witharia-label(see Known defect). - 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#
<Input type="email" name="email" top="Email" defaultValue={me.email} required fullWidth/>speedway/app/routes/account/accounts.tsx:452-458