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. 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". 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 the placeholder "Search" (no ellipsis); 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 (the frame and the native input or textarea inside it both fill the width), noAnimate.
  • A font-mono class on the control styles what the user types; the placeholder always stays in the UI font.
  • 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:101-103, on the InputChromeProps both Input and InputContainer share).

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 through aria-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's disabledReason does, and useDisabledReason's reason can 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 FormModal or 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#

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 or text-bearing ReactNode top names the field on its own; a field without a caption 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.

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 fullWidth fills 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-disabled rule.

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? A string top supplies aria-label; a text-bearing ReactNode top supplies the name through aria-labelledby. A placeholder-only field still needs an explicit aria-label.
  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#

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

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