Skip to content

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.

Change the props and copy the code.

color
size
shape
label
state
Generated code
TSX
<Checkbox
  label="Email me about new releases"
/>
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.

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)}
/>

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.

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.

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.

About once a month.

Code
<Checkbox label="Marketing emails" helper="About once a month." defaultChecked />
<Checkbox
label="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.

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.

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 in states.css.
  • Naming comes from label. Without one, pass aria-label — an unlabeled checkbox announces nothing.
  • The mixed state announces itself. A native checkbox with indeterminate set is exposed as mixed, with nothing layered on top.
  • Windows high contrast repaints the box from system keywords and the tick as CanvasText, or GrayText when 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 -content mark, so the tick clears 4.5:1 in every theme that ships.

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.

Don't

Dark mode takes effect the instant it is flipped. A setting that acts now belongs to Toggle.

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.