Input

User Interface (UI)

How an input looks, from its parts and sizes to its icons, prefixes, actions, states, width and tokens.

Let's start with how an input looks: its parts, its sizes, what goes before and after the value, how it shows each of its states and how wide it should be.

Anatomy

  1. Container — The root <div>, which draws the border and carries the size.
  2. Leading — Optional. An icon or a prefix, like a currency, hidden from assistive technologies.
  3. Control — The <input> itself, which holds the value and takes focus. Its label names it.
  4. Trailing — Optional. An icon or a suffix, like a unit, also hidden from assistive technologies.
  5. Actions — Optional buttons that act on the value, like Show password, which you reach with Tab.
  6. Focus ring — An outline around the whole field, drawn outside it so nothing moves.

Every input has a container and a control. The label, any hint and any error message sit outside it, in the page, and point to the control by its id (see Label every input). The parts always render in the same order, which you can see in the DOM contract.

Sizes

The size prop scales the height, text, icons, padding and corner radius together, and md is the default. The heights are the same as the Button's, so an input and a button of the same size line up in a row.

<form role="search">
  <Input size="xs" type="search" name="q" aria-label="Search" leading={<SearchIcon />} />
  <Button size="xs" type="submit">Search</Button>
</form>
{/* …sm, md, lg, xl */}
SizeMin. heightTextIconPaddingGapRadius
xs24px12px14px8px4px6px
sm32px14px16px10px8px6px
md36px14px16px12px8px8px
lg40px16px20px14px8px8px
xl48px16px20px16px8px12px

Sizes are set in rem, and the table shows the values we recommend at the default text size of 16px. Every size meets the 24 × 24px minimum from WCAG 2.5.8. On touch screens, the input gets two things on top of that. Its text grows to at least 16px, since Safari on iOS zooms in on an input with smaller text as soon as it gets focus, and its hit area grows to at least 44px tall, without the field looking any bigger. The heights are minimums, so the field grows with larger text.

Icons, prefixes and actions

