Input

The canonical text input. Under the official brand it's honest material: an opaque field on a flat surface — no glass, no etched frames (those live in the proposed system). A quiet border at rest; focus turns it gold with a soft gold glow, so you always know exactly where you're typing. Green means the value checks out; red means it needs another look. Disabled picks up a muted fill so it clearly isn't taking input right now. Native input underneath: every HTML type (text, email, password, number, search, …) works without reinventing the wheel.

Input is the atom. For the form unit — label + input + helper text + error text with auto-linked ARIA — compose with /text-field instead of placing <Input> next to loose <label> elements.

States

Five states, five looks — all flat, all friendly. Idle is a quiet border on an opaque field. Hover and focus turn the border gold and add a soft gold glow. Invalid goes red; valid goes green (a positive check passed — the value is good). Disabled takes a muted fill and dimmed text so it clearly isn't asking for input. (The hairline-and-lift treatment specced elsewhere on this page is the proposed system's take.) Severity colors always pair with an icon (ADR-0017); color alone is never the signal.

state: idle
Default. A quiet solid border on an opaque field — flat and ready. (The --shadow-xs outset lift belongs to the proposed system; official fields sit flush.)
state: focus
Gold border plus a soft 3px gold glow — the field lights up and says "go ahead." Shown statically here via the is-focused modifier; live focus on any other field gives you the real thing. (The amplified --shadow-sm lift is proposed-only.)
state: valid
Green border; aria-invalid='false' explicit. Pair it with a check icon. Use it once a positive check passes — passwords match, email format OK, username available.
state: invalid
Red border; aria-invalid set on the input. Pair it with an error icon in the trailing slot or an error message via TextField.
state: disabled
Muted fill + dimmed text. The field clearly steps back: it isn't asking for anything right now. (The dashed-frame inversion is the proposed system's version.)

Password match — live demo of valid + invalid

The canonical use of valid / invalid: a password confirmation. Both fields are valid when they match, invalid when they don't. Edit either to see the states switch live.

Password
Always neutral while typing; only the confirmation reflects match state.
Confirm
Match — green border + check icon.

Icon slots

Two slots, both optional. Each slot owns its padding so leading and trailing icons line up with the input text. Drop in any ReactNode — a Lucide stroke icon, a Halo SVG, or a short text glyph (currency, units).

leadingIcon
Affordance hint at the start of the field. Search glyph + 'Search docs' is the canonical search shape, mirrored by CommandPaletteTrigger in the AppBar.
trailingIcon
Action or validation hint at the end. Use sparingly — busy trailing slots compete with the value for attention.
leadingIcon + trailingIcon
A unit label on each end. The currency-prefix + clear-action shape is the canonical money-input layout.
(no icons)
The base shape. Most fields don't need an icon — only reach for one when the affordance isn't obvious from the label.

Type variants

Input passes through the native HTML type attribute. The visual register is the same; the platform handles keyboard, validation hints, autocomplete, and password masking for free.

type='email'
Mobile keyboards surface @ and .com; browsers can validate format. Pair with a leading mail glyph when no label is nearby.
type='password'
Native masking. Browsers offer to save / generate; the field stays canonical visually while the platform handles the security UX.
type='search'
Mobile keyboards show a 'Search' return key. Some browsers add a clear button — left in place since it pairs with the native UX.
type='number'
Numeric keypad on mobile; spinner buttons on desktop. Pair with a unit prefix or suffix in the icon slot when the unit matters.

Materiality

Materiality is where the two systems part ways most. Glass bodies, etched outlines, and floating lifts all belong to the proposed system. The official input is honest material: an opaque field on a flat surface, one solid border, and a soft gold glow on focus — no transparency, no shadows doing aesthetic work. The spec below documents the proposed treatment; read it as the counterpart, not the official recipe.

Height
--space-8 = 40px

One canonical height. Pairs with the canonical button height in mixed rows (form + submit) without a vertical mismatch.

Frame
--space-px solid --border-hairline

Proposed spec — 1px hairline at rest, transitioning to --gold-aa-500 on hover and focus, --patina-500 on valid, --rust-500 on invalid, dashed on disabled. The official field keeps one solid border and swaps color instead: gold on focus, green on valid, red on invalid.

Body
Opaque — official fields carry their own solid surface.

