Agent docs

Input

One field for text, numbers, or a single checkbox or toggle.

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". isToggle renders element="checkbox" as a daisyUI toggle instead of a checkbox.
  • The native props accepted narrow per mode: a textarea gets rows and no type, a checkbox narrows type to "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 (11 type="email", 4 type="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): email sets type, inputMode, autoComplete and a placeholder; password sets type and autoComplete; search sets type="search", a leading Search glyph and "Search…"; url sets type, inputMode and "https://"; numeric sets type="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 mono after adornment; pair it with presets="numeric" for the number-with-unit field. An explicit after wins.
  • top / bottom: label-above and helper-text-below the field, string or JSX via renderNode.
  • required: renders the kit's RequiredMark next to top (a red asterisk whose tooltip says "Required", plus the hidden word for assistive tech; a string value replaces the tooltip text). RequiredMark is exported for any label the kit does not draw itself; SchemaForm uses it. Also sets the native required attribute, so a required field actually blocks an empty submit, not just looks like it should.
  • error: true for a generic message, a string or node shown verbatim otherwise. Presence suppresses bottom entirely, the two are mutually exclusive by design, not by convention.
  • changed: a distinct dirty-state highlight, separate from error.

Sizing and surface:

  • size / textSize: independent from each other, InputSize (xs..xl). textSize defaults to size but can be set smaller to keep a large tap target with smaller text. At md the 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 older flat/noBorder boolean pair; if you see flat or noBorder referenced anywhere (including in old comments or a prior version of this doc), that is stale, the live prop is surface. As of this pass no shipped call site in either app sets surface explicitly, every field uses the default outline.
  • color: the checked accent for checkbox/toggle modes only (default primary). 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: renders null entirely 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, and labelRef (input.types.ts:89-91, on the InputChromeProps both Input and InputContainer share).

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 top has no text to lift, so it names nothing. Pass aria-label yourself.
  • A field with no top at all, carrying only a placeholder, has no accessible name. A placeholder is not a name; it disappears on first keystroke and screen readers treat it inconsistently. Pass aria-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#

CombinationProducesWhere usedWhy
top="..." required fullWidthA labeled, required, full-width text fieldspeedway/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 textspeedway/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 framespeedway/app/routes/workspaces/review/review.tsx:486, speedway/app/components/FilterBar.tsx:49, 201The shared search-input shape across every filter toolbar in speedway
element="checkbox" (no isToggle)A plain checkboxspeedway/app/routes/workspaces/review/review.tsx area, walmart-mvp/frontend/src/pages/ImportParts.tsx:408Confirms/consent checkboxes use the plain checkbox skin, not the toggle
element="textarea"A multi-line fieldspeedway/app/routes/workspaces/review/review.tsx:1839, walmart-mvp/frontend/src/pages/ErrorManagement.tsx:405Free-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 controlwalmart-mvp/frontend/src/pages/ErrorManagement.tsx:378-384Shows after used for prose, not just an icon

Banned combinations#

  • flat or noBorder as prop names. These do not exist on the current component; the live equivalent is surface="flat" or surface="outline". A caller trying to pass flat/noBorder is either targeting an old version of this component or misremembering the old app's InputV1. Neither compiles against the current types.
  • error and bottom together, expecting both to show. The component suppresses bottom whenever error is set (input.tsx:198-205); passing both is not a type error, but only error will ever render. Treat them as one slot with two possible contents, not two independent ones.
  • isToggle without element="checkbox". isToggle only changes rendering when element resolves to "checkbox" (controlKind, input.tsx:10-13); on any other mode it is a silent no-op.
  • A placeholder as 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 top names the field on its own; a field without one needs an explicit aria-label. The other likely wrong answer is reaching for a Label component to pair with htmlFor, the shadcn idiom; this kit exports no Label, and top is that surface.
  • A ReactNode top on an accessibility-critical field, with no aria-label. Only a string top becomes the accessible name, because JSX has no text for the component to lift. Pass aria-label alongside it.

Before you adopt this#

Five questions to answer before reaching for Input.

  1. 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.
  2. Does this app already ship a local implementation of the same thing? Not applicable, no local Input clone is documented in either app.
  3. 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.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? top is NOT the field's accessible name; pair it with aria-label (see Known defect).
  5. Which canon §A rules bind this surface, and which does the composition break? §A2 resolves the checked accent through the shared SemanticColor vocabulary.

Travels with#

  • InputContainer, whenever the control is not a native element but the field needs the same label/error/chrome frame.
  • Tooltip, for the required asterisk when required is 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

@versable-git/ui · reference, canon, and method, read in place