Components

Input

How to build a text input that people can label, fill in and fix, that works with every keyboard, autofill and password manager, and that agents can check.

Inputs are where people tell you things, like their name, their email address or what they're looking for. They look like the simplest component there is, a box with a border, which is why they're so often built in a hurry. Think of a placeholder that stands in for a label and disappears as soon as you type, a phone keyboard without an @ on it, a password field that won't let you paste, or a red border that doesn't say what's wrong.

This page walks you through building one the right way. We'll look at:

  • User Interface (UI) - how an input looks
  • User Experience (UX) - how it behaves for every person and every input
  • Developer Experience (DX) - how developers work with it
  • Agentic Experience (AX) - how agents can read and verify it

Every example on this page runs on our reference implementation, which does its part of every rule in the checklist, so feel free to type into them as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for an Input with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.

The Input is the box you type in. Its label, hint and error message are elements of their own, which you link to it by ID, and a Field component, which is on our Roadmap, will put them together for you. Until then, the examples do it with a plain <label>, and the rules hold either way.

<label htmlFor="email">Email address</label>
<Input
  id="email"
  type="email"
  autoComplete="email"
  value={value}
  onChange={(event) => setValue(event.target.value)}
/>

At a glance

AspectInput
Element<input>, inside a <div> that draws the field
Roletextbox, or searchbox with type="search", while ARIA has no role for a password input
NameIts <label>, linked with for, and never its placeholder
KeyboardThe browser's own text editing, Tab on to its actions, and Enter to submit its form
StatesHover, focus, disabled, read-only and invalid
Typestext, email, password, search, tel and url
Sizesxs, sm, md, lg and xl, with the same heights as the Button
PatternNone in the APG, since a native <input> covers it
WCAG 2.21.3.1, 1.3.5, 1.4.3, 1.4.11, 2.4.7, 2.5.8, 3.3.1, 3.3.2, 3.3.3, 3.3.8 and 4.1.2, among others

User Interface (UI)

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;
}

User Experience (UX)

Now let's look at how an input behaves for every person, input and setting. A good input is accessible, predictable, designed in every state and adaptive.

Accessible

Use the native element

A native <input> gives you focus, text editing, selection, copy and paste, undo, spellcheck, autofill, password managers, the right keyboard on phones and form submission, all for free. A contenteditable element only gives you the editing. It has no role or name, it isn't submitted with its form, autofill and password managers skip it, and phones can't tell which keyboard to show, so every one of those is one more thing to rebuild by hand.

{/* Do: you get editing, autofill, the right keyboard and form submission for free */}
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" autoComplete="email" />

{/* Don't: there's no role, name, autofill or form submission until you rebuild them */}
<div className="input" contentEditable onInput={update} />

Label every input

The label is also the input's accessible name. Screen readers read it when the input gets focus, voice control users say it out loud ("click Phone number"), and tests and agents use it to find the input.

For international numbers, include the country code

<label htmlFor="phone">Phone number</label>
<p id="phone-hint">For international numbers, include the country code</p>
<Input id="phone" type="tel" autoComplete="tel" aria-describedby="phone-hint" />
  • Every input has a visible <label>, linked to it with for and the input's id (WCAG 1.3.1, 3.3.2). Clicking the label focuses the input too, which makes for a much bigger target.
  • A hint, like the format you expect, goes in the input's description, which screen readers read after its name. You can do that with aria-describedby, pointing at the hint's id.
  • A search input next to its Search button is the one input that doesn't need a label of its own. The button already says what it's for, so you can name the input with aria-label="Search".
  • If you name an input with aria-label or aria-labelledby, make sure the name includes the visible label, ideally at the start (WCAG 2.5.3).
  • The Input doesn't make up IDs of its own (see Deterministic), so give it an id for its label to point at, and one to each hint and error message that points back at it.

A placeholder isn't a label. It disappears as soon as people start typing, so they can't check what they were asked for, it's lighter than the text around it, so it's harder to read, and it isn't a reliable name for assistive technologies. If you use one at all, use it for an example, like name@example.com, and put anything people need to know in the hint.

{/* Do: the label stays put while people type */}
<label htmlFor="email">Email address</label>
<Input id="email" type="email" autoComplete="email" />

{/* Don't: the only label disappears as soon as people start typing */}
<Input type="email" autoComplete="email" placeholder="Email address" />

Contrast and size

  • The value needs a contrast of at least 4.5:1 with its background, and so do the placeholder, prefixes and suffixes, since people need to read them too (WCAG 1.4.3). Disabled inputs are exempt, but they should still be readable.
  • The border needs 3:1 against the page (WCAG 1.4.11). An empty input is nothing but its border, so it's how people see that there's something to fill in, and where to click. The focus ring needs 3:1 as well.
  • Every input is at least 24px tall (WCAG 2.5.8). On touch screens, the hit area grows to 44px tall (WCAG 2.5.5), and pressing anywhere on the field, its icons and padding included, focuses the input.

Show focus

When an input has focus, the whole field shows an outline in the same focus colour as every other component (WCAG 2.4.7). It's drawn by the root rather than the <input>, so it goes around the icons and buttons too, and like the Button's, it's at least 2px thick and drawn outside the border, and we recommend 2px for both. Browsers match :focus-visible on a text input however it got focus, since people are about to type either way, so the ring shows after a click too. A Button among the actions shows its own ring instead, so only one thing on the page ever looks focused.

