Developer Experience (DX)
Next, let's look at how developers use the Checkbox. 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 { Checkbox } from "@/components/Checkbox";
export function Updates() {
const [updates, setUpdates] = useState(false);
return (
<Checkbox checked={updates} onChange={(event) => setUpdates(event.target.checked)}>
Email me product updates
</Checkbox>
);
}
Consistent
Once you've learned the Button's and the Input's APIs, you already know most of the Checkbox'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 text everywhere |
disabled | Prop | The component can't be used |
readonly | Prop | It can be focused, and its form sends it, but it can't be changed |
checked | Prop | Whether it's checked, which you bind the way your framework binds a checkbox's |
indeterminate | Prop | Neither checked nor unchecked, as the native property means it |
| Default slot | Slot | The label, as it is for the Button |
We recommend size="md" as the default. The Checkbox also leaves a few props out, on purpose:
- There's no
labelprop. The label is the content between its tags, which can hold more than plain text, like a word in bold, and which the<label>wraps either way. - There's no
colororvariant. A checked box has no intent of its own, and its one other coloured state, invalid, comes fromaria-invalid, so its colour and its state can't disagree. To match your brand, set--checkbox--box--fillfrom a class, as under Tokens. - There's no
hintorerror. Each of them is an element of its own, linked by ID, as on the Input, so the Checkbox fits any layout. - There's no
optionsor array value. A list of options is a group, which the Checkbox Group builds out of Checkboxes, with a<fieldset>and a<legend>.
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 size="medium", which isn't one of the sizes.
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | How big the box and its label are |
checked | boolean | false | Whether it's checked, when you bind it. Without it, the box keeps its own |
indeterminate | boolean | false | Shows a dash, and makes screen readers announce it as mixed |
disabled | boolean | false | Blocks focus, toggling and submission |
readonly | boolean | false | Blocks toggling, while it stays focusable and its form sends it |
| Event | Payload | Description |
|---|---|---|
change | Event | Fires when people check or uncheck it, with a click, a tap or Space |
It comes straight from the <input>, like every other native event, such as focus and blur, and a binding to checked listens to it for you. Read the new state from event.target.checked.
| Slot | Description |
|---|---|
| Default | The label, which names the checkbox, and which a press on toggles the box, unless it's read-only |
We also export CheckboxProps, which comes in handy for wrappers and data-driven forms:
import type { CheckboxProps } from "@/components/Checkbox";
const preferences: (CheckboxProps & { name: string; label: string })[] = [
{ name: "updates", label: "Email me product updates" },
{ name: "texts", label: "Text me about my orders", disabled: true },
];
Composable
The Checkbox renders a <label> around the <input>, so it splits what you pass between them. Classes and styles go on the <label>, so a class can set its variables. Everything else lands on the <input>, including id, name, value, required, ARIA attributes and event listeners. A ref to the Checkbox reaches the <input> too, since that's what form libraries and focus() need.
Forms
The <input> is a real form control, so the Checkbox works with your forms however you build them. You can bind checked, as above, or leave it to the browser and read it from the form when it's sent. A checked box sends its name with its value, or with on when it has no value, and an unchecked box sends nothing at all, so check whether its name is there, rather than for a value of false. That also means the form works before your JavaScript loads.
import type { FormEvent } from "react";
export function Preferences() {
function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
// An unchecked box sends nothing, so it's checked when its name is there.
api.savePreferences({ updates: data.has("updates") });
}
return (
<form onSubmit={save}>
<Checkbox name="updates" value="yes" defaultChecked>Email me product updates</Checkbox>
<Button type="submit" color="primary">Save preferences</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 Checkbox puts them all on the <input>. Keep in mind that a server reading the form sees no field at all for an unchecked box, so treat a missing name as "no".
With other components
- The Checkbox Group puts Checkboxes in a
<fieldset>with a<legend>, gives them onename, binds each one'scheckedto a list of values, and works out its select-all box'sindeterminatefor you. A<fieldset disabled>already disables every Checkbox inside it. - The Field, on our Roadmap, will render a checkbox's hint and error message, and set its
aria-describedbyandaria-invalid, as it will for the Input. - A Button that submits the form goes after the checkboxes, and takes the same
size, so their text lines up.
Controllable
The Checkbox doesn't keep any state you can't reach. Its checked state is yours to bind, or to leave to the browser, and every other state is a prop or an attribute that you control:
You'll get our updates
const [updates, setUpdates] = useState(true);
<Checkbox checked={updates} onChange={(event) => setUpdates(event.target.checked)}>Email me updates</Checkbox>
<p>You'll {updates ? "get" : "miss"} our updates</p>
<Checkbox name="remember">Remember me</Checkbox>
| State | How you control it |
|---|---|
| Checked | checked, bound to your state, or left to the browser |
| Indeterminate | indeterminate, worked out from the options it stands for |
| Disabled | disabled, or a <fieldset disabled> around it |
| Read-only | readonly, which sets aria-readonly on the <input> and cancels its clicks |
| Required | required, which lands on the <input> |
| Invalid | aria-invalid, set while its error message shows (see Required and invalid) |
| Focus | A ref to its <input>, and focus() |
When you bind checked, the box always shows the state you hold. If a change doesn't make it into your state, like an option people aren't allowed to uncheck, the box goes back to that state as soon as it renders, rather than showing a tick that your state doesn't have. When you don't bind it, the box keeps its own state, and starts out checked if you say so.
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
checkbox/shared-vocabulary | Must | Component | It uses the shared names: size, disabled, readonly, checked, indeterminate, the default slot for its label and change | Open Components | Review |
checkbox/typed | Must | Component | Props, events and slots are typed, with unions rather than string | Open Components | Type check |
checkbox/control-attributes | Must | Component | Attributes, listeners and refs land on the <input>, while classes and styles land on the <label> | Open Components | Unit test |
checkbox/form-control | Must | Component | Its form sends its name with its value while it's checked, and nothing while it's unchecked, whether checked is bound or not | HTML | Unit test |
checkbox/controllable | Should | Component | checked can be bound or left to the browser, a bound box always shows the state you hold, and every other state is a prop or an attribute | Open Components | Unit test |
FAQ
<label> does that without an ID, and keeps the text and the box from drifting apart. An input's label, on the other hand, goes above it, below it or beside it depending on the layout, so the Input leaves it out. Hints and error messages stay outside the Checkbox, as they do for the Input, since text inside the <label> would become part of the name.value, or it isn't. It's a common source of bugs when the server expects a false. Treat a missing name as "no" when you read the form, and if your server can't, send the value from your own state, as you would with a bound checked.<input type="checkbox"> inside its <label>, so even before the page hydrates, people can check it, a press on the label toggles it, and its form sends it. Only two things have to wait. The indeterminate state is a property that only JavaScript can set, and a read-only box needs a script to cancel its clicks, so until then people can toggle it. If a read-only value matters, check on the server that it hasn't changed, as you would for any value a form sends.