Experiments

Checkbox

A checkbox whose whole container is the hit area, in three layouts from one compound component

October 19, 2026

Variant
Size

Click anywhere on an item

A checkbox where the whole container toggles it, not just the 20px square and the words beside it. In most checkboxes the gap between the box and the label is dead, and so is the padding around them, so a click that lands a few pixels off does nothing. I first saw it done properly in Rauno's component library1, and once you notice it, every other checkbox feels like it is ignoring you.

I built it together with an AI agent. I had only seen Rauno's version, so I explained to the agent in words what I wanted.

What went wrong first

The strike on a wrapped label was not showing correctly, and I told the agent. The strike is an inline background rather than a line across the label's box. An inline background lays out as one strip broken across the lines of text, so a label that wraps is struck through line by line. That is not what we got at first. The label was a flex item, and flex items are blockified, so the background painted once across the whole two-line block. The strike ran through the gap between the lines instead of through either of them. Wrapping the text in one more span keeps it inline, and fixes it.

How it works

There is no click handler doing this. The container is a <label> that wraps a visually hidden <input type="checkbox">2, and the browser does the rest: a click anywhere inside a label goes to its input, Space toggles it from the keyboard, and a screen reader announces a real checkbox with a real name. Every pixel of the container is hit area, and there is no JavaScript deciding whether a click counted.

Three layouts, one component

It started as three separate prototypes - a lone inline checkbox, full-width to-do rows, and bordered option cards - and I liked all three too much to pick one. So I decided on one compound component instead: a Checkbox is a single item, and a Checkbox.List decides how its items are laid out.

the three variants

// inline: one checkbox on its own, hugging its label, with no List needed
<Checkbox name="terms" required>
I agree to the terms and conditions
</Checkbox>
// row: full-width to-do rows, with a trailing detail
<Checkbox.List variant="row" strikethrough>
<Checkbox>
<Checkbox.Label>Buy groceries</Checkbox.Label>
<Checkbox.Meta>Today</Checkbox.Meta>
</Checkbox>
</Checkbox.List>
// card: settings with a description, the box after the title
<Checkbox.List variant="card">
<Checkbox name="digest" defaultChecked>
<Checkbox.Label>Weekly digest</Checkbox.Label>
<Checkbox.Description>A summary every Monday.</Checkbox.Description>
</Checkbox>
</Checkbox.List>

A Checkbox with plain text as its children wraps it in a Checkbox.Label for you, so the simple case stays a one-liner. Anything richer is composed from the parts: Checkbox.Label for the text that strikes through, Checkbox.Description for the second line a card needs, and Checkbox.Meta for a trailing detail like a due date. The card puts the box last on purpose - a setting reads its title first and its state second.

The props

the API

<Checkbox.List
variant="inline" // "inline" | "row" | "card"
strikethrough={false} // strike checked labels through
animated={true} // turn the motion off for long lists
size="md" // "sm" | "md" | "lg": box, gap and text together
error={message} // one message under the whole list
// ...any <fieldset> prop: aria-label, className, a <legend> child
/>
<Checkbox
checked={checked} // controlled
onCheckedChange={setChecked}
defaultChecked={false} // or uncontrolled
strikethrough={true} // overrides the List, for one item
animated={false} // overrides the List, for one item
size="lg" // overrides the List, for one item
required // blocks the form until checked, and adds a red *
indeterminate // mixed: filled with a dash, announced as mixed
error={message} // a message under a lone checkbox
className="..." style={{}} // go on the container
// ...any <input> prop: name, value, disabled, required, ref, aria-*
/>

variant, size, strikethrough and animated live on the List and reach every item through context, so a list is configured once rather than per row. An item can still override size, strikethrough or animated for itself. A Checkbox on its own, outside any List, behaves as if it sat in a default one: inline, medium, no strike, animated.

It works controlled or uncontrolled, the way an <input> does: pass checked and onCheckedChange to own the state, or defaultChecked to let the component keep it. Everything else goes to the real input underneath - name and value so it submits with a form, disabled, ref for focus management, aria-* - while className goes to the container, which is the thing you actually want to style. The List renders a <fieldset>3, the element that already means "a group of controls", so it can take a <legend> or an aria-label.

Forms and validation