You'll also want to keep focused inputs clear of sticky headers and footers, which you can do with scroll-padding (WCAG 2.4.11, 2.4.12). On phones, the on-screen keyboard covers the bottom of the page too, so check that a focused input near the bottom scrolls into view above it.

Predictable

Leave typing to the browser

The Input leaves text editing to the browser, so moving the caret, selecting, copying, cutting, pasting, undoing and spellchecking all work just as they do everywhere else. We recommend never taking any of them over, and never blocking paste. People paste addresses and codes, and password managers paste passwords in for them. Blocking paste makes people type a long password by hand, which WCAG counts as a cognitive function test (WCAG 3.3.8).

Keyboard

KeyInput
Tab, Shift+TabMoves focus to and from the input, and on to its actions
Arrows, Home and EndMove the caret, as in any other text field
EnterSubmits its form

Enter submits the form the input is in, through the form's submit button, which HTML calls implicit submission. Give every form a submit button, since a form with two inputs and no submit button doesn't submit at all, and don't make Enter do anything else, like move to the next input.

Pick the type that fits

The type tells phones which keyboard to show, and browsers what to expect. inputmode changes only the keyboard, which is what you need for digits that aren't amounts, like a security code.

<label htmlFor="email">Email address</label>
<Input id="email" type="email" autoComplete="email" spellCheck={false} />

<label htmlFor="phone">Phone number</label>
<Input id="phone" type="tel" autoComplete="tel" />

<label htmlFor="website">Website</label>
<Input id="website" type="url" autoComplete="url" spellCheck={false} />

<label htmlFor="code">Security code</label>
<Input id="code" inputMode="numeric" autoComplete="one-time-code" spellCheck={false} />
When people type…UsePhones showautocomplete, for example
A name, or any other texttype="text", the defaultThe standard keyboardname, given-name, organization
An email addresstype="email"A keyboard with @ and .email
A passwordtype="password"The standard keyboard, hiding the valuecurrent-password, new-password
A searchtype="search"A keyboard with a search keyNone
A phone numbertype="tel"A phone keypadtel
A web addresstype="url"A keyboard with / and .url
A code, or a card numbertype="text" with inputmode="numeric"A number keypadone-time-code, cc-number

We left number out of the Input's types on purpose. It's meant for amounts you'd step up and down, so browsers add arrows to it, accept an e for an exponent, and some change its value when you scroll over it. That's not what you want for a code, a card number or a phone number, so for those, use inputmode="numeric", which only changes the keyboard. Numbers with steppers, dates and the other types each get a component of their own.

Help people fill it in

  • Give each input that asks about the person filling in the form the matching autocomplete value, like email, tel or postal-code (WCAG 1.3.5). Browsers and password managers then fill it in for them, which helps everyone, and most of all people who find typing hard. You'll find every value in the HTML standard.
  • For values that aren't words, like usernames, codes and email addresses, turn off spellcheck, and autocapitalize and autocorrect where the type doesn't already, so phones don't capitalise the first letter or "correct" a username into a word.
  • If you'd like the Enter key on phones to say what it does, set enterkeyhint, as in enterkeyhint="search" or "send".

Don't cut people off

maxlength stops people from typing past a limit, but it also silently cuts off what they paste, and they may never notice that the end is missing. If there's a limit, we recommend saying what it is in the hint, letting people go over it, and saying by how much in an error message, so they can decide what to cut.

Every state

Disabled

A disabled input can't be focused, edited or submitted. disabled sets the native attribute, so the input leaves the tab order, screen readers announce it as dimmed or unavailable when they read through the page, and its form leaves its value out when it's sent. That last part is easy to miss: if people need to see a value, and the form needs to send it, the input should be read-only instead.

We recommend disabling as little as you can, since a disabled input can't explain why it's disabled, and keyboard users can't even reach it to find out. Explain why next to it, or show the value as text instead.

Never disable an input while it has focus, either, like every input in a form while it's being sent. Disabling a focused input takes its focus away, and keyboard and screen reader users lose their place. Make the inputs read-only while the form is sent instead, which keeps the focus where it is.

Read-only

A read-only input shows a value that people can't change, like an account ID. It stays in the tab order, so people can focus it, select the value and copy it, screen readers announce it as read-only, and its form still sends it. readonly is a native attribute, so it lands on the <input> like any other. We recommend a dashed border, which tells it apart from an input you can type in, while the value keeps its full contrast.

Invalid

An invalid input has a value that needs fixing, and an error message that says how. The Input shows the state, and the page decides when to show it, since only the page knows what makes a value right.

import { useRef, useState, type FormEvent } from "react";
import { flushSync } from "react-dom";

export function Subscribe() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const input = useRef<HTMLInputElement>(null);

  // Only checks what people have typed, so tabbing past the input doesn't flag it.
  function leave() {
    if (email) setError(validateEmail(email));
  }

  function update(value: string) {
    setEmail(value);
    // Once an error shows, check again as people type, so it clears as soon as it's fixed.
    if (error) setError(validateEmail(value));
  }

  async function subscribe(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const message = validateEmail(email);
    if (message) {
      // Focus once the error is in the page, so screen readers read it with the input.
      flushSync(() => {
        setError(message);
        setStatus("Couldn't subscribe. Check your email address.");
      });
      input.current?.focus();
      return;
    }
    await api.subscribe(email);
    setStatus("You're subscribed");
  }

  return (
    <form noValidate onSubmit={subscribe}>
      <label htmlFor="email">Email address</label>
      <Input
        ref={input}
        id="email"
        type="email"
        autoComplete="email"
        required
        value={email}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? "email-error" : undefined}
        onChange={(event) => update(event.target.value)}
        onBlur={leave}
      />
      {error && <p id="email-error">{error}</p>}
      <Button type="submit" color="primary">Subscribe</Button>
      <p role="status">{status}</p>
    </form>
  );
}

