Building a Checkbox Component — Custom Styles Without Losing Native Behaviour

Custom checkboxes are where most UI libraries make their first real accessibility mistake. They replace the native input with a styled div, wire up click handlers, and forget about keyboard navigation, screen readers, and the indeterminate state. We're keeping the native input and styling around it — all the customisation, none of the accessibility regressions.
The checkbox is deceptively simple. It's a binary input — checked or unchecked. But the moment you try to style it, you hit a wall: native checkboxes are notoriously difficult to customise across browsers. The instinct is to hide the native input and build something custom from scratch.
That instinct is wrong — or at least, it's the harder path with more ways to go wrong. We're going to style the native input using CSS, keep all the browser behaviour we get for free, and add the one thing CSS can't give us: the indeterminate state via a ref.
Native vs custom — making the call
There are two approaches to a custom checkbox:
Approach 1 — Style the native input. Hide the default appearance with appearance-none, then use CSS to draw the box, the checkmark, and the focus ring. The native <input type="checkbox"> stays in the DOM. Keyboard navigation, form submission, label association, and screen reader announcements all work without any extra work.
Approach 2 — Fully custom with ARIA. Replace the input entirely with a <div role="checkbox">. You own every behaviour — keyboard events, focus management, checked state, form integration via a hidden input. More control, significantly more code, more ways to introduce accessibility bugs.
For a library, Approach 1 is the right default. It gives consumers everything they expect from a checkbox without requiring them to trust that your ARIA implementation is correct. We use Approach 2 only when the design demands something the native element genuinely can't provide.
The four files
Checkbox.variants.ts
The checkmark itself is drawn with CSS using a background image — a white SVG checkmark encoded as a data URI. Add this to your tokens.css under @layer base:
The indeterminate state gets a dash instead of a checkmark — the correct visual for "some but not all items selected."
Checkbox.types.ts
We omit type from the native props — it's always "checkbox" and consumers shouldn't be able to change it. indeterminate is our custom prop because the indeterminate state cannot be set via HTML attributes — it requires a ref and a DOM property assignment.
Checkbox.tsx
What each decision is doing
indeterminate via useEffect and a ref — the indeterminate state is not an HTML attribute. You cannot set it with <input indeterminate={true} /> — React will ignore it. The only way to set it is via the DOM property element.indeterminate = true. We do this in a useEffect that runs whenever the indeterminate prop changes. This is one of the few legitimate cases where direct DOM manipulation in an effect is the correct solution.
mergedRef — same pattern as Textarea. We need innerRef for the indeterminate DOM assignment and we need to forward the external ref to consumers. The merged ref callback handles both.
Inline label with htmlFor — the label is optional but when provided it's rendered inside the component, connected to the input via htmlFor and id. This means clicking the label toggles the checkbox — the native browser behaviour you get for free with a proper label association. A consumer who needs more complex label content can omit the label prop and render their own <Label> component alongside.
checkboxLabelVariants receives disabled — when the checkbox is disabled, the label needs to look disabled too. Passing disabled as a variant prop to the label CVA definition keeps the styling in one place rather than writing conditional classNames in JSX.
appearance-none with CSS background image — appearance-none strips the native checkbox appearance entirely. We then draw the checkmark as a white SVG via a CSS background image on the :checked pseudo-class. This approach works in all modern browsers, requires no JavaScript for the visual, and the SVG scales cleanly at any size.
The component index
src/components/Checkbox/index.ts:
Update the root entry point
Add to src/index.ts:
Usage
The decision rule
Use the indeterminate prop whenever a checkbox represents a group where some but not all items are selected — a "select all" row in a table, a parent node in a tree. Never leave it visually unchecked when items below it are partially selected. The indeterminate state exists precisely for this case and screen readers announce it correctly when the DOM property is set.
Up next: Building a Radio Group component with keyboard navigation — Radio shares the native-input approach from Checkbox, but introduces the compound component pattern and arrow-key navigation between options.
Related: Building a Label component that actually connects to its field — for cases where the Checkbox's inline label prop isn't enough, the Label component handles required indicators, optional markers, and disabled states that complete the field pattern.
Muhammad Athar is the founder of DesignDev.io and the engineer behind everything you read here. He writes about the decisions behind building real products — the components, the architecture, and the tradeoffs that don't make it into the tutorial.
Photo by Jakub Żerdzicki on Unsplash