required goes to the input like any other attribute, so the browser blocks the submit and points at the item itself, and it adds a red asterisk after the label - hidden from screen readers, which announce the input's own required state instead. An uncontrolled checkbox follows its form the rest of the way too: a reset puts the box back to where it started, which a checkbox keeping its own state in React misses unless it listens for it, and a disabled List greys out every item because the styling reads the input's :disabled rather than a prop. A native onChange runs alongside onCheckedChange instead of replacing it, which is exactly what a form library's register hands over. When the browser refuses a submit, the unchecked required items turn red - the outline, a card's border and the asterisk - through :user-invalid, which only matches once somebody has actually tried, so a form never opens already shouting; an aria-invalid from a form library's own validation does the same.

The error message

error is where the message goes, and there is exactly one place for it: under a lone checkbox, lined up with the left edge of its box, or under a whole List, so a card on its own and a list of ten both say it once, at the bottom. Passing the prop at all reserves the message's line, even while it is undefined, which is precisely what error={errors.terms?.message} from a form library passes, so the message arriving never pushes the rest of the form down. With a message, the unchecked items turn invalid and every item points at the message with aria-describedby, and the line is a polite live region, so it is heard as well as seen.

The indeterminate state

indeterminate is the state between checked and not, for a "select all" over a partial selection: the box fills with a dash instead of a tick, and because it is the input's own indeterminate property, a screen reader announces it as mixed. It is a DOM property with no attribute, and the browser clears it on every click, so the component sets it from an effect and puts it back after each click until its owner passes a new value.

What a screen reader hears

The name a screen reader reads is the Checkbox.Label alone, through aria-labelledby, and a Checkbox.Description is linked with aria-describedby. Left to the <label>, the name was every word inside it, so a card read out its whole description and a row read out its due date as part of what the checkbox was called.

Strikethrough and size

strikethrough is off by default because most checkboxes are choices, not tasks, and crossing out a chosen setting reads as if it were cancelled. A struck-through label also fades to 60%, since both say the same thing - this one is done - while an agreement or a chosen setting does neither.

size moves three things at once, because they only look right together: sm is a 16px box, an 8px gap and 13px text, md is 20, 12 and 15, and lg is 24, 14 and 17. Past the presets, the box and the gap are custom properties, below.

The custom properties

Everything a prop should not have to carry is a custom property instead, eight of them. They inherit, so setting one on the List, on a single item, or on a parent that knows nothing about checkboxes reaches every box below it, and each has a default that works in both themes without being set at all.

the custom properties

.checklist {
/* the fill behind the tick; the site accent by default */
--checkbox-color: var(--color-accent);
/* the box, and the space before the label; from the size preset */
--checkbox-size: 20px;
--checkbox-gap: 12px;
/* hover fill of inline and row items; a light neutral per theme */
--checkbox-hover: oklch(0.922 0 0 / 0.45);
/* the panel behind a row list; a neutral per theme by default */
--checkbox-list-bg: var(--color-neutral-100);
/* the keyboard focus ring; --checkbox-color and 1.5px by default */
--checkbox-ring: var(--checkbox-color);
--checkbox-ring-width: 1.5px;
/* the outline, card border and asterisk of an invalid item */
--checkbox-invalid: var(--color-red-600);
}

In Tailwind they are one class each, [--checkbox-size:24px] in a className, which is why there is no colour prop at all, and why size only picks between presets rather than taking a number. The box stays centred on the first line of the label whatever --checkbox-size is, with margin-top: calc((1lh - var(--checkbox-size)) / 2), which is also what keeps it level with a label that wraps.

The check

The box fills on a light spring - 0.35s at 0.3 bounce, from 60% scale - and the tick draws itself 0.1s later, from the short arm to the long one, so it writes left to right the way you would draw it. Unchecking runs both back faster, in 0.15s, because leaving should never take longer than arriving.

The first version traced the outline instead: a stroke that ran all the way round the box and then turned into the tick. It is lovely once. On the fourth item in a row it is a lot of motion for a 20px square, and on a long list it is worse, which is what animated={false} is for. That prop was my idea: in a list you don't want this kind of animation. With it off, the box, the strike and the fade all land instantly, the same as they do if you prefer reduced motion. Hover follows the same thinking without a prop: a lone checkbox eases its hover in over 150ms, but an item inside a List switches instantly, because the cursor crosses item after item and a fade on each one trails along behind it.

What I want to do with it

I would like to turn this into a package. I feel every checkbox should work like this one.

References

  1. 1.Component library · Devouring Details · Rauno Freiberg
  2. 2.<label> · HTML · MDN
  3. 3.<fieldset> · HTML · MDN

Would you like to see more of this?

One email each time a new experiment is published.

Next experiment

Progress Rings

Concentric progress rings drawn with CSS gradients and masks, each one driven by a single number