validateEmail stands in for your own check, which returns an error message, or an empty string when the address looks right.

  • Set aria-invalid="true" on the input while its error message shows, and point aria-describedby at the message. Screen readers then announce the input as invalid, and read the message after its name (WCAG 3.3.1).
  • Say what's wrong and how to fix it, as in "Enter an email address in the correct format, like name@example.com", rather than "Invalid input" (WCAG 3.3.3). A red border alone can't say any of that, and it means nothing to people who can't tell red apart (WCAG 1.4.1).
  • Check a value when people leave the input, or when they submit, and not while they're still typing it. An error that shows up after the first letter only tells people what they already know. Once an error shows, though, check again as they type, and clear it as soon as the value is right.
  • When a submit fails, move focus to the first invalid input, so keyboard and screen reader users land right on it and hear its error. Do it once the error is in the page, which in most frameworks means waiting for the next render, as above. If the input already has focus, as it does when people press Enter in it, moving focus doesn't announce anything, which is why the example also says what happened in a status message. With several errors, a summary at the top of the form, with a link to each input, works even better.
  • Put novalidate on the form, so that the browser's own error bubbles, which look different in every browser and vanish after a few seconds, don't compete with yours. Keep required on the input, though, which tells screen readers that it's required.

Passwords

A password input hides what people type, which makes typos hard to spot. Let them check what they've typed with a Show password button in actions. It's a toggle button that switches the input's type to text and back, so it keeps its label and says whether it's on with aria-pressed.

const [shown, setShown] = useState(false);

<label htmlFor="password">Password</label>
<Input
  id="password"
  type={shown ? "text" : "password"}
  autoComplete="current-password"
  actions={
    <Button
      label="Show password"
      variant="ghost"
      size="xs"
      iconOnly
      aria-controls="password"
      aria-pressed={shown}
      onClick={() => setShown(!shown)}
      leading={shown ? <EyeOffIcon /> : <EyeIcon />}
    />
  }
/>
  • Use autocomplete="current-password" to sign in and "new-password" to sign up, so password managers fill in the right one, or offer to make up a strong one.
  • The toggle is a Button, so its type is "button", and it never sends the form by accident (see the Button's Don't submit forms by accident).
  • Edge draws a Show password button of its own once people start typing, so the Input hides it when it has actions, to make sure there's only ever one.

Adaptive

Pointer, touch and pen

  • Hover styles only apply to pointers that can actually hover (@media (hover: hover)), otherwise a tap would leave the border looking hovered.
  • On touch screens, the input reaches past the field to a hit area at least 44px tall, without the field looking any bigger. It does it with a transparent border, which the browser's autofill colour doesn't paint.
  • On touch screens, the text is at least 16px, whatever the size. Safari on iOS zooms in on an input with smaller text as soon as it gets focus, and people then have to zoom back out to see the rest of the form.
  • Pressing an icon, a prefix or the padding focuses the input, as pressing the input would. It happens on mousedown, so an input that already has focus keeps it, without a blur that would run its validation.

More contrast

When someone asks for more contrast (prefers-contrast: more), as with Increase contrast on macOS and iOS, the border is drawn in the text colour at full strength, rather than at 55%. The value and the placeholder already pass 4.5:1, so they stay as they are.

Forced colours

Windows contrast themes replace every colour with the user's own, and drop backgrounds and shadows. The Input's border stays, since the system draws it in a colour of its own, focus is drawn with outline, and a disabled input is marked with GrayText. A read-only input's border stays dashed. The invalid border's second pixel is a shadow, which forced colours drop, so in these themes it's the error message, in text, that tells people something's wrong, which is one more reason to always show one.

Colour scheme

The tokens use light-dark(), which follows color-scheme, so the Input adapts to your light and dark themes on its own. Setting color-scheme on your themes also lets the browser match the parts of the input it draws itself, like the autofill colour in Chrome.

Autofill

Browsers tint the inputs they fill in, so people can see what was filled in for them. We recommend leaving the tint as it is. It's useful, and the tricks that hide it, like a huge inset box-shadow, break as browsers change. The Input rounds the <input>'s corners so the tint follows the field's, and keeps it inside the field on touch screens.

Text size and spacing

Heights are minimums in rem, so the field grows with its text instead of clipping it, at 200% text size (WCAG 1.4.4) and with wider line, letter and word spacing (WCAG 1.4.12). A value that's longer than the input scrolls inside it, as in any single-line input, so for text that runs long, like a message, a Textarea, on our Roadmap, is a better fit.

Languages and direction

The Input uses logical properties and leading and trailing slots rather than left and right, so it mirrors itself in right-to-left languages, prefix, suffix and all. The value needs a little help, though:

<div dir="rtl" lang="ar">
  <label htmlFor="name">الاسم</label>
  <Input id="name" dir="auto" autoComplete="name" />

  <label htmlFor="email">البريد الإلكتروني</label>
  <Input id="email" type="email" dir="ltr" autoComplete="email" leading={<MailIcon />} />
</div>
  • Some values always run left to right, like email addresses, phone numbers and web addresses. Give their inputs dir="ltr", which lands on the <input>, so the value reads the right way round while the field around it, icon included, stays right to left.
  • For free text, which people could type in either direction, like a name, dir="auto" lets the input follow whatever they type.
  • Remember to translate labels, hints and error messages along with the rest of the page, and to mark any that are in another language with lang, so that screen readers pronounce them correctly (WCAG 3.1.2).

