Input
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
| Aspect | Input |
|---|---|
| Element | <input>, inside a <div> that draws the field |
| Role | textbox, or searchbox with type="search", while ARIA has no role for a password input |
| Name | Its <label>, linked with for, and never its placeholder |
| Keyboard | The browser's own text editing, Tab on to its actions, and Enter to submit its form |
| States | Hover, focus, disabled, read-only and invalid |
| Types | text, email, password, search, tel and url |
| Sizes | xs, sm, md, lg and xl, with the same heights as the Button |
| Pattern | None in the APG, since a native <input> covers it |
| WCAG 2.2 | 1.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
- Container — The root
<div>, which draws the border and carries the size. - Leading — Optional. An icon or a prefix, like a currency, hidden from assistive technologies.
- Control — The
<input>itself, which holds the value and takes focus. Its label names it. - Trailing — Optional. An icon or a suffix, like a unit, also hidden from assistive technologies.
- Actions — Optional buttons that act on the value, like Show password, which you reach with
Tab. - 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 */}
| Size | Min. height | Text | Icon | Padding | Gap | Radius |
|---|---|---|---|---|---|---|
xs | 24px | 12px | 14px | 8px | 4px | 6px |
sm | 32px | 14px | 16px | 10px | 8px | 6px |
md | 36px | 14px | 16px | 12px | 8px | 8px |
lg | 40px | 16px | 20px | 14px | 8px | 8px |
xl | 48px | 16px | 20px | 16px | 8px | 12px |
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.
leadingandtrailingare decorative, so the slots wrap them inaria-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".actionsholds Buttons, which keep their names and stay in the tab order, right after the input. We recommendghost, icon-only Buttons two sizes below the input, likexsin anmdinput. 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.
Enter an email address in the correct format, like name@example.com
<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>
| State | Selector | Recommended 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.
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" />
</>
);
}
/* 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
Tabtakes 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 inchthat could outgrow it.
Tokens
The input reads three theme tokens, which every component shares:
| Token | Used for |
|---|---|
--color--neutral-text | The value, and mixed into the border, the placeholder and any prefix or suffix |
--color--error-text | The border of an invalid input |
--color--focus | The 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:
import styles from "./SearchBar.module.css";
export function SearchBar() {
return <Input type="search" aria-label="Search" className={styles.pill} />;
}
.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 withforand the input'sid(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'sid. - 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-labeloraria-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
idfor 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
| Key | Input |
|---|---|
Tab, Shift+Tab | Moves focus to and from the input, and on to its actions |
Arrows, Home and End | Move the caret, as in any other text field |
Enter | Submits 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… | Use | Phones show | autocomplete, for example |
|---|---|---|---|
| A name, or any other text | type="text", the default | The standard keyboard | name, given-name, organization |
| An email address | type="email" | A keyboard with @ and . | email |
| A password | type="password" | The standard keyboard, hiding the value | current-password, new-password |
| A search | type="search" | A keyboard with a search key | None |
| A phone number | type="tel" | A phone keypad | tel |
| A web address | type="url" | A keyboard with / and . | url |
| A code, or a card number | type="text" with inputmode="numeric" | A number keypad | one-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
autocompletevalue, likeemail,telorpostal-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, andautocapitalizeandautocorrectwhere thetypedoesn't already, so phones don't capitalise the first letter or "correct" a username into a word. - If you'd like the
Enterkey on phones to say what it does, setenterkeyhint, as inenterkeyhint="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 pointaria-describedbyat 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
Enterin 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
novalidateon 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. Keeprequiredon 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
typeis"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 ablurthat 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:
| Name | Kind | What it means |
|---|---|---|
size | Prop | xs, sm, md, lg or xl, with the same heights everywhere |
disabled | Prop | The component can't be used |
leading, trailing | Slots | Content before and after the main content |
actions | Slot | Buttons that act on the component's value, after everything else |
value | Prop | The 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, likeghost, would also leave an empty input invisible (WCAG 1.4.11). - There's no
coloreither. An input has no intent of its own, and its one coloured state, invalid, comes fromaria-invalid, so its colour and its state can't disagree. - There's no
label,hintorerror. 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 intrailing, 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
disabled | boolean | false | Blocks focus, editing and submission |
value | string | The value, when you bind it. Without it, the input keeps its own |
| Event | Payload | Description |
|---|---|---|
input | Event | Fires as people type |
change | Event | Fires 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.
| Slot | Description |
|---|---|
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 |
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 itsaria-describedbyandaria-invalidfor you. - A Button next to an input, like Search, takes the same
size, so the two line up. A Button inactionsisghostand icon-only, two sizes smaller, and it's still a Button, with its name, its focus ring and itstype="button". - A Spinner in
trailingshows 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:
| State | How you control it |
|---|---|
| Value | value, bound to your state, or left to the browser |
| Disabled | disabled |
| Read-only | readonly, which lands on the <input> |
| Invalid | aria-invalid, set while its error message shows (see Invalid) |
| Focus | A 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 owntype, which is always set,textincluded. - Parts are marked with
data-slotand always come in the same order:leading,control,trailingandactions. 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
nameorautocomplete.
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
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
input/contrast | Must | Component | The value, the placeholder, prefixes and suffixes have a contrast of 4.5:1, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe color-contrast, contrast checker |
input/boundary-contrast | Must | Component | The border has a contrast of 3:1 against the page, so an empty input stays visible | WCAG 1.4.11 (AA) | Contrast checker |
input/focus-visible | Must | Component | Focus shows an outline at least 2px thick around the whole field, outside its border | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard |
input/target-size | Must | Component | The field is at least 24px tall, with a hit area at least 44px tall on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | Emulation |
input/click-to-focus | Should | Component | Pressing its icons, prefixes or padding focuses the input, and an input that already has focus keeps it, without a blur | Open Components | Unit test |
input/no-zoom-on-focus | Should | Component | On touch screens, the text is at least 16px, so Safari on iOS doesn't zoom in when the input gets focus | Open Components | Emulation |
input/resizable | Must | Component | The field is sized with min-height and padding in rem, so it grows with its text instead of clipping it | Open Components, beyond WCAG 1.4.4, 1.4.12 (AA) | 200% zoom |
input/stable-size | Must | Component | No state changes the field's size or position | Open Components | Visual regression test |
input/state-selectors | Should | Component | States are styled from the attributes on the <input> that expose them, and never from :invalid or :user-invalid | Open Components | Review |
input/hover-capable | Should | Component | Hover styles only apply inside @media (hover: hover) | Open Components | Review |
input/forced-colors | Must | Component | The border, and the focus, disabled and read-only states, stay visible in forced colours mode | Open Components | Emulation |
input/more-contrast | Should | Component | The border is drawn at full strength when prefers-contrast: more is set | Open Components | Emulation |
input/fits-value | Should | Usage | Its width fits the value it expects, like a short one for a postcode, and never outgrows the screen | Open Components, beyond WCAG 1.4.10 (AA) | Review |
UX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
input/native-element | Must | Component | It's a native <input>, and never a contenteditable element | Open Components, beyond WCAG 2.1.1, 4.1.2 (A) | Unit test |
input/visible-label | Must | Usage | Every 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-label | WCAG 1.3.1, 3.3.2, 4.1.2 (A) | axe label |
input/label-in-name | Must | Usage | An aria-label or aria-labelledby includes the visible label, ideally at the start | WCAG 2.5.3 (A) | axe label-content-name-mismatch |
input/described | Should | Usage | Hints, like the format it expects, are linked with aria-describedby, and a placeholder only ever shows an example | WCAG 1.3.1 (A), Open Components | Screen reader |
input/decorative-slots | Must | Component | leading and trailing are hidden from assistive technologies with aria-hidden, while actions stay reachable | WCAG 1.1.1 (A) | Unit test |
input/units-in-label | Must | Usage | A prefix or a suffix, like a currency or a unit, is in the label or the hint as well | WCAG 1.3.1 (A) | Review |
input/input-purpose | Must | Usage | An input that asks about the person filling it in has the matching autocomplete, like email or tel | WCAG 1.3.5 (AA) | axe autocomplete-valid, review |
input/fitting-type | Should | Usage | The 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 spellcheck | Open Components | Review |
input/keyboard | Must | Component | Tab reaches the input and then its actions, and the browser's text editing keys work as they do in any text field | WCAG 2.1.1 (A) | Unit test |
input/allow-paste | Must | Both | Pasting, autofill and password managers are never blocked | WCAG 3.3.8 (AA) | Unit test, review |
input/enter-submits | Should | Both | Enter submits the input's form, which has a submit button | HTML | Unit test |
input/no-truncation | Should | Usage | A length limit is stated in the hint and checked with an error message, rather than enforced with maxlength | Open Components | Review |
input/error-message | Must | Usage | An invalid input has an error message, in text and linked with aria-describedby, that says what's wrong and how to fix it | WCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA) | Screen reader |
input/invalid-state | Must | Both | An input sets aria-invalid="true" while its error message shows, and only looks invalid when it does | WCAG 4.1.2 (A), Open Components | Unit test |
input/validate-late | Should | Usage | Errors show when people leave the input or submit, never while they're first typing, and clear as soon as the value is fixed | Open Components | Review |
input/focus-first-error | Should | Usage | When a submit fails, focus moves to the first invalid input, or to a summary of the errors, once the error messages are in the page | Open Components | Keyboard, screen reader |
input/disabled-inert | Must | Component | A disabled input can't be focused, edited or submitted | HTML | Unit test |
input/read-only-values | Should | Usage | A 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 it | Open Components | Review |
input/keep-focus | Must | Usage | An input is never disabled while it has focus, so while its form is sent, it's read-only instead | Open Components | Keyboard |
input/action-buttons | Must | Both | Buttons in actions are named Buttons that Tab reaches after the input, and one that goes away moves focus back to the input | WCAG 2.1.1, 2.4.3, 4.1.2 (A) | Unit test, keyboard |
input/show-password | Should | Usage | A password input has a Show password toggle in actions, which sets aria-pressed and keeps its label | Open Components | Review |
input/focus-not-obscured | Must | Usage | Sticky headers and footers never cover a focused input, for example thanks to scroll-padding | WCAG 2.4.11 (AA), 2.4.12 (AAA) | Keyboard |
input/mirrors-in-rtl | Should | Both | It uses logical properties, and values that always run left to right, like email addresses, get dir="ltr" in right-to-left layouts | Open Components | Review |
DX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
input/shared-vocabulary | Must | Component | It uses the shared names: size, disabled, leading, trailing and actions | Open Components | Review |
input/typed | Must | Component | Props, events and slots are typed, with unions rather than string, and type only takes the text types | Open Components | Type check |
input/control-attributes | Must | Component | Attributes, listeners and refs land on the <input>, while classes and styles land on the root | Open Components | Unit test |
input/form-control | Must | Component | Its form sends its value under its name, whether the value is bound or not | HTML | Unit test |
input/controllable | Should | Component | Its value can be bound or left to the browser, and every other state is a prop or an attribute | Open Components | Unit test |
AX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
input/role-and-name | Must | Component | It can be found by its role and label alone, with getByRole("textbox", { name }), or with getByLabel for a password | WCAG 4.1.2 (A) | Unit test |
input/deterministic-dom | Must | Component | The same props render the same markup, with no generated IDs, and states change attributes, never elements | Open Components | Unit test |
input/data-attributes | Should | Component | The size is mirrored as data-size, and parts are marked with data-slot | Open Components | Unit test |
input/dev-warnings | Should | Component | It warns during development when an input has no label | Open Components | Unit 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>
import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { h, nextTick, ref, type ComponentPublicInstance } from "vue";
import Input from "./Input.vue";
const icon = () => h("svg");
const label = (id: string, text: string) => h("label", { for: id }, text);
describe("Input", () => {
it("is a native input, found by its role and its label", () => {
render({ render: () => [label("email", "Email address"), h(Input, { id: "email", type: "email" })] });
const input = screen.getByRole("textbox", { name: "Email address" });
expect(input.tagName).toBe("INPUT");
expect(input).toHaveAttribute("type", "email");
});
it("is a searchbox with type search, and found by its label with type password", () => {
render({
render: () => [
h(Input, { type: "search", "aria-label": "Search" }),
label("password", "Password"),
h(Input, { id: "password", type: "password" }),
],
});
expect(screen.getByRole("searchbox", { name: "Search" })).toBeInTheDocument();
// ARIA has no role for a password input, so it's found by its label alone.
expect(screen.getByLabelText("Password")).toHaveAttribute("type", "password");
});
it("puts attributes, listeners and refs on the input, and classes on its root", async () => {
const user = userEvent.setup();
const onFocus = vi.fn();
const instance = ref<ComponentPublicInstance<{ input: HTMLInputElement }>>();
render({
render: () => [
label("postcode", "Postcode"),
h(Input, { ref: instance, id: "postcode", class: "postcode", name: "postcode", autocomplete: "postal-code", onFocus }),
],
});
const input = screen.getByRole("textbox", { name: "Postcode" });
await user.tab();
expect(input).toHaveAttribute("name", "postcode");
expect(input).toHaveAttribute("autocomplete", "postal-code");
expect(input).not.toHaveClass("postcode");
expect(input.parentElement).toHaveClass("input", "postcode");
expect(onFocus).toHaveBeenCalled();
expect(instance.value?.input).toBe(input);
});
it("binds its value with v-model, or keeps its own without it", async () => {
const user = userEvent.setup();
const email = ref("ada@");
render({
render: () => [
h(Input, {
"aria-label": "Bound",
modelValue: email.value,
"onUpdate:modelValue": (value: string | undefined) => (email.value = value ?? ""),
}),
h(Input, { "aria-label": "Unbound" }),
],
});
const bound = screen.getByRole("textbox", { name: "Bound" });
await user.type(bound, "example.com");
expect(email.value).toBe("ada@example.com");
email.value = "";
await nextTick();
expect(bound).toHaveValue("");
const unbound = screen.getByRole("textbox", { name: "Unbound" });
await user.type(unbound, "Ada");
expect(unbound).toHaveValue("Ada");
});
it("is submitted with its form, by its name, and submits it on Enter", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn((event: Event) => event.preventDefault());
render({
render: () =>
h("form", { onSubmit }, [
label("email", "Email address"),
h(Input, { id: "email", name: "email", type: "email" }),
h("button", { type: "submit" }, "Subscribe"),
]),
});
await user.type(screen.getByRole("textbox", { name: "Email address" }), "ada@example.com{Enter}");
expect(onSubmit).toHaveBeenCalledTimes(1);
const form = onSubmit.mock.calls[0]![0].target as HTMLFormElement;
expect(new FormData(form).get("email")).toBe("ada@example.com");
});
it("keeps the browser's text editing keys, and lets people paste", async () => {
const user = userEvent.setup();
render({ render: () => [label("name", "Full name"), h(Input, { id: "name" })] });
const input = screen.getByRole("textbox", { name: "Full name" });
await user.tab();
await user.keyboard("Lovelace{Home}Ada ");
expect(input).toHaveValue("Ada Lovelace");
await user.clear(input);
await user.paste("Ada Lovelace");
expect(input).toHaveValue("Ada Lovelace");
});
it("can't be focused or edited while disabled, and its form leaves it out", async () => {
const user = userEvent.setup();
render({
render: () =>
h("form", [
label("username", "Username"),
h(Input, { id: "username", name: "username", value: "ada", disabled: true }),
]),
});
const input = screen.getByRole("textbox", { name: "Username" });
await user.tab();
await user.type(input, "lovelace");
expect(input).toBeDisabled();
expect(input).not.toHaveFocus();
expect(input).toHaveValue("ada");
expect(new FormData(input.closest("form")!).has("username")).toBe(false);
});
it("passes aria-invalid and its error message to the input", () => {
render({
render: () => [
label("email", "Email address"),
h(Input, { id: "email", "aria-invalid": "true", "aria-describedby": "email-error" }),
h("p", { id: "email-error" }, "Enter an email address, like name@example.com"),
],
});
const input = screen.getByRole("textbox", { name: "Email address" });
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAccessibleDescription("Enter an email address, like name@example.com");
});
it("hides its prefix and suffix from assistive technologies, but not its actions", () => {
const { container } = render(Input, {
attrs: { "aria-label": "Price, in US dollars" },
slots: { leading: () => "$", trailing: () => "USD", actions: () => h("button", "Clear") },
});
const part = (name: string) => container.querySelector(`[data-slot="${name}"]`);
expect(part("leading")).toHaveAttribute("aria-hidden", "true");
expect(part("trailing")).toHaveAttribute("aria-hidden", "true");
expect(part("actions")).not.toHaveAttribute("aria-hidden");
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
it("reaches its actions with Tab, after the input", async () => {
const user = userEvent.setup();
render(Input, {
attrs: { "aria-label": "Search" },
props: { type: "search" },
slots: { actions: () => h("button", { type: "button" }, "Clear search") },
});
await user.tab();
expect(screen.getByRole("searchbox", { name: "Search" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Clear search" })).toHaveFocus();
});
it("focuses the input when its icons or padding are pressed, without a blur", async () => {
const user = userEvent.setup();
const onBlur = vi.fn();
const { container } = render(Input, {
attrs: { "aria-label": "Search", onBlur },
props: { type: "search" },
slots: { leading: icon },
});
const input = screen.getByRole("searchbox", { name: "Search" });
await user.click(container.querySelector('[data-slot="leading"]')!);
expect(input).toHaveFocus();
await user.click(container.querySelector(".input")!);
expect(input).toHaveFocus();
expect(onBlur).not.toHaveBeenCalled();
});
it("mirrors its size as a data attribute", () => {
const { container } = render(Input, { attrs: { "aria-label": "Search" }, props: { size: "lg" } });
expect(container.firstElementChild).toHaveAttribute("data-size", "lg");
});
it("renders the same markup every time, with its parts in a fixed order, across states", async () => {
const options = {
attrs: { "aria-label": "Price, in US dollars" },
slots: { leading: () => "$", trailing: () => "USD", actions: () => h("button", "Clear") },
};
const first = render(Input, options).container.innerHTML;
const { container, rerender } = render(Input, options);
expect(container.innerHTML).toBe(first);
const root = container.firstElementChild!;
const parts = () => [...root.children].map((part) => part.getAttribute("data-slot"));
expect(parts()).toEqual(["leading", "control", "trailing", "actions"]);
const input = root.querySelector("input");
await rerender({ disabled: true });
expect(container.firstElementChild).toBe(root);
expect(root.querySelector("input")).toBe(input);
expect(parts()).toEqual(["leading", "control", "trailing", "actions"]);
});
it("warns in development when it has no label", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
render(Input);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no accessible name"), expect.anything());
warn.mockClear();
render({ render: () => [label("name", "Full name"), h(Input, { id: "name" })] });
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
});
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 address | Input |
| Type a single line of digits, like a security code | Input, with inputmode="numeric" |
| Write something longer, like a comment or a message | Textarea |
| Pick one option out of a few, or out of many | Radio Group, or Select |
| Type to filter a list of suggestions | Combobox |
| Pick a date | Date Picker |
| Turn something on or off | Checkbox, or Switch |
All of them are on our Roadmap, and they'll share the Input's sizes, states and rules for labels and errors.
required on the inputs that are, which tells screen readers.:invalid also matches before people have typed anything. Keep the attributes that describe the value, like required and type="email", put novalidate on the form, and show your own error messages, as under Invalid.<input>, so even before the page hydrates, people can type into it, autofill fills it in, and its form sends its value. Only the parts that need JavaScript have to wait for it, which are your bound value, your validation, and focusing the input when people press one of its icons.Sources
- HTML Standard: the input element, implicit submission and autofill
- Web Content Accessibility Guidelines (WCAG) 2.2, and its list of input purposes
- Understanding SC 1.4.11: Non-text Contrast, for the borders of text inputs
- Understanding SC 3.3.8: Accessible Authentication (Minimum), for pasting and password managers
- WAI Forms Tutorial, for labels, instructions and validation
- GOV.UK Design System: Text input and Error message, for widths, prefixes and error messages
- GOV.UK: Why the GOV.UK Design System team changed the input type for numbers
- Primer: Text input, for leading and trailing visuals and actions
- Base UI: Input and Field, for keeping the control apart from its label