An input can hold more than its value: an icon or a prefix before it, an icon or a suffix after it, and buttons that act on it. You can use any icon library you like (the examples use Lucide's, like SearchIcon) and place them in the leading, trailing and actions slots.

<Input type="search" aria-label="Search" leading={<SearchIcon />} />

<label htmlFor="price">Price, in US dollars</label>
<Input id="price" inputMode="decimal" leading="$" trailing="USD" />
  • A leading icon says what the input is for at a glance, like a magnifying glass for a search. A prefix or a suffix gives the value its unit, like a currency, a unit of measure or a domain.
  • leading and trailing are decorative, so the slots wrap them in aria-hidden="true", just like the Button's icons. Screen readers don't read a prefix or a suffix, though, so make sure the label says the same thing, as in "Price, in US dollars".
  • actions holds Buttons, which keep their names and stay in the tab order, right after the input. We recommend ghost, icon-only Buttons two sizes below the input, like xs in an md input. You'll find one under Passwords.
  • A button that goes away along with what it acts on, like Clear once the input is empty, moves focus back to the input, so that it never falls back to the top of the page.
  • Pressing an icon, a prefix or the padding focuses the input, just like pressing the input itself.

States

Each state has a look of its own, styled from the attribute on the <input> that exposes it. That way, what people see and what assistive technologies report can never drift apart, and an input can't look invalid without actually being invalid.

At rest (try hovering, clicking or tabbing to it)
Disabled
Read-only

Enter an email address in the correct format, like name@example.com

Invalid
<label htmlFor="name">Full name</label>
<Input id="name" defaultValue="Ada Lovelace" />

<label htmlFor="username">Username</label>
<Input id="username" defaultValue="ada" disabled />

<label htmlFor="account">Account ID</label>
<Input id="account" defaultValue="ACME-4821" readOnly />

<label htmlFor="email">Email address</label>
<Input id="email" type="email" defaultValue="ada@" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error">Enter an email address in the correct format, like name@example.com</p>
StateSelectorRecommended look
Hover:hover, inside @media (hover: hover)The border at 80% of the text colour, up from 55%
Focus:has(> input:focus-visible)A 2px outline, 2px from the edge, in --color--focus
Disabled:has(> input:disabled)50% opacity and a not-allowed cursor
Read-only:has(> input[readonly])A dashed border, with the value at full contrast
Invalid:has(> input[aria-invalid="true"])A 2px border in --color--error-text

The field is drawn by the root, so it reads each state from the <input> inside it, with :has(). We recommend leaving :invalid and :user-invalid out of it. The browser decides them from its own checks, like required or type="email", so :invalid matches an empty required input before anyone has typed in it, and neither of them knows what your error message says. The invalid look comes from aria-invalid instead, which you set when the error message shows, so the red border and what screen readers announce always agree.

None of the states change the input's size or position. The invalid border's second pixel is drawn inside the field, and the focus ring outside it. You'll find how each state behaves under Every state.

Width and layout

The Input fills the width it's given, and its width is up to the layout, which is why it doesn't have a prop for it. We recommend sizing an input to the value it expects, since its width is a hint in itself. A short input for a postcode tells people a short answer is expected, while full width suits a street address. You can set the width from a class, in ch, which grows with the text.

Address.tsx
import styles from "./Address.module.css";

export function Address() {
  return (
    <>
      <label htmlFor="street">Street address</label>
      <Input id="street" autoComplete="street-address" />

      <label htmlFor="postcode">Postcode</label>
      <Input id="postcode" className={styles.postcode} autoComplete="postal-code" />

      <label htmlFor="phone">Phone number</label>
      <Input id="phone" className={styles.phone} type="tel" autoComplete="tel" />
    </>
  );
}
Address.module.css
/* Room for the value, in characters, plus the padding on both sides. */
.postcode {
  width: calc(10ch + 2 * var(--input--padding));
}
.phone {
  width: calc(20ch + 2 * var(--input--padding));
}
  • Put the label above its input, with any hint between them. People read both before they type, and they stay in view when the page is zoomed in or the screen is narrow, where a label beside its input gets pushed off to the side.
  • Keep forms to a single column, so the order people read the fields in and the order Tab takes them through are the same (WCAG 1.3.2, 2.4.3).
  • Keep the widths within the screen. On a narrow screen, an input shouldn't be wider than its column (WCAG 1.4.10), so add max-width: 100% to any width you set in ch that could outgrow it.

Tokens

The input reads three theme tokens, which every component shares:

TokenUsed for
--color--neutral-textThe value, and mixed into the border, the placeholder and any prefix or suffix
--color--error-textThe border of an invalid input
--color--focusThe focus ring, which is the same for every component

There's no color prop, so the input doesn't read any of the other colours. Everything else is mixed from the text colour: 55% for the border, which needs 3:1 against the page, 80% for the border on hover, and 70% for the placeholder, prefixes and suffixes, which need 4.5:1. With our reference tokens, from the Tailwind CSS palette, the border comes to 3.5:1 and the placeholder to 5.6:1 on white, and 5.0:1 and 7.4:1 on zinc-900. If you change the tokens, make sure to check the same pairs. You'll find the tokens themselves in the Button's tokens.css, which you can copy into your theme.

If you need to tune an input, you can set its own variables, such as --input--height, --input--radius or --input--border-color, from a class. The Input's styles sit in the components cascade layer, so your class wins without any specificity tricks:

SearchBar.tsx
import styles from "./SearchBar.module.css";

export function SearchBar() {
  return <Input type="search" aria-label="Search" className={styles.pill} />;
}
SearchBar.module.css
.pill {
  --input--radius: 999px;
}

UI Checklist

RuleLevelScopeRequirementBasisCheck
input/contrastMustComponentThe value, the placeholder, prefixes and suffixes have a contrast of 4.5:1, and the focus ring has 3:1 against the pageWCAG 1.4.3, 1.4.11 (AA)axe color-contrast, contrast checker
input/boundary-contrastMustComponentThe border has a contrast of 3:1 against the page, so an empty input stays visibleWCAG 1.4.11 (AA)Contrast checker
input/focus-visibleMustComponentFocus shows an outline at least 2px thick around the whole field, outside its borderWCAG 2.4.7 (AA), 2.4.13 (AAA)Keyboard
input/target-sizeMustComponentThe field is at least 24px tall, with a hit area at least 44px tall on touch screensWCAG 2.5.8 (AA), 2.5.5 (AAA)Emulation
input/click-to-focusShouldComponentPressing its icons, prefixes or padding focuses the input, and an input that already has focus keeps it, without a blurOpen ComponentsUnit test
input/no-zoom-on-focusShouldComponentOn touch screens, the text is at least 16px, so Safari on iOS doesn't zoom in when the input gets focusOpen ComponentsEmulation
input/resizableMustComponentThe field is sized with min-height and padding in rem, so it grows with its text instead of clipping itOpen Components, beyond WCAG 1.4.4, 1.4.12 (AA)200% zoom
input/stable-sizeMustComponentNo state changes the field's size or positionOpen ComponentsVisual regression test
input/state-selectorsShouldComponentStates are styled from the attributes on the <input> that expose them, and never from :invalid or :user-invalidOpen ComponentsReview
input/hover-capableShouldComponentHover styles only apply inside @media (hover: hover)Open ComponentsReview
input/forced-colorsMustComponentThe border, and the focus, disabled and read-only states, stay visible in forced colours modeOpen ComponentsEmulation
input/more-contrastShouldComponentThe border is drawn at full strength when prefers-contrast: more is setOpen ComponentsEmulation
input/fits-valueShouldUsageIts width fits the value it expects, like a short one for a postcode, and never outgrows the screenOpen Components, beyond WCAG 1.4.10 (AA)Review

FAQ

Copyright © 2026