Checkbox
Checkbox is a single native input and nothing else — no wrapper span, no hidden field, no SVG. The tick is a pseudo-element, the mixed state is the browser's own, and the label slots match Input.
- Stable
- React 19
- One DOM node
- Native mixed state
Code
<Checkbox label="Email me about new releases" defaultChecked /><div class="wayra-label" data-placement="right" data-size="md"><input type="checkbox" class="wayra-checkbox" id="usage-cb" checked /><label class="wayra-label-text" for="usage-cb">Email me about new releases</label></div>Pass label and the component wraps the input in the label markup. Leave it off and you
get the bare <input>.
type="checkbox" is pinned, not defaulted. The native default is text, and a checkbox
you could type into is not a variant anyone wants.
The box renders with appearance: none. That removes the browser’s own control and lets
the input draw its tick as an ::after pseudo-element.
Playground
Section titled “Playground”Change the props and copy the code.
Generated code
<Checkbox
label="Email me about new releases"
/>Colors
Section titled “Colors”Code
<Checkbox color="primary" aria-label="primary" defaultChecked /><Checkbox color="secondary" aria-label="secondary" defaultChecked /><Checkbox color="accent" aria-label="accent" defaultChecked /><Checkbox color="neutral" aria-label="neutral" defaultChecked /><Checkbox color="info" aria-label="info" defaultChecked /><Checkbox color="success" aria-label="success" defaultChecked /><Checkbox color="warning" aria-label="warning" defaultChecked /><Checkbox color="error" aria-label="error" defaultChecked /><input type="checkbox" class="wayra-checkbox" data-color="primary" checked aria-label="primary" /><input type="checkbox" class="wayra-checkbox" data-color="secondary" checked aria-label="secondary" /><input type="checkbox" class="wayra-checkbox" data-color="accent" checked aria-label="accent" /><input type="checkbox" class="wayra-checkbox" data-color="neutral" checked aria-label="neutral" /><input type="checkbox" class="wayra-checkbox" data-color="info" checked aria-label="info" /><input type="checkbox" class="wayra-checkbox" data-color="success" checked aria-label="success" /><input type="checkbox" class="wayra-checkbox" data-color="warning" checked aria-label="warning" /><input type="checkbox" class="wayra-checkbox" data-color="error" checked aria-label="error" />A checked box is the role color with its own -content mark on top. That pair clears 4.5:1
in every theme, by the same two-token contract the rest of the library uses.
The native look — a white box with a colored tick — was considered and dropped. It would
need the role to stay readable inside the control against base-100, and the token pairs
were never tuned for that.
Geometry comes off --wayra-size-selector, not the field unit. A theme can shrink its
text fields and leave its selection controls alone.
Code
<Checkbox size="xs" aria-label="xs" defaultChecked /><Checkbox size="sm" aria-label="sm" defaultChecked /><Checkbox size="md" aria-label="md" defaultChecked /><Checkbox size="lg" aria-label="lg" defaultChecked /><Checkbox size="xl" aria-label="xl" defaultChecked /><input type="checkbox" class="wayra-checkbox" data-size="xs" checked aria-label="xs" /><input type="checkbox" class="wayra-checkbox" data-size="sm" checked aria-label="sm" /><input type="checkbox" class="wayra-checkbox" data-size="md" checked aria-label="md" /><input type="checkbox" class="wayra-checkbox" data-size="lg" checked aria-label="lg" /><input type="checkbox" class="wayra-checkbox" data-size="xl" checked aria-label="xl" />| Size | Box | Multiplier |
|---|---|---|
xs |
16px | 4 |
sm |
18px | 4.5 |
md |
20px | 5 |
lg |
24px | 6 |
xl |
28px | 7 |
The small end uses half steps on purpose: 4 → 5 is a 25% jump on a box that size. At the
default tokens an md box is exactly half the height of an md field, so a form row that
mixes a checkbox and an input lines up.
Code
<Checkbox size="lg" aria-label="rounded" defaultChecked /><Checkbox shape="rectangle" size="lg" aria-label="rectangle" defaultChecked /><Checkbox shape="circle" size="lg" aria-label="circle" defaultChecked /><input type="checkbox" class="wayra-checkbox" data-shape="rounded" data-size="lg" checked aria-label="rounded" /><input type="checkbox" class="wayra-checkbox" data-shape="rectangle" data-size="lg" checked aria-label="rectangle" /><input type="checkbox" class="wayra-checkbox" data-shape="circle" data-size="lg" checked aria-label="circle" />These are radius words, not Button’s aspect-ratio words. Button uses square and circle
to mean “make it 1:1”; the box already is, so here circle means a full radius and there
is no square at all.
rounded follows the theme’s --wayra-radius-selector. The other two are absolute, so a
theme that squares its badges cannot flatten the three values into one.
The mixed state
Section titled “The mixed state”parent: mixed
Code
import { useState } from "react";import { Checkbox } from "wayra-ui";
const TOPICS = ["Releases", "Security advisories", "Newsletter"];
export function IndeterminateDemo() { const [checked, setChecked] = useState([true, false, false]);
const all = checked.every(Boolean); const some = checked.some(Boolean);
return ( <div className="hu-demo-col"> <div className="hu-tree"> <Checkbox label="All notifications" checked={all} // Visual only, per the HTML spec: clicking clears the mixed state and // the box becomes plainly checked or unchecked. Owning it in the // state that feeds the prop is what keeps the parent honest. indeterminate={some && !all} onChange={(event) => setChecked(TOPICS.map(() => event.target.checked)) } />
<div className="hu-demo-nest"> {TOPICS.map((topic, index) => ( <Checkbox key={topic} label={topic} checked={checked[index]} onChange={(event) => setChecked( checked.map((value, i) => i === index ? event.target.checked : value, ), ) } /> ))} </div> </div>
<p className="hu-readout"> parent: {all ? "checked" : some ? "mixed" : "unchecked"} </p> </div> );}indeterminate is the one thing here with no HTML attribute and no React prop. It exists
only as a DOM property, so the component holds the node in a ref and writes the property in
an effect. The stylesheet then keys on :indeterminate itself. There is no
data-indeterminate and no ARIA, because a native checkbox in that state already exposes
mixed to assistive technology.
The state is visual only, per the HTML spec. Clicking clears it and the box becomes plainly checked or unchecked. Own that transition in the state that feeds the prop:
<Checkbox label="Select all" checked={all} indeterminate={some && !all} onChange={(e) => setAll(e.target.checked)}/>Groups
Section titled “Groups”There is no CheckboxGroup. Several independent booleans are several independent controls.
What they need is an accessible grouping, and the platform already has one:
<fieldset> <legend>Notify me about</legend> <Checkbox name="notify" value="releases" label="Releases" /> <Checkbox name="notify" value="security" label="Security advisories" /></fieldset>A group-level error belongs on the fieldset, not on one option. Put the message there and
point at it with aria-describedby. Inside a Form the message itself can come from
the same hook Radio documents — and to keep the boxes from each
repeating it, pass every option error="": an error that resolves to an empty string
renders nothing and marks nothing, so only the group speaks.
Nothing to configure. name, ref, checked, defaultChecked and onChange all come
from ComponentProps<"input">, so a native form, TanStack Form or Formik work with no
adapter. The integration point is error plus aria-invalid.
react-hook-form is built in, exactly as it is on
Input. Inside Form — which provides the useForm instance
you own — a Checkbox registers itself on name alone: no register spread, the validation
message arrives in the hint slot, and the box disables itself while the form submits. An
explicit error or disabled still wins, and noRhf turns the whole thing off for one
instance. Outside a Form, spread register("terms") onto the box and it binds the same
way.
const schema = z.object({ terms: z.boolean().refine((accepted) => accepted, "Accept the terms first."),});
const form = useForm({ resolver: zodResolver(schema) });
<Form form={form} onSubmit={save}> <Checkbox name="terms" label="Accept the terms" /> <Button type="submit">Save</Button></Form>Unlike Radio, the message lands on the control. A radio option is never alone, so its error belongs to the group; a checkbox routinely is — “accept the terms” is the commonest field in any form — so its error is its own.
What lands in the payload
Section titled “What lands in the payload”Three shapes, and all three are react-hook-form’s rules for any checkbox rather than this component’s:
| Setup | Value |
|---|---|
One box, no value |
true / false |
One box with value="x" |
"x" checked, false unchecked |
Several boxes sharing a name, each with a value |
the array of the checked ones |
The group shape needs defaultValues: { toppings: [] }. That empty array is what tells
react-hook-form the field is a group; without it a single checked box posts its bare value
instead of a one-item array.
indeterminate never reaches the form. It is a DOM property rather than an attribute, so
react-hook-form only ever reads checked — a mixed box reports as unchecked until someone
clicks it. If you need a real third state in the payload, put it in defaultValues
yourself.
What’s not included
Section titled “What’s not included”Checkbox takes no variant prop, and that is not a gap. The control already is two
variants: unchecked it is an outline control where the role draws the edge, checked it is a
filled one where the role fills the box. Stacking outline, filled, soft, and ghost
on top would double every state with nothing new to say.
Code
<Checkbox aria-label="Unchecked" /><Checkbox aria-label="Checked" defaultChecked /><input type="checkbox" class="wayra-checkbox" aria-label="Unchecked example" /><input type="checkbox" class="wayra-checkbox" checked aria-label="Checked example" />The hover mixes follow from that. An unchecked box tints 12% toward its color, because
there is a surface to tint. A checked one darkens 88% toward black, the same step Button’s
filled hover uses.
A press answers on pointer-down, on the box or on its label: the box gives to 0.94, and an unchecked one tints one press step past its hover, to 20%. A checked one keeps the hover’s darken step, because one step further toward black would take the tick under 3:1.
Label, helper, and error
Section titled “Label, helper, and error”About once a month.
You have to accept to continue.
Code
<Checkbox label="Marketing emails" helper="About once a month." defaultChecked />
<Checkboxlabel="Accept the terms"labelPlacement="left"error="You have to accept to continue."/><div class="wayra-label" data-placement="right" data-size="md"><input type="checkbox" class="wayra-checkbox" id="cb-helper" checked aria-describedby="cb-helper-hint" /><label class="wayra-label-text" for="cb-helper">Marketing emails</label><p class="wayra-label-hint" id="cb-helper-hint">About once a month.</p></div><div class="wayra-label" data-placement="left" data-size="md"><input type="checkbox" class="wayra-checkbox" id="cb-error" aria-invalid="true" aria-describedby="cb-error-hint" /><label class="wayra-label-text" for="cb-error">Accept the terms</label><p class="wayra-label-hint" id="cb-error-hint" role="alert">You have to accept to continue.</p></div>This is the wrapper Input documents: a real <label for>, one hint
slot, role="alert" when it carries the error, and the hint color keyed on the control’s
own aria-invalid or :user-invalid. Because for and id already make the text toggle
the box, the wrapper gives the label a pointer cursor.
labelPlacement is right (default) or left, not the field set. A checkbox never
matches :placeholder-shown, so floating has nothing to key on, and top over a small
square reads as a heading over nothing.
API reference
Section titled “API reference”CheckboxProps extends ComponentProps<"input"> minus type, size, and color.
| Prop | Type | Default | Description |
|---|---|---|---|
color |
WayraColor |
"primary" |
The edge unchecked, the fill checked. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Off --wayra-size-selector. |
shape |
"rounded" | "rectangle" | "circle" |
"rounded" |
Radius words, not aspect ratios. |
indeterminate |
boolean |
false |
The mixed state. Visual only, per the spec. |
label |
ReactNode |
— | Wraps the box in the label contract. |
labelPlacement |
"right" | "left" |
"right" |
No top, no floating. |
helper |
ReactNode |
— | Persistent hint, wired into aria-describedby. |
error |
ReactNode |
— | Takes the hint slot, adds role="alert", sets aria-invalid. |
unstyled |
boolean |
false |
Drops the classes, keeps the data attributes. |
checked, defaultChecked, name, value, onChange, required, and ref are the
native props, typed. There is no width: a checkbox is intrinsically sized, so layout
stays your business.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Tab |
Moves focus. Each checkbox is its own tab stop. |
Space |
Toggles. Native — there is no key handler in the component. |
- Focus uses
:focus-visible, so a mouse click leaves no ring. It is the same rule Button follows, drawn once instates.css. - Naming comes from
label. Without one, passaria-label— an unlabeled checkbox announces nothing. - The mixed state announces itself. A native checkbox with
indeterminateset is exposed asmixed, with nothing layered on top. - Windows high contrast repaints the box from system keywords and the tick as
CanvasText, orGrayTextwhen disabled. Otherwise a checked box would be a solid system fill with the mark lost inside it. - Reduced motion drops the mark’s scale-in and the box’s give under a press. The mark still appears and a press still tints; only the motion goes.
- Disabled fades the box, its label, and its hint by the same amount.
- Contrast: the checked box pairs the role with its own
-contentmark, so the tick clears 4.5:1 in every theme that ships.
Do and don’t
Section titled “Do and don’t”Do
Label the checkbox with what checking it does. The words carry the meaning; the box only carries the state.
Don't
One word out of context. Read on its own by a screen reader, “Remember” answers nothing.
Do
Use a checkbox for a value that is submitted later with the rest of the form.
Do
Square boxes in a list of independent options. The shape says the user can pick any number of them.
Don't
shape set to circle on a list of checkboxes. They read as one exclusive choice and behave like several.