Developer Experience (DX)

Next, let's look at how developers use the Input. It has one API, with the same names as every other component, it's typed end to end, and it works with your forms however you build them.

import { useState } from "react";
import { Input } from "@/components/Input";

export function Newsletter() {
  const [email, setEmail] = useState("");

  return (
    <>
      <label htmlFor="email">Email address</label>
      <Input
        id="email"
        type="email"
        autoComplete="email"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />
    </>
  );
}

Consistent

Once you've learned the Button's API, you already know most of the Input's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
sizePropxs, sm, md, lg or xl, with the same heights everywhere
disabledPropThe component can't be used
leading, trailingSlotsContent before and after the main content
actionsSlotButtons that act on the component's value, after everything else
valuePropThe value, which you bind the way your framework binds a form control's

We recommend size="md" and type="text" as the defaults. The Input also leaves a few props out, on purpose:

  • There's no variant. Buttons need ranking, so there's one primary action per view, but the inputs in a form are all equally important. A variant without a border, like ghost, would also leave an empty input invisible (WCAG 1.4.11).
  • There's no color either. An input has no intent of its own, and its one coloured state, invalid, comes from aria-invalid, so its colour and its state can't disagree.
  • There's no label, hint or error. Each of them is an element of its own, linked by ID, which the Field on our Roadmap will render, so the Input stays one control that fits any layout.
  • There's no loading. If a value is being checked, like whether a username is free, put a Spinner in trailing, and say what's happening in a status message.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like type="number", which isn't one of the Input's types.

PropTypeDefaultDescription
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the input is
type"text" | "email" | "password" | "search" | "tel" | "url""text"The kind of text it takes, which picks the keyboard on phones
disabledbooleanfalseBlocks focus, editing and submission
valuestringThe value, when you bind it. Without it, the input keeps its own
EventPayloadDescription
inputEventFires as people type
changeEventFires when people commit a change, like when they leave the input

Both come straight from the <input>, like every other native event, such as focus and blur, and a binding to value listens to them for you.

SlotDescription
leadingAn icon or a prefix before the value, hidden from assistive technologies
trailingAn icon or a suffix after the value, hidden from assistive technologies
actionsButtons that act on the value, which Tab reaches after the input

We also export InputProps, which comes in handy for wrappers and data-driven forms:

import type { InputProps } from "@/components/Input";

const fields: (InputProps & { name: string; label: string })[] = [
  { name: "email", label: "Email address", type: "email" },
  { name: "phone", label: "Phone number", type: "tel" },
];

Composable

The Input renders two elements, so it splits what you pass between them. Classes and styles go on the root, which draws the field, so a class can set its variables and its width. Everything else lands on the <input>, including id, name, autocomplete, inputmode, placeholder, required, readonly, ARIA attributes and event listeners. A ref to the Input reaches the <input> too, since that's what form libraries and focus() need.

Forms

The <input> is a real form control, so the Input works with your forms however you build them. You can bind its value, as above, or leave it to the browser and read it from the form when it's sent, by its name. That also means the form works before your JavaScript loads.

import type { FormEvent } from "react";

export function SignUp() {
  function signUp(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    api.signUp(data.get("username"), data.get("password"));
  }

  return (
    <form onSubmit={signUp}>
      <label htmlFor="username">Username</label>
      <Input
        id="username"
        name="username"
        autoComplete="username"
        autoCapitalize="none"
        autoCorrect="off"
        spellCheck={false}
      />
      <label htmlFor="password">Password</label>
      <Input id="password" name="password" type="password" autoComplete="new-password" />
      <Button type="submit" color="primary">Create account</Button>
    </form>
  );
}

Form libraries work the same way. They register the <input> itself, through its ref or its attributes and listeners, which is why the Input puts them all on the <input>.

With other components

  • The Field, on our Roadmap, will render the label, hint and error message around an Input, give it an id, and set its aria-describedby and aria-invalid for you.
  • A Button next to an input, like Search, takes the same size, so the two line up. A Button in actions is ghost and icon-only, two sizes smaller, and it's still a Button, with its name, its focus ring and its type="button".
  • A Spinner in trailing shows that a value is being checked, while a status message says what's happening, since the Spinner stays hidden from screen readers.
  • Components that take typed text, like a Combobox or a Date Picker, will build on the Input, with the same sizes, slots and DOM.

Controllable

The Input doesn't keep any state you can't reach. The value is yours to bind, or to leave to the browser, and every other state is a prop or an attribute that you control:

StateHow you control it
Valuevalue, bound to your state, or left to the browser
Disableddisabled
Read-onlyreadonly, which lands on the <input>
Invalidaria-invalid, set while its error message shows (see Invalid)
FocusA ref to its <input>, and focus()

Agentic Experience (AX)

Finally, let's look at how agents read, fill in and check an input. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against.

Semantic

Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names, values and states. This is how they see some of the examples on this page:

- textbox "Full name": Ada Lovelace
- textbox "Username" [disabled]: ada
- textbox "Account ID": ACME-4821
- textbox "Email address" [invalid]: ada@
- searchbox "Search"
- textbox "Password"
- button "Show password"

An input without a label is a textbox with no name, so an agent can't tell an email address from a search, any more than a screen reader user can. With a role and a name, an agent can find the input and fill it in:

await page.getByRole("textbox", { name: "Email address" }).fill("ada@example.com");

A password input is the odd one out. ARIA doesn't define a role for it, so tools disagree on what it is: Playwright reports it as a textbox, as above, while Testing Library doesn't find it by that role at all. Finding it by its label, with getByLabel("Password") or getByLabelText("Password"), works everywhere.

Described

Here's the whole contract in one block, for agents to read before they build or review an input:

component: Input
summary: Takes a single line of text, like a name, an email address or a search. Its label names it.
standard: https://opencomponents.dev/docs/components/input
element: input # inside a div, which draws the field and takes class and style
role: textbox # searchbox, with type="search". ARIA has none for type="password"
props:
  size: { type: [xs, sm, md, lg, xl], default: md }
  type: { type: [text, email, password, search, tel, url], default: text }
  disabled: { type: boolean, default: false }
  value: { type: string } # when it's bound. Without it, the input keeps its own
slots:
  leading: An icon or a prefix before the value, hidden from assistive technologies.
  trailing: An icon or a suffix after the value, hidden from assistive technologies.
  actions: Buttons that act on the value, which Tab reaches after the input.
events:
  input: Event, from the <input>. Fires as people type.
  change: Event, from the <input>. Fires when people commit a change, like when they leave it.
states:
  hover: ":hover, in @media (hover: hover)"
  focus: ":has(> input:focus-visible)"
  disabled: ":has(> input:disabled)"
  read-only: ":has(> input[readonly])"
  invalid: ":has(> input[aria-invalid=true]), never :invalid"
keyboard:
  Tab: Moves focus to the input, then to its actions.
  Enter: Submits its form.
parts: [leading, control, trailing, actions] # data-slot, in this order. The control is the <input>
tokens:
  theme: ["--color--neutral-text", "--color--error-text", "--color--focus"]
  variables: [--input--height, --input--padding, --input--gap, --input--font-size, --input--icon--size, --input--radius, --input--border-color]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/input#checklist

It's also published on its own at /raw/docs/components/input.yaml, with every rule from the checklist in place of the rules link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them.

Every rule has a stable ID, like input/visible-label, so you and your agents can cite it in reviews and commits.

Deterministic

The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's an email input with an error:

<Input
  id="email"
  type="email"
  autocomplete="email"
  aria-invalid="true"
  aria-describedby="email-error"
/>

And here's what it renders:

<div class="input" data-size="md">
  <input
    data-slot="control"
    type="email"
    id="email"
    autocomplete="email"
    aria-invalid="true"
    aria-describedby="email-error"
  />
</div>
  • The size is mirrored as data-size, which is always there. The type is the <input>'s own type, which is always set, text included.
  • Parts are marked with data-slot and always come in the same order: leading, control, trailing and actions. Only the control is always there.
  • States change attributes, never elements. The <input> is never replaced, so focus, the caret, what people have typed and the screen reader's position all survive every state change, an error included.
  • There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot. You give the input its id, which the Field will do for you.
  • The type is always a prop, and never guessed from the input's name or autocomplete.

Verifiable

Every rule in the checklist can be checked, and about half of them automatically.

Unit tests

Every rule that the checklist checks with a unit test has one in Input.test.ts, which you'll find under Reference implementation. We run them on every change to this page, so the code you see always passes them.

In the browser

In the browser, you can run axe to check labels, contrast and autocomplete values, and fill in the form the way an agent would, to check what happens when a value is wrong:

import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

test("the newsletter form says what's wrong with an email address", async ({ page }) => {
  await page.goto("/newsletter");

  const email = page.getByRole("textbox", { name: "Email address" });
  await email.fill("ada@");
  await page.getByRole("button", { name: "Subscribe" }).click();

  await expect(email).toBeFocused();
  await expect(email).toHaveAttribute("aria-invalid", "true");
  await expect(email).toHaveAccessibleDescription(/in the correct format/);

  const { violations } = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
    .analyze();
  expect(violations).toEqual([]);
});

Some things still need a human, so try your forms with the keyboard alone, with a screen reader, on a phone, where you can check the keyboards, autofill and zoom, and at 200% zoom, with forced colours and more contrast turned on. You can emulate the last two in Chrome DevTools, under Rendering.

During development

Our reference implementation also warns you in the console as soon as an input renders without a label, which is the most common input bug out there.

Prompts

You can point your agent straight at this page. To build an input, you could use:

Build an Input component for our React design system that meets the Open Components
Input standard: https://opencomponents.dev/raw/docs/components/input.md

Follow its API, its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.

To review one, the contract is enough:

Review our Input against the Open Components Input contract:
https://opencomponents.dev/raw/docs/components/input.yaml

For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.

And to review how a screen uses its inputs:

Review how our sign-up form uses its inputs against the Open Components Input
contract: https://opencomponents.dev/raw/docs/components/input.yaml

For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.

Checklist

Here's every rule in one place, along with where it comes from and how to check it. An input meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.

The scope says who meets a rule. Component rules are met by the Input itself, so they're the ones to check when you build or review one. Usage rules are met by the forms that use it, like giving every input a label, so they're the ones to check when you review a screen. Both rules need the two to work together, like an invalid input: the Input looks invalid from aria-invalid, as long as you set it when the error message shows.

The basis says where a rule comes from. About half the rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from HTML. The rest are Open Components rules, which are our own, like checking a value when people leave the input rather than while they type. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level AAA, like the 44px hit area on touch screens, and a rule that asks for more than its criterion says beyond, like using a native <input>, where WCAG would let you rebuild one by hand.

