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() |
DX Checklist
| 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 |
FAQ
<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.