No backdrop-filter here either — but for the opposite reason. The proposed input inherits the glass behind it; the official input is simply a solid, opaque field on a flat page. What you type sits on real material.

Lift
--shadow-xs at rest, --shadow-sm on focus

The floating outset lift is a proposed-system move. Official inputs sit flat — motion lives in buttons and cards (hover lift, press scale), not in fields.

Corners
--radius-sm = 2px

The 2px machined-edge look is proposed. Official corners are a touch softer — friendly, not etched.

Focus ring
--focus-ring (idle) / --focus-ring-error (invalid) / --focus-ring-affirm (valid)

These ring tokens are the proposed set. The official focus treatment is a soft 3px gold glow; invalid swaps it to red, valid to green. Same job, warmer delivery.

Props

invalid
boolean

Renders the red error border and sets aria-invalid on the underlying input. Pair with an error icon (trailing slot) or an error message (via TextField) — color alone is never the signal (ADR-0017).

valid
boolean

The positive sibling of invalid. Renders the green success border and sets aria-invalid="false" (the explicit "validated, no problem" signal). Use after a positive check — password match, email format OK, username available. Mutually exclusive with invalid; if both are set, invalid wins (errors are always loudest).

leadingIcon
ReactNode

Content before the value. Lucide stroke icon, Halo SVG, or a short text glyph (currency, unit).

trailingIcon
ReactNode

Content after the value. Validation tick, clear button, unit suffix. Use sparingly.

…native attrs
InputHTMLAttributes

All standard input attributes pass through: type, value, onChange, placeholder, required, autoComplete, disabled, etc. size is omitted from the prop surface (no size variant; it's a native input attr unrelated to visual sizing).

ref
Ref<HTMLInputElement>

Forwarded to the underlying input, not the wrapper. Use for imperative focus, value reads, etc.

Usage

Basictsx
import { Input } from '@halo-compliance/ui'

<Input
  placeholder="Search docs"
  value={query}
  onChange={(e) => setQuery(e.target.value)}
/>
With icon slotstsx
<Input
  type="email"
  placeholder="you@lender.com"
  leadingIcon={<MailIcon />}
  trailingIcon={isValid ? <CheckIcon /> : null}
  value={email}
  onChange={(e) => setEmail(e.target.value)}
/>
Invalid + error message via TextFieldtsx
<TextField>
  <TextField.Label>Email</TextField.Label>
  <TextField.Input
    type="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    invalid={!!error}
  />
  {error && <TextField.ErrorText>{error}</TextField.ErrorText>}
</TextField>

When to reach for which

  • Input — when you need just the field. A controlled search box in chrome, a value-only cell in a table, a one-off prompt.
  • TextField — for any form field with a label or helper text. The compound pattern wires ARIA correctly so the label, helper, and error text all describe the right input.
  • CommandPaletteTrigger — when the affordance looks like a field but the action is "open a palette." The trigger borrows the field aesthetic deliberately, but it's a button (no value).

Anti-patterns

  • Don't drop a bare <input> into routes. The eslint rule no-bare-input-element catches it. Bare inputs drift on border, focus glow, and disabled styling.
  • Don't put a label inside the input as placeholder. Placeholder is for the example value, not the label. Use a real <label> (or TextField) so screen readers and the WCAG label-association rules are satisfied.
  • Don't carry severity by color alone. Invalid pairs with an icon in the trailing slot or a message via TextField (ADR-0017). Same rule that governs Card variants and Button variants.
  • Don't restyle the frame at the consumer. Inputs ship with one canonical look: quiet border, gold focus, green valid, red invalid. If a tier feels missing, that's a substrate conversation, not a one-off override.
  • Don't show valid before the user has done anything. Valid is the result of a positive check (match, format-OK, available) — not the default. Idle is the right starting state; switch to valid only when validation has actually passed.
  • /text-field — the form pattern that wraps Input with label + helper + error and auto-links ARIA.
  • /button — pairs with Input in submit rows; the form-input height matches the canonical button height.
  • /app-bar#middle — where the search-shaped affordance lives in chrome. CommandPaletteTrigger borrows Input's visual register.
  • /tokens--shadow-xs, --shadow-sm, --focus-ring, --focus-ring-error, --focus-ring-affirm land here.