Skip to content
Monogem

Component · Core

Input

Overview

The primary control for single-line text entry — a name, an email address, a search query. Input is documented alongside Label and Textarea because all three share one field structure; this page is the canonical home for that shared model (see below).

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

input-showcase.tsx
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:

TypeUse for
textGeneral single-line text; the default
emailEmail addresses — native validation + email keyboard on mobile
passwordSecrets — masked by the browser
searchSearch queries — some browsers add a native clear affordance
numberGenuine quantities only, where numeric stepping makes sense (e.g. "how many seats")
telPhone numbers — numeric keyboard, no stepping, no format validation
urlURLs — 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.

SizeHeightPadding-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

StateLook
Default--background fill, --input border, --foreground text, --muted-foreground placeholder
HoverNo change — see above
Focus--ring ring, visible on :focus-visible — geometry above
DisabledReduced 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 isWhen it shows
PlaceholderA short format example inside the control (you@example.com)Only while the control is empty; never a substitute for the label
Helper textStanding guidance below the controlBy default
Error messageWhat's wrong, after validationReplaces 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:

StateBehavior
Focus--ring on :focus-visible only — same --ring token and trigger as Button, with the field's own flush geometry (below)
DisabledReduced 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
HoverNone. 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.

TokenWhere used
--backgroundDefault fill
--mutedRead-only fill
--foregroundEntered value, error message text
--muted-foregroundPlaceholder, helper text, "(optional)" label suffix
--destructiveError border
--inputDefault border
--ringFocus ring
--radius-smCorner radius, all sizes
--scale-8 / --scale-10 / --scale-12Heights: sm / default / lg
--scale-3Padding-x, all sizes
--scale-2Label-to-control gap
--scale-1-5Control-to-message gap
--scale-6Gap 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, not number.
  • 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 native required attribute.
  • Style a hover state "to be safe" — the baseline intentionally has none.

Accessibility

  • Every Input needs an associated <label> — see Label.
  • aria-describedby references 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 required carries required-field semantics to assistive tech on its own.
  • Native disabled removes the control from the tab order automatically; native readonly does not — a read-only field is still reachable and its value still selectable/copyable.
  • Autocomplete: set the native autocomplete attribute (e.g. email, tel) where it applies — it's free assistive-tech and autofill support with no design decision attached.
  • Contrast: --muted-foreground against --background measures 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. --ring against --background measures 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.

Related Components / Patterns

  • Textarea — multi-line text entry.
  • Label — the visible name of the field.
  • Form — assembling fields into a complete form.