Component · Core
Input
Overview
Anatomy
Container (border, --radius-sm, background) → native <input> showing either placeholder text
or an entered value.
- Container — the border, radius, and background.
- Native
<input>— where the placeholder or the entered value renders.
No leading/trailing icon slots, prefixes, or suffixes in the baseline.
Live Example
Sizes
Helper text
Lowercase letters, numbers, and hyphens only.
Error
Enter a valid email address.
Read-only
Disabled
Code Example
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const SIZES = ["sm", "default", "lg"] as const;
/**
* Live Input example — the three sizes and the documented states, each wired
* as the shared form-field model puts it: Label bound with `htmlFor` / `id`,
* `--scale-2` (gap-2) to the control, `--scale-1-5` (gap-1.5) to a supporting
* message. Helper text is `--muted-foreground`; an error message is
* `--foreground` (Input's canonical error contract) and the field carries
* `aria-invalid="true"` + `aria-describedby`.
*/
export function InputShowcase() {
return (
<div className="flex max-w-sm flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Sizes
</h3>
<div className="flex flex-col gap-6">
{SIZES.map((size) => (
<div key={size} className="flex flex-col gap-2">
<Label htmlFor={`input-size-${size}`}>
{size[0].toUpperCase() + size.slice(1)}
</Label>
<Input
id={`input-size-${size}`}
size={size}
type="email"
placeholder="you@example.com"
/>
</div>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Helper text
</h3>
<div className="flex flex-col gap-2">
<Label htmlFor="input-helper">Workspace name</Label>
<Input
id="input-helper"
aria-describedby="input-helper-text"
placeholder="acme"
defaultValue="acme"
/>
<p
id="input-helper-text"
className="text-xs leading-snug text-muted-foreground"
>
Lowercase letters, numbers, and hyphens only.
</p>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Error
</h3>
<div className="flex flex-col gap-2">
<Label htmlFor="input-error">Email</Label>
<Input
id="input-error"
type="email"
aria-invalid="true"
aria-describedby="input-error-text"
defaultValue="ada@"
/>
<p
id="input-error-text"
className="text-xs leading-snug text-foreground"
>
Enter a valid email address.
</p>
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Read-only
</h3>
<div className="flex flex-col gap-2">
<Label htmlFor="input-readonly">Account ID</Label>
<Input id="input-readonly" readOnly defaultValue="acc_8f2a19c4" />
</div>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Disabled
</h3>
<div className="flex flex-col gap-2">
<Label htmlFor="input-disabled" className="opacity-50">
Referral code
</Label>
<Input id="input-disabled" disabled defaultValue="Not available" />
</div>
</section>
</div>
);
}Variants
Types
Baseline input types — text-entry types where the native keyboard, validation, and autofill behavior genuinely differ:
| Type | Use for |
|---|---|
text | General single-line text; the default |
email | Email addresses — native validation + email keyboard on mobile |
password | Secrets — masked by the browser |
search | Search queries — some browsers add a native clear affordance |
number | Genuine quantities only, where numeric stepping makes sense (e.g. "how many seats") |
tel | Phone numbers — numeric keyboard, no stepping, no format validation |
url | URLs — native validation + URL keyboard on mobile |
type="number" is easy to reach for and usually wrong. Phone numbers, postal codes, IDs,
account numbers, and card numbers are identifiers or formatted text, not quantities — they don't
want spinner steppers or numeric-only validation. Use text (or tel for phone numbers) instead.
Other native HTML input types (date, color, range, file, etc.) are out of scope — each has its own native UI different enough to warrant a dedicated future component rather than a baseline type.
Sizes
Three sizes, on the same height steps as Button, so an Input and a Button line up in a row.
| Size | Height | Padding-x |
|---|---|---|
| sm | --scale-8 (32px) | --scale-3 (12px) |
| default | --scale-10 (40px) | --scale-3 (12px) |
| lg | --scale-12 (48px) | --scale-3 (12px) |
Corner radius is --radius-sm at every size — Radius already names
"Tags, inputs" for this token. Text is the "Body small" recipe (text-sm leading-normal) — value
and placeholder text are read content, not a UI label, so they stay at regular weight rather than
Button's medium-weight "Label / button" recipe.
States
| State | Look |
|---|---|
| Default | --background fill, --input border, --foreground text, --muted-foreground placeholder |
| Hover | No change — see above |
| Focus | --ring ring, visible on :focus-visible — geometry above |
| Disabled | Reduced opacity — details above — disabled attribute, no pointer events |
| Read-only | --muted fill, --input border unchanged, readonly attribute, default (not text) cursor |
| Error | --destructive border, --foreground text — the error message doesn't need to be red when the border, message, and invalid semantics already signal it; if also focused, the ring stays --ring, so "focused" and "invalid" both stay visible as two independent signals |
"Filled" is a content condition, not a visual state. An entered value looks identical to whichever state the field is actually in (Default, Focus, Disabled, Read-only, Error) — no token, border, or background changes when a value is present; the placeholder is simply replaced by the value's own text. Content is layered on top of these states, never a state of its own — which is why there's no "Filled" row above.
Usage Guidance
The shared form-field model
Label, Input, and Textarea all follow this structure. It lives here because Input has the fullest anatomy; Label and Textarea link back to this section instead of repeating it.
Label (+ "(optional)" if not required)
↓ --scale-2 (8px)
Control (Input or Textarea)
↓ --scale-1-5 (6px)
Supporting message(s) — helper text and/or error message
Stacked fields in a form use --scale-6 (24px) — Spacing already
names this "gap between form fields."
Required and optional. Fields are required by default; a form should state this once, up
front ("Fields are required unless marked optional"). Optional fields append (optional) after
the label text, in --muted-foreground. There is no red asterisk — see
Rationale below. A native required control uses the HTML
required attribute; don't also add aria-required="true" — it's redundant once required is
present. Requirement and validity are never communicated by color alone.
Placeholder, helper text, error message — three different things:
| What it is | When it shows | |
|---|---|---|
| Placeholder | A short format example inside the control (you@example.com) | Only while the control is empty; never a substitute for the label |
| Helper text | Standing guidance below the control | By default |
| Error message | What's wrong, after validation | Replaces helper text by default |
Essential persistent guidance — a required format the user needs even after an error appears
(e.g. "Must be 8+ characters") — may stay visible alongside the error instead of being replaced.
Helper and error text each get a unique id; every message currently visible is referenced
through the control's aria-describedby (space-separated if both are shown). aria-invalid="true"
is added only when the field is actually invalid — never present by default.
Helper and error text typography
Helper and error text share one recipe — Typography's "Caption"
role: text-xs leading-snug (12px / 400 / 0 / 1.375). They differ only in color, matching the
States table: --muted-foreground for helper, --foreground for error. Gap from the
control is --scale-1-5 (6px), per the diagram above.
Both wrap normally across multiple lines — no truncation, no fixed height, since a supporting message that gets cut off defeats its own purpose. Only one message renders at a time by default (error replaces helper, per the table above), so wrapping never has to reconcile two messages in the same slot; the "essential guidance stays visible" exception above is the one case where both can be on screen together, stacked.
Focus, disabled, read-only, hover — shared by Input and Textarea:
| State | Behavior |
|---|---|
| Focus | --ring on :focus-visible only — same --ring token and trigger as Button, with the field's own flush geometry (below) |
| Disabled | Reduced opacity (opacity-50); native disabled attribute, removed from the tab order; details below |
| Read-only | --muted background, full opacity, native readonly attribute — stays focusable and selectable, visually distinct from disabled |
| Hover | None. The cursor already signals text entry; focus is the state that matters. No decorative border shift. |
Focus ring geometry
The ring is a Color foundation "emphasis border" —
--scale-0-5 (2px), the same recipe already named for a "thick focus outline" — drawn in --ring,
flush against the control's outer edge (no gap between border and ring), following the control's
own corner radius (--radius-sm), so the ring traces the full rounded shape rather than boxing a
rounded corner in a square outline. Border color doesn't change on plain focus — only the ring is
added. This holds even when the field is invalid: the ring stays --ring, never --destructive —
only the border carries --destructive (see States). --destructive in dark mode
doesn't reach the contrast a meaningful focus indicator needs, so the ring keeps using the token
that's already proven accessible instead.
--ring sits at the opposite end of the neutral ramp from --background in both themes (900 vs.
50 light; 300 vs. 950 dark), so it stays discoverable against the field's surrounding surface
regardless of theme. The ring uses :focus-visible, the same selector as
Button, to meet WCAG 2.4.7 (Focus Visible). Browsers decide when that
selector matches through their own heuristics: keyboard focus matches, and a text-entry control
may also match after a pointer click because it accepts keyboard input. Don't suppress that native
behavior.
This flush treatment is one of the system's two focus-ring geometries — Button
and Card ship the offset variant (a 2px gap between the control edge and the ring)
instead. The two differ only in that gap; the token (--ring), the :focus-visible trigger, the
own-corner-radius tracing, and the stays---ring-when-invalid rule are shared. Neither geometry
is more discoverable than the other. See Color — Border width.
Disabled treatment
One opacity value applies to the field as a unit — label, control (border, background, and value/placeholder text), and any helper or error text all dim together. Not the control alone, and not select pieces of it: dimming everything by the same amount keeps the relative contrast between each piece and its background roughly intact, so the field still reads as one coherent (inactive) thing instead of one part staying sharp while another fades to noise.
That one value is disabled:opacity-50 (0.5) — what Input, and every other Monogem
primitive, ships today. It isn't a token: a project re-theming the system can tune it, but it has
to stay dim enough to read as inactive without the label, value, or helper text stopping being
recognizable.
Opacity is never the only signal. Disabled already pairs with the native disabled attribute —
removed from the tab order and unavailable for interaction — so there's a semantic cue for
assistive tech as well as the visual treatment. Implementations may add a not-allowed cursor to
the field wrapper as another cue for pointer users; it isn't the browser default, and it cannot be
observed on the control itself if that control has pointer-events: none. WCAG's contrast
requirements (1.4.3, 1.4.11) explicitly exempt disabled controls, so a dimmed disabled field
doesn't have to hit AA the way an active one does — but "exempt from the contrast formula" isn't
"illegible": disabled content must stay recognizable as the same label, value, and helper text.
Rationale: required and optional
A red asterisk is the conventional required-field marker, but Color's
own principle is "red means danger, only" — a required field isn't dangerous. Marking optional
fields instead (in neutral --muted-foreground) keeps red reserved for actual errors and
destructive actions, and needs fewer indicators overall since most fields are required.
Tokens
Input uses existing Monogem semantic and primitive tokens to stay consistent with the design system; it introduces no component-specific styling values.
| Token | Where used |
|---|---|
--background | Default fill |
--muted | Read-only fill |
--foreground | Entered value, error message text |
--muted-foreground | Placeholder, helper text, "(optional)" label suffix |
--destructive | Error border |
--input | Default border |
--ring | Focus ring |
--radius-sm | Corner radius, all sizes |
--scale-8 / --scale-10 / --scale-12 | Heights: sm / default / lg |
--scale-3 | Padding-x, all sizes |
--scale-2 | Label-to-control gap |
--scale-1-5 | Control-to-message gap |
--scale-6 | Gap between stacked form fields |
Do / Don’t
Do
- Pair every Input with a Label via native
htmlFor/id. - Pick the type from the Types table by what the value actually is, not what it looks
like — a card number is
text, notnumber. - Use a placeholder for a format example only; never as the only description of the field.
- Show helper text by default; let an error message replace it unless the guidance is essential enough to keep visible.
Don’t
- Use
type="number"for anything that isn't a genuine quantity. - Rely on the red border alone to communicate an error — always pair it with error text.
- Add
aria-required="true"alongside a nativerequiredattribute. - Style a hover state "to be safe" — the baseline intentionally has none.
Accessibility
- Every Input needs an associated
<label>— see Label. aria-describedbyreferences every currently visible helper/error message id; drop the reference when a message stops being shown.aria-invalid="true"is added only while the field is actually invalid, and removed once it's corrected.- Native
requiredcarries required-field semantics to assistive tech on its own. - Native
disabledremoves the control from the tab order automatically; nativereadonlydoes not — a read-only field is still reachable and its value still selectable/copyable. - Autocomplete: set the native
autocompleteattribute (e.g.email,tel) where it applies — it's free assistive-tech and autofill support with no design decision attached. - Contrast:
--muted-foregroundagainst--backgroundmeasures 7.527:1 (light) and 7.858:1 (dark) — well past the 4.5:1 WCAG 1.4.3 text minimum, so placeholder and helper text both pass.--ringagainst--backgroundmeasures 17.169:1 (light) and 13.295:1 (dark) — well past the 3:1 WCAG 1.4.11 non-text minimum, so the focus ring passes too. No new token is needed.