UI rules

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

UX rules

RuleLevelScopeRequirementBasisCheck
input/native-elementMustComponentIt's a native <input>, and never a contenteditable elementOpen Components, beyond WCAG 2.1.1, 4.1.2 (A)Unit test
input/visible-labelMustUsageEvery input has a visible <label>, linked with for, rather than a placeholder alone, unless it's a search next to its Search button, named with aria-labelWCAG 1.3.1, 3.3.2, 4.1.2 (A)axe label
input/label-in-nameMustUsageAn aria-label or aria-labelledby includes the visible label, ideally at the startWCAG 2.5.3 (A)axe label-content-name-mismatch
input/describedShouldUsageHints, like the format it expects, are linked with aria-describedby, and a placeholder only ever shows an exampleWCAG 1.3.1 (A), Open ComponentsScreen reader
input/decorative-slotsMustComponentleading and trailing are hidden from assistive technologies with aria-hidden, while actions stay reachableWCAG 1.1.1 (A)Unit test
input/units-in-labelMustUsageA prefix or a suffix, like a currency or a unit, is in the label or the hint as wellWCAG 1.3.1 (A)Review
input/input-purposeMustUsageAn input that asks about the person filling it in has the matching autocomplete, like email or telWCAG 1.3.5 (AA)axe autocomplete-valid, review
input/fitting-typeShouldUsageThe type or inputmode fits the value, so phones show the right keyboard, digits that aren't amounts use inputmode="numeric" rather than type="number", and values that aren't words turn off spellcheckOpen ComponentsReview
input/keyboardMustComponentTab reaches the input and then its actions, and the browser's text editing keys work as they do in any text fieldWCAG 2.1.1 (A)Unit test
input/allow-pasteMustBothPasting, autofill and password managers are never blockedWCAG 3.3.8 (AA)Unit test, review
input/enter-submitsShouldBothEnter submits the input's form, which has a submit buttonHTMLUnit test
input/no-truncationShouldUsageA length limit is stated in the hint and checked with an error message, rather than enforced with maxlengthOpen ComponentsReview
input/error-messageMustUsageAn invalid input has an error message, in text and linked with aria-describedby, that says what's wrong and how to fix itWCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)Screen reader
input/invalid-stateMustBothAn input sets aria-invalid="true" while its error message shows, and only looks invalid when it doesWCAG 4.1.2 (A), Open ComponentsUnit test
input/validate-lateShouldUsageErrors show when people leave the input or submit, never while they're first typing, and clear as soon as the value is fixedOpen ComponentsReview
input/focus-first-errorShouldUsageWhen a submit fails, focus moves to the first invalid input, or to a summary of the errors, once the error messages are in the pageOpen ComponentsKeyboard, screen reader
input/disabled-inertMustComponentA disabled input can't be focused, edited or submittedHTMLUnit test
input/read-only-valuesShouldUsageA value people need to see but can't change is read-only rather than disabled, so they can focus it and copy it, and its form sends itOpen ComponentsReview
input/keep-focusMustUsageAn input is never disabled while it has focus, so while its form is sent, it's read-only insteadOpen ComponentsKeyboard
input/action-buttonsMustBothButtons in actions are named Buttons that Tab reaches after the input, and one that goes away moves focus back to the inputWCAG 2.1.1, 2.4.3, 4.1.2 (A)Unit test, keyboard
input/show-passwordShouldUsageA password input has a Show password toggle in actions, which sets aria-pressed and keeps its labelOpen ComponentsReview
input/focus-not-obscuredMustUsageSticky headers and footers never cover a focused input, for example thanks to scroll-paddingWCAG 2.4.11 (AA), 2.4.12 (AAA)Keyboard
input/mirrors-in-rtlShouldBothIt uses logical properties, and values that always run left to right, like email addresses, get dir="ltr" in right-to-left layoutsOpen ComponentsReview

DX rules

RuleLevelScopeRequirementBasisCheck
input/shared-vocabularyMustComponentIt uses the shared names: size, disabled, leading, trailing and actionsOpen ComponentsReview
input/typedMustComponentProps, events and slots are typed, with unions rather than string, and type only takes the text typesOpen ComponentsType check
input/control-attributesMustComponentAttributes, listeners and refs land on the <input>, while classes and styles land on the rootOpen ComponentsUnit test
input/form-controlMustComponentIts form sends its value under its name, whether the value is bound or notHTMLUnit test
input/controllableShouldComponentIts value can be bound or left to the browser, and every other state is a prop or an attributeOpen ComponentsUnit test

AX rules

RuleLevelScopeRequirementBasisCheck
input/role-and-nameMustComponentIt can be found by its role and label alone, with getByRole("textbox", { name }), or with getByLabel for a passwordWCAG 4.1.2 (A)Unit test
input/deterministic-domMustComponentThe same props render the same markup, with no generated IDs, and states change attributes, never elementsOpen ComponentsUnit test
input/data-attributesShouldComponentThe size is mirrored as data-size, and parts are marked with data-slotOpen ComponentsUnit test
input/dev-warningsShouldComponentIt warns during development when an input has no labelOpen ComponentsUnit test

Reference implementation

Our Vue 3 reference implementation does its part of every rule on this page, and powers every example on it. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves. It reads the same theme tokens as the Button, so copy tokens.css from the Button's reference implementation into your theme too.

The tests are in Input.test.ts. To run them, you'll need Vitest, set to environment: "jsdom" and globals: true, along with @testing-library/vue, @testing-library/user-event and @testing-library/jest-dom.

<script setup lang="ts">
import { onMounted, useAttrs, useTemplateRef } from "vue";

export interface InputProps {
  size?: "xs" | "sm" | "md" | "lg" | "xl";
  /** The kind of text it takes, which picks the keyboard on phones. The other input types each get a component of their own. */
  type?: "text" | "email" | "password" | "search" | "tel" | "url";
  disabled?: boolean;
}

// Attributes go on the <input> rather than on the root, so they're declared below.
defineOptions({ inheritAttrs: false });

const { size = "md", type = "text", disabled = false } = defineProps<InputProps>();

/** The value, which `v-model` binds. Without it, the input keeps its own, and its form submits it. */
const model = defineModel<string>();

defineSlots<{
  /** An icon or a prefix before the value, like a currency. */
  leading?: () => unknown;
  /** An icon or a suffix after the value, like a unit. */
  trailing?: () => unknown;
  /** Buttons that act on the value, like Show password, which Tab reaches after the input. */
  actions?: () => unknown;
}>();

const attrs = useAttrs();
// Classes and styles go on the root, which draws the field, so a class can set its
// variables. Everything else goes on the <input>: `id`, `name`, `autocomplete`, ARIA
// and listeners. Attrs aren't reactive, so these run on every render.
const onRoot = (key: string) => key === "class" || key === "style";
const rootAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => onRoot(key)));
const controlAttrs = () => Object.fromEntries(Object.entries(attrs).filter(([key]) => !onRoot(key)));

const input = useTemplateRef<HTMLInputElement>("input");

// The template shows the model, or the `value` attribute while there's none, so an
// input without `v-model`, like a read-only one, can still start with a value.
function onInput(event: Event) {
  model.value = (event.target as HTMLInputElement).value;
}

// A press on the field's icons or padding focuses the input, as a press on the input
// would. It's on mousedown, so an input that already has focus keeps it, without a
// blur that would run its validation.
function onMousedown(event: MouseEvent) {
  const target = event.target as Element;
  // The input itself, and the buttons in `actions`, do what they always do.
  if (target.closest("input, button, a[href], select, textarea, [tabindex]")) return;
  event.preventDefault();
  input.value?.focus();
}

// A ref to the Input gets you the <input>, through `input`.
defineExpose({ input });

onMounted(() => {
  if (import.meta.env.DEV) {
    const el = input.value;
    const named = el?.labels?.length || el?.hasAttribute("aria-label") || el?.hasAttribute("aria-labelledby");
    if (el && !named) {
      console.warn("[Input] has no accessible name: give it a <label for>, or an aria-label for a search.", el);
    }
  }
});
</script>

<template>
  <div class="input" v-bind="rootAttrs()" :data-size="size" @mousedown="onMousedown">
    <span v-if="$slots.leading" data-slot="leading" aria-hidden="true">
      <slot name="leading" />
    </span>
    <input
      ref="input"
      data-slot="control"
      :type="type"
      :disabled="disabled"
      v-bind="controlAttrs()"
      :value="model ?? $attrs.value"
      @input="onInput"
    />
    <span v-if="$slots.trailing" data-slot="trailing" aria-hidden="true">
      <slot name="trailing" />
    </span>
    <span v-if="$slots.actions" data-slot="actions">
      <slot name="actions" />
    </span>
  </div>
</template>

<style scoped>
/* The same layer order as Tailwind CSS. The input's styles go in `components`,
   so they win over the resets in `base`, and give way to utilities and to any
   style outside a layer, like a class of yours that sets its variables. */
@layer theme, base, components, utilities;

@layer components {
  .input {
    /* Set per size. The heights are the Button's, so an input and a button line up in a row. */
    --input--height: 2.25rem;
    --input--padding: 0.75rem;
    --input--gap: 0.5rem;
    --input--font-size: 0.875rem;
    --input--icon--size: 1rem;
    --input--radius: 0.5rem;
    /* The text colour at 55%, which has 3:1 against the page, so people can see where to type. */
    --input--border-color: color-mix(in srgb, currentColor 55%, transparent);

    position: relative;
    display: flex;
    align-items: center;
    box-sizing: border-box;
    /* A min-height rather than a height, so the field grows with larger text. */
    min-height: var(--input--height);
    /* In a flex row, it can get narrower than an <input> is on its own, rather than overflow. */
    min-width: 0;
    border: 1px solid var(--input--border-color);
    border-radius: var(--input--radius);
    background-color: transparent;
    color: var(--color--neutral-text);
    font-family: inherit;
    font-size: var(--input--font-size);
    line-height: 1.25;
    cursor: text;
  }

  /* Sizes */
  .input[data-size="xs"] {
    --input--height: 1.5rem;
    --input--padding: 0.5rem;
    --input--gap: 0.25rem;
    --input--font-size: 0.75rem;
    --input--icon--size: 0.875rem;
    --input--radius: 0.375rem;
  }
  .input[data-size="sm"] {
    --input--height: 2rem;
    --input--padding: 0.625rem;
    --input--radius: 0.375rem;
  }
  .input[data-size="lg"] {
    --input--height: 2.5rem;
    --input--padding: 0.875rem;
    --input--font-size: 1rem;
    --input--icon--size: 1.25rem;
  }
  .input[data-size="xl"] {
    --input--height: 3rem;
    --input--padding: 1rem;
    --input--font-size: 1rem;
    --input--icon--size: 1.25rem;
    --input--radius: 0.75rem;
  }

  /* Parts. The control fills the field, with the same padding on both sides, since it
     can run in another direction than the field, like an email address in Arabic. */
  .input > [data-slot="control"] {
    flex: 1;
    align-self: stretch;
    min-width: 0;
    box-sizing: border-box;
    margin: 0;
    padding: 0 var(--input--padding);
    border: 0;
    /* So the browser's autofill colour follows the field's rounded corners. */
    border-radius: calc(var(--input--radius) - 1px);
    background-color: transparent;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    /* The root draws the focus ring, around the whole field. */
    outline: none;
  }
  .input > [data-slot="control"]::placeholder {
    /* The text colour at 70%, which has 4.5:1 against the page. Firefox lowers its opacity on its own. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    opacity: 1;
  }
  .input > [data-slot="leading"],
  .input > [data-slot="trailing"] {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    /* Prefixes and suffixes, like a currency or a unit, at the placeholder's 4.5:1. */
    color: color-mix(in srgb, currentColor 70%, transparent);
    white-space: nowrap;
  }
  /* Icons take the icon size, and text, like a currency, the input's own. */
  .input > [data-slot] > :slotted(svg) {
    flex-shrink: 0;
    width: var(--input--icon--size);
    height: var(--input--icon--size);
  }
  .input > [data-slot="actions"] {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    gap: 0.25rem;
    cursor: auto;
  }
  /* The parts beside the control overlap its padding, down to the gap. */
  .input > [data-slot="leading"] {
    margin-inline-end: calc(var(--input--gap) - var(--input--padding));
    padding-inline-start: var(--input--padding);
  }
  .input > [data-slot="control"] + [data-slot] {
    margin-inline-start: calc(var(--input--gap) - var(--input--padding));
  }
  .input > [data-slot="trailing"] + [data-slot="actions"] {
    padding-inline-start: var(--input--gap);
  }
  .input > [data-slot="trailing"]:last-child {
    padding-inline-end: var(--input--padding);
  }
  /* Buttons sit closer to the edge, since they have padding of their own. */
  .input > [data-slot="actions"] {
    padding-inline-end: calc(var(--input--padding) / 2);
  }

  /* The browser's own clear button can't be reached with the keyboard, and only some
     browsers draw one, so a search input leaves it out. Put a Button in `actions` instead. */
  .input > [data-slot="control"]::-webkit-search-cancel-button {
    appearance: none;
  }
  /* Edge draws its own Show password button, so leave it out when there's one in `actions`. */
  .input:has(> [data-slot="actions"]) > [data-slot="control"]::-ms-reveal {
    display: none;
  }

  /* States. Each one reads the attribute on the <input> that exposes it, so what people
     see and what assistive technologies report can't drift apart. :invalid isn't one of
     them: it matches a required input before anyone has typed in it. */
  @media (hover: hover) {
    .input:hover:where(:not(:has(> [data-slot="control"]:is(:disabled, [readonly])))) {
      --input--border-color: color-mix(in srgb, currentColor 80%, transparent);
    }
  }
  .input:has(> [data-slot="control"]:focus-visible) {
    outline: 2px solid var(--color--focus);
    outline-offset: 2px;
  }
  .input:has(> [data-slot="control"][readonly]) {
    /* Dashed, so it reads as something you can't change, while the value keeps its contrast. */
    border-style: dashed;
    cursor: default;
  }
  .input:has(> [data-slot="control"][aria-invalid="true"]) {
    --input--border-color: var(--color--error-text);
    /* A second pixel of border, drawn inside, so the field doesn't grow. */
    box-shadow: inset 0 0 0 1px var(--input--border-color);
  }
  .input:has(> [data-slot="control"]:disabled) {
    opacity: 0.5;
    cursor: not-allowed;
  }
  .input > [data-slot="control"]:disabled {
    cursor: inherit;
  }

  /* On touch screens, the text is at least 16px, or Safari on iOS zooms in when the
     input gets focus, and the input reaches past the field to a hit area at least 44px
     tall, with a transparent border that the browser's autofill colour doesn't paint. */
  @media (any-pointer: coarse) {
    .input {
      font-size: max(var(--input--font-size), 1rem);
    }
    .input > [data-slot="control"] {
      margin-block: min(0px, (var(--input--height) - 2.75rem) / 2 - 1px);
      border-block: max(0px, (2.75rem - var(--input--height)) / 2 + 1px) solid transparent;
      background-clip: padding-box;
    }
  }

  /* With more contrast, the border is drawn in the text colour at full strength. */
  @media (prefers-contrast: more) {
    .input,
    .input:hover {
      --input--border-color: currentColor;
    }
  }

  /* Forced colors mode keeps borders but drops shadows, so mark states with system colours. */
  @media (forced-colors: active) {
    .input:has(> [data-slot="control"]:focus-visible) {
      outline-color: Highlight;
    }
    .input:has(> [data-slot="control"]:disabled) {
      border-color: GrayText;
      color: GrayText;
      opacity: 1;
    }
  }
}
</style>

FAQ

Input or something else?

An Input takes a single line of text. For anything else, another component will do a better job:

When people need to…Use
Type a single line of text, like a name or an email addressInput
Type a single line of digits, like a security codeInput, with inputmode="numeric"
Write something longer, like a comment or a messageTextarea
Pick one option out of a few, or out of manyRadio Group, or Select
Type to filter a list of suggestionsCombobox
Pick a dateDate Picker
Turn something on or offCheckbox, or Switch

All of them are on our Roadmap, and they'll share the Input's sizes, states and rules for labels and errors.

Sources

Copyright © 2026