Theme
On this page (19)
Component Skill — components/input/component-skill.md

Native basis

<input> element. Browser provides built-in validation, autofill, and accessibility.

Web Platform APIs

<input><textarea>field-sizing: content:user-invalid:focus-visible

Classes

.input.label.field-description.field-error

Sizes (data-size)

xsHeight: 1.75remsmHeight: 2remmdHeight: 2.25remlgHeight: 2.75remxlHeight: 3.25rem

Notes

• Every demo below is an editable CodeExample - the shown source IS what renders. Edit it, hit Reset, or drive the State tab (controls generated from input.schema.json).

• State controls mutate the [data-example-root] element - examples mark it where the schema applies

• Always pair inputs with <label> using matching for/id

• Textarea auto-grows via field-sizing: content - zero JavaScript

• Use readonly for non-editable values the user can still select/copy

• Use semantic type values (email, tel, url, search, password, number) for mobile keyboards and validation

Every example on this page is one executable source: the editor bytes and the sandboxed preview are the same string - editing the code changes the preview (no second render tree can drift from the sample).

§Default

Standard text input with label.

§With description

Use .field-description below the input for help text (wired with aria-describedby).

§Sizes

The five-step data-size ladder. The State tab's size select mutates the marked root input.

§Disabled

Non-interactive but legible: muted surface and text inside the full-strength border, not-allowed cursor - flip the disabled row in the State tab.

§Invalid

aria-invalid="true" with a .field-error message: the page's own verdict, so it shows at once. The browser's automatic check (:user-invalid) is deliberately quieter - it never judges a standalone text field, and inside a form it can wait for a submit (see Form composition and Custom validation below).

§Required

Just the required attribute - the label's red star renders automatically, and the State tab toggles it live.

§With icon

Wrap the field in .input-group: the group draws the frame, and an addon placed BEFORE the .input sits at the start. Icons get .input-group-icon (decorative, aria-hidden).

§Icon at the end

Same group, the addon placed AFTER the .input - DOM order is the placement, nothing else changes. Start and end addons combine freely.

§Clear button

A trailing .input-group-button is a real, focusable button inside the field. Here it clears the search and returns focus; it hides itself while the field is empty (hidden toggled on input).

§Password with show / hide

The eye button flips type between password and text; aria-pressed tells screen readers whether the password is showing, and the icon follows it.

§Text addons

Static text around the value - a protocol, a domain, a unit - with .input-group-text. It is part of the frame, not of the value the form submits.

§File

Native file picker - the selector button is styled automatically.

§Textarea

<textarea> with the .input class. Auto-grows with content via field-sizing: content - zero JS.

§Readonly

Non-editable value the user can still select and copy - so it gets a copy button: a trailing .input-group-button writes the value with navigator.clipboard and confirms with a check for 1.5 s. If the clipboard is blocked (e.g. a sandboxed frame), it selects the text instead so Ctrl/⌘+C works.

§With button

Pair an input with a button for inline actions (the df$ handler below logs input events into the sandbox console).

§Form composition

data-validate="submit" on the form: the browser still checks required and the email format, but a field only turns red once someone tries to submit - leaving a half-typed address is not an error. Two listeners stamp data-submitted on the attempt (invalid fires when the browser blocks it, submit when it passes); reset clears it. Without data-validate, a field inside a form turns red as soon as it is left with an invalid value.

§Custom validation

Your rule, your message, your moment. novalidate turns off the browser's bubbles; the page checks on submit - a username already taken, an invite code in a set shape, two passwords that must agree - and marks each failing field with aria-invalid, fills its .field-error and sets setCustomValidity (so :invalid and form.checkValidity() agree). Focus moves to the first problem. After that first attempt, each field re-checks as it is edited, so an error clears the moment it is fixed. Try: admin / abc / two different passwords.

§States

Machine contract: the table below is verified against input.schema.json by bun run verify - state names, types, enum values and defaults must match in both directions. The CodeExample State tabs render their controls from exactly these rows (hint editor hints included), and state values observed from the live DOM win over schema defaults.

StateTypeValuesDefaultDescription
valuestring—"Hello world"Current field value. Examples may set another value - the panel observes the DOM first.
disabledbooleantrue, falsefalseDisables interaction (native disabled property).
readonlybooleantrue, falsefalseRead-only value, selectable/copyable (readonly attribute; muted styling).
requiredbooleantrue, falsefalseMarks the field required (required attribute; the adjacent .label renders its star automatically).
invalidbooleantrue, falsefalseError presentation (aria-invalid attribute → destructive border/ring; pair with a .field-error).
typeenumtext, email, password, search, numbertextInput behavior / mobile keyboard (semantic type values).
sizeenumxs, sm, md, lg, xlmdHeight ladder via data-size; the unsized default IS the md step (2.25rem).

§CSS view file

/* -- Input component ------------------------------------------- */
@layer components {
  .input {
    /* border-box is load-bearing for the shared field ladder: the UA gives
       <input> content-box, so `height` would otherwise exclude the 2px frame
       and render 2px taller than the border-box fields (select, button-based
       triggers). With it, every declared height IS the rendered box.
       The unsized default IS the ladder's md step (2.25rem = 36px) - same
       height, padding and font as [data-size="md"] below, so an unlabeled
       field and an explicit md field are the same control. */
    box-sizing: border-box;
    height: 2.25rem;
    width: 100%;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    padding: 0 0.75rem;
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;
    box-shadow: var(--shadow-xs);
    transition: border-color 150ms, box-shadow 150ms;
    &:focus {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    &::placeholder { color: var(--muted-foreground); }
    /* -- Readonly state -------------------------------------- */
    /* :not(:disabled) is load-bearing: a disabled input ALSO matches
       :read-only, and this rule's specificity (class + pseudo + :not attr)
       outranks the plain :disabled below - without the exclusion, disabled
       controls would render with the readonly look (0.7/default). */
    &:read-only:not([type="file"], :disabled) {
      background: var(--muted);
      cursor: default;
      opacity: 0.7;
      &:focus {
        border-color: var(--input);
        box-shadow: var(--shadow-xs);
      }
    }
    /* Disabled = legible but inert: a muted surface and muted text inside
       the full-strength border - never an opacity fade that lets the box
       dissolve into the page. */
    &:disabled {
      background-color: var(--muted);
      color: var(--muted-foreground);
      box-shadow: none;
      cursor: not-allowed;
    }
    /* -- Invalid state --------------------------------------- */
    /* WHEN a field turns red: aria-invalid="true" always (the page decided).
       The browser's own verdict (:user-invalid, i.e. after the user left a
       changed field) only counts inside a <form> - a standalone text box has
       nothing to submit, so a half-typed value is never judged - and inside
       form[data-validate="submit"] only once the page has stamped
       data-submitted on the form (a submit attempt): check when the person
       is finished, not the instant they leave the box. */
    &:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) {
      border-color: var(--destructive);
      &:focus {
        border-color: var(--destructive);
        box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2);
      }
    }
    /* -- File input ------------------------------------------ */
    &[type="file"] {
      padding: 0;
      font-size: 0.875rem;
      &::file-selector-button {
        height: 100%;
        border: none;
        border-right: 1px solid var(--input);
        background: var(--muted);
        color: var(--foreground);
        font-size: 0.875rem;
        font-weight: 500;
        font-family: var(--font-sans);
        padding: 0 0.75rem;
        margin-right: 0.75rem;
        cursor: pointer;
        transition: background 150ms;
        &:hover { background: var(--accent); }
      }
    }
    /* -- Native in-field buttons ------------------------------
       The clear "×" of type="search" and the calendar/clock icon of the
       date/time types are clickable controls INSIDE the text field - without
       this they inherit the field's text cursor and look like more text to
       type into (the file picker's button above already points). */
    &::-webkit-search-cancel-button,
    &::-webkit-calendar-picker-indicator {
      cursor: pointer;
    }
    /* -- Sizes ----------------------------------------------- */
    &[data-size="xs"] { height: 1.75rem; padding: 0 0.5rem;   font-size: 0.75rem; }
    &[data-size="sm"] { height: 2rem;    padding: 0 0.625rem; font-size: 0.8125rem; }
    &[data-size="md"] { height: 2.25rem; padding: 0 0.75rem;  font-size: 0.875rem; }
    &[data-size="lg"] { height: 2.75rem; padding: 0 1rem;     font-size: 1rem; }
    &[data-size="xl"] { height: 3.25rem; padding: 0 1.25rem;  font-size: 1.125rem; }
  }
  /* -- Input group: icons, text and buttons inside the field ---
     The GROUP draws the field frame (border, radius, shadow, focus ring,
     invalid / disabled / readonly surfaces); the .input inside goes
     frameless and fills the rest. DOM order is the placement: an addon
     before the .input sits at the start, one after it at the end - so a
     trailing copy / show-password / clear button is just markup after the
     input, and any addon width works without hand-tuned padding. */
  .input-group {
    box-sizing: border-box;
    display: flex;
    align-items: center;
    width: 100%;
    height: 2.25rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    box-shadow: var(--shadow-xs);
    color: var(--muted-foreground);
    font-size: 0.875rem;
    transition: border-color 150ms, box-shadow 150ms;
    & > .input {
      flex: 1;
      min-width: 0;
      height: 100%;
      border: none;
      border-radius: inherit;
      background: transparent;
      box-shadow: none;
      font-size: inherit;
      &:focus { box-shadow: none; }
      &:not(:first-child) { padding-inline-start: 0.5rem; }
      &:not(:last-child) { padding-inline-end: 0.5rem; }
    }
    &:has(> .input:focus) {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    &:has(> .input:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *))) {
      border-color: var(--destructive);
      &:has(> .input:focus) {
        box-shadow: 0 0 0 2px oklch(from var(--destructive) l c h / 0.2);
      }
    }
    /* readonly: the group carries the muted surface (the input's own
       readonly background would stop short of the addons) */
    &:has(> .input:read-only:not(:disabled)) {
      background: var(--muted);
      & > .input { background: transparent; }
      &:has(> .input:focus) {
        border-color: var(--input);
        box-shadow: var(--shadow-xs);
      }
    }
    &:has(> .input:disabled) {
      background: var(--muted);
      box-shadow: none;
      cursor: not-allowed;
      & > .input { background: transparent; }
    }
    /* decorative icon (inline <svg> or lucide <i>) */
    & > .input-group-icon {
      flex-shrink: 0;
      width: 1rem;
      height: 1rem;
      color: var(--muted-foreground);
      pointer-events: none;
      &:first-child { margin-inline-start: 0.75rem; }
      &:last-child { margin-inline-end: 0.75rem; }
    }
    /* static text: a unit, a protocol, a domain */
    & > .input-group-text {
      flex-shrink: 0;
      white-space: nowrap;
      color: var(--muted-foreground);
      user-select: none;
      &:first-child { margin-inline-start: 0.75rem; }
      &:last-child { margin-inline-end: 0.75rem; }
    }
    /* an in-field action: copy, show/hide, clear, open a picker */
    & > .input-group-button {
      flex-shrink: 0;
      display: inline-grid;
      place-items: center;
      box-sizing: border-box;
      height: calc(100% - 0.5rem);
      aspect-ratio: 1;
      padding: 0;
      border: none;
      border-radius: var(--radius-sm);
      background: transparent;
      color: var(--muted-foreground);
      cursor: pointer;
      transition: background-color 150ms, color 150ms;
      & svg { width: 1rem; height: 1rem; }
      /* icon swaps (copy -> check, eye -> eye-off): the UA's [hidden] rule
         only covers HTML elements, so a hidden <svg> needs its own */
      & svg[hidden] { display: none; }
      &:hover { background: var(--accent); color: var(--accent-foreground); }
      &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
      &:disabled { cursor: not-allowed; background: transparent; color: var(--muted-foreground); }
      &:first-child { margin-inline-start: 0.25rem; }
      &:last-child { margin-inline-end: 0.25rem; }
      & + .input-group-button { margin-inline-start: 0.125rem; }
    }
    /* -- Sizes: the same ladder as .input, set on the group ------ */
    &[data-size="xs"] { height: 1.75rem; font-size: 0.75rem; }
    &[data-size="sm"] { height: 2rem;    font-size: 0.8125rem; }
    &[data-size="md"] { height: 2.25rem; font-size: 0.875rem; }
    &[data-size="lg"] { height: 2.75rem; font-size: 1rem; }
    &[data-size="xl"] { height: 3.25rem; font-size: 1.125rem; }
  }
  /* -- Auto-growing textarea --------------------------------- */
  textarea.input {
    field-sizing: content;
    min-height: 5rem;
    padding-top: 0.5rem;
    padding-bottom: 0.5rem;
  }
  /* -- Field description ------------------------------------- */
  .field-description {
    font-size: 0.8125rem;
    color: var(--muted-foreground);
    margin: 0.375rem 0 0;
  }
  /* -- Field error ------------------------------------------- */
  .field-error {
    font-size: 0.8125rem;
    color: var(--destructive);
    margin: 0.375rem 0 0;
  }
  /* -- Accessibility ---------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .input {
      transition: none;
    }
    .input[type="file"]::file-selector-button {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .input {
      border-width: 2px;
    }
    .input:disabled {
      border-color: var(--muted-foreground);
    }
  }
  @media (forced-colors: active) {
    .input {
      border-color: ButtonBorder;
      color: ButtonText;
      background: Field;
    }
    .input:focus {
      outline: 2px solid Highlight;
      outline-offset: 1px;
    }
    .input:disabled {
      opacity: 1;
      color: GrayText;
      border-color: GrayText;
    }
    .input:is([aria-invalid="true"], :user-invalid:is(form *):not(form[data-validate="submit"]:not([data-submitted]) *)) {
      border-color: LinkText;
    }
    .field-error {
      color: LinkText;
    }
    .input-group {
      border-color: ButtonBorder;
      background: Field;
      & > .input:focus { outline: none; }
      &:has(> .input:focus) { outline: 2px solid Highlight; outline-offset: 1px; }
      &:has(> .input:disabled) { border-color: GrayText; }
      & > .input-group-button { color: ButtonText; }
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub