Loading…
Loading…
Allows users to select one or more options from a set of choices.
The Checkbox is a form control that allows users to select one or more options from a set — or to toggle a single binary choice. It's one of the most fundamental input elements in web interfaces, appearing in forms, settings pages, filter panels, Table row selection, and consent flows.
Unlike a Radio Button (which enforces a single selection from a group), checkboxes are independent — each checkbox operates on its own, and selecting one doesn't deselect another. This independence makes checkboxes the right choice when users need to pick zero, one, or many options from a list.
The checkbox also supports a unique third state: indeterminate. This "partially checked" state appears when a parent checkbox represents a group where only some children are selected — common in tree views, bulk selection interfaces, and "select all" patterns.
When to use a Checkbox:
When NOT to use a Checkbox:
The Switch vs. Checkbox decision trips up many designers: if the change takes effect immediately (dark mode toggle, notification mute), use a Switch. If the change is deferred until a form is submitted, use a Checkbox.
Validate your check and label colors with the Contrast Checker to ensure the checked state is clearly visible.
| Variant | Description | Use Case |
|---|---|---|
| Standard | Square box with a checkmark when selected | Default for all form contexts |
| Indeterminate | Square box with a horizontal dash (–) | Parent checkbox when some children are selected |
| Card checkbox | Entire card surface acts as the checkbox area | Feature selection, plan comparison, settings groups |
| Chip checkbox | Styled as a selectable Tag or chip | Filter interfaces, multi-select tags |
| Size | Box Dimension | Label Font Size | Use Case |
|---|---|---|---|
| Small | 16×16px | 13px | Dense forms, table rows, compact UIs |
| Medium | 20×20px | 14px | Default for most forms |
| Large | 24×24px | 16px | Settings pages, mobile layouts, accessibility-focused UIs |
| Position | Description |
|---|---|
| Right (default) | Label text to the right of the checkbox. Standard for LTR languages. |
| Left | Label text to the left. Common in right-aligned forms or settings panels. |
| Hidden | Visually hidden label (still accessible via aria-label). For checkboxes in table rows where the column header serves as the label. |
| Property | Type | Default | Description |
|---|---|---|---|
checked | boolean | false | Whether the checkbox is checked (controlled) |
defaultChecked | boolean | false | Initial checked state (uncontrolled) |
indeterminate | boolean | false | Shows the indeterminate (–) state. Note: this is a DOM property, not an HTML attribute — it must be set via JavaScript. |
onChange | (checked: boolean) => void | — | Callback when the checked state changes |
disabled | boolean | false | Prevents interaction |
required | boolean | false | Marks the field as required for form validation |
name | string | — | Form field name for submission |
value | string | 'on' | Value submitted with the form when checked |
label | ReactNode | — | Label content. Can be a string or JSX with links (e.g., "I agree to the terms") |
description | string | — | Helper text below the label |
error | string | — | Error message shown below the checkbox |
size | 'sm' | 'md' | 'lg' | 'md' | Checkbox size variant |
Important: The indeterminate state is visual only — it does not affect the underlying checked value. When a user clicks an indeterminate checkbox, you decide what happens (typically it becomes fully checked). This logic is your responsibility.
| Token Category | Token Example | Checkbox Usage |
|---|---|---|
| Color – Unchecked Border | --color-border-strong | Border of the unchecked box |
| Color – Checked Fill | --color-primary-600 | Background when checked |
| Color – Checkmark | --color-on-primary (white) | The checkmark/dash icon color |
| Color – Hover | --color-primary-700 | Checked hover state |
| Color – Disabled | --color-border-disabled, --color-fill-disabled | Reduced contrast when disabled |
| Color – Error | --color-error-600 | Border color when in error state |
| Color – Focus Ring | --color-focus-ring | Focus indicator color |
| Border Radius | --radius-xs (3px) or --radius-sm (4px) | Checkbox box corners (square-ish, not round — that implies radio) |
| Spacing – Gap | --space-2 (8px) | Gap between checkbox and label |
| Spacing – Group | --space-3 (12px) | Vertical gap between checkboxes in a group |
| Typography | --font-size-sm, --font-weight-normal | Label text |
| Transition | --duration-fast (100ms) | Check animation |
For a full reference on token architecture, see our Design Tokens Complete Guide.
| State | Visual Treatment |
|---|---|
| Unchecked | Empty box with visible border. Border color: --color-border-strong. |
| Checked | Filled box with checkmark (✓). Background: --color-primary-600. Checkmark: white. |
| Indeterminate | Filled box with horizontal dash (–). Same colors as checked. |
| Hover | Border darkens (unchecked) or fill lightens (checked). Subtle background highlight on the label row. |
| Focus | Visible focus ring (2px solid, 2px offset). Must meet 3:1 contrast per WCAG SC 1.4.11. |
| Active / Pressed | Brief scale-down (transform: scale(0.95)) on the box for tactile feedback. |
| Disabled | Reduced opacity (0.4). No hover/focus effects. cursor: not-allowed. |
| Disabled + Checked | Muted fill color. Checkmark visible but low-contrast. |
| Error | Border color switches to --color-error-600. Error message appears below. |
| Required | Small asterisk or "required" indicator near the label. Does not change the checkbox itself. |
A well-crafted check animation adds polish. The checkmark should "draw" in (stroke-dashoffset animation on an SVG path) over 100–150ms. Avoid bouncy or slow animations — the checkbox should feel instant and crisp.
.checkbox-icon path {
stroke-dasharray: 20;
stroke-dashoffset: 20;
transition: stroke-dashoffset 150ms ease-out;
}
.checkbox[aria-checked="true"] .checkbox-icon path {
stroke-dashoffset: 0;
}
| Criterion | Level | Requirement |
|---|---|---|
| SC 1.3.1 Info and Relationships | A | Checkbox must be programmatically associated with its label via <label for="id"> or wrapping <label>. |
| SC 1.4.3 Contrast (Minimum) | AA | Label text: 4.5:1 contrast. Verify with Contrast Checker. |
| SC 1.4.11 Non-text Contrast | AA | Checkbox border and checkmark: 3:1 contrast against adjacent colors. The unchecked border must be clearly visible against the background. |
| SC 2.5.8 Target Size (Minimum) | AA | Minimum 24×24px clickable area (WCAG 2.2). The hit area includes the label — make the <label> clickable. |
| SC 3.3.2 Labels or Instructions | A | Every checkbox must have a visible label. |
| SC 4.1.2 Name, Role, Value | A | Use native <input type="checkbox"> for automatic role, state, and name. Custom checkboxes must implement role="checkbox" with aria-checked. |
<input type="checkbox">): Needs no ARIA — the role and checked state are built in. Just pair it with a <label>.<div role="checkbox">): Must implement role="checkbox", aria-checked="true|false|mixed", tabindex="0", and handle Space key to toggle.aria-checked="mixed" for custom implementations. For native checkboxes, set the .indeterminate DOM property (there's no HTML attribute for it).<fieldset> with a <legend> to group related checkboxes. The legend becomes the group label for screen readers.aria-describedby and use aria-invalid="true".| Key | Action |
|---|---|
| Space | Toggles the checkbox checked state |
| Tab | Moves focus to the next focusable element |
| Shift + Tab | Moves focus to the previous focusable element |
Note: Unlike radio buttons, checkboxes in a group do not use arrow keys for navigation. Each checkbox is independently focusable via Tab. This is an important distinction.
<label> wrapping or for attribute.aria-checked on a native checkbox. Don't — it's redundant and can cause double announcements.<input> with .indeterminate = true announces as "mixed" in most screen readers. Verify this.For more, see our ARIA Attributes Guide and Keyboard Accessibility Guide.
aria-label for screen readers.aria-required on the group<!-- Single Checkbox -->
<div class="checkbox-field">
<input type="checkbox" id="terms" name="terms" required />
<label for="terms">
I agree to the <a href="/terms">terms of service</a>
</label>
</div>
<!-- Checkbox Group -->
<fieldset class="checkbox-group">
<legend>Notification preferences</legend>
<div class="checkbox-field">
<input type="checkbox" id="notif-email" name="notifications" value="email" checked />
<label for="notif-email">Email</label>
</div>
<div class="checkbox-field">
<input type="checkbox" id="notif-sms" name="notifications" value="sms" />
<label for="notif-sms">SMS</label>
</div>
<div class="checkbox-field">
<input type="checkbox" id="notif-push" name="notifications" value="push" checked />
<label for="notif-push">Push notifications</label>
</div>
</fieldset>
<!-- Select All (Indeterminate) -->
<div class="checkbox-field">
<input type="checkbox" id="select-all" aria-label="Select all rows" />
<label for="select-all">Select all</label>
</div>
<script>
// Set indeterminate state (no HTML attribute for this)
const selectAll = document.getElementById("select-all");
selectAll.indeterminate = true;
</script>import { useRef, useEffect, forwardRef, type InputHTMLAttributes } from "react";
interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "onChange"> {
label: string;
description?: string;
error?: string;
indeterminate?: boolean;
checked?: boolean;
onChange?: (checked: boolean) => void;
size?: "sm" | "md" | "lg";
}
const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
label,
description,
error,
indeterminate = false,
checked,
onChange,
size = "md",
id,
disabled,
className = "",
...props
},
ref,
) => {
const internalRef = useRef<HTMLInputElement>(null);
const checkboxRef = (ref as React.RefObject<HTMLInputElement>) || internalRef;
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
const inputId = id || `checkbox-${label.toLowerCase().replace(/\s+/g, "-")}`;
return (
<div className={`checkbox-field checkbox-${size} ${error ? "has-error" : ""} ${className}`}>
<input
ref={checkboxRef}
type="checkbox"
id={inputId}
checked={checked}
disabled={disabled}
aria-invalid={!!error || undefined}
aria-describedby={
[description && `${inputId}-desc`, error && `${inputId}-error`]
.filter(Boolean)
.join(" ") || undefined
}
onChange={(e) => onChange?.(e.target.checked)}
{...props}
/>
<label htmlFor={inputId}>
{label}
{description && (
<span id={`${inputId}-desc`} className="checkbox-description">
{description}
</span>
)}
</label>
{error && (
<span id={`${inputId}-error`} className="checkbox-error" role="alert">
{error}
</span>
)}
</div>
);
},
);
Checkbox.displayName = "Checkbox";
export default Checkbox;
// Usage
<Checkbox
label="Subscribe to newsletter"
description="We send updates once a week."
checked={subscribed}
onChange={setSubscribed}
/>
<Checkbox
label="Select all"
indeterminate={someSelected && !allSelected}
checked={allSelected}
onChange={handleSelectAll}
/>| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Element | Custom <div> with ripple | <button role="checkbox"> via Radix | Primitive <button role="checkbox"> | Custom <span> wrapping <input> |
| Indeterminate | Supported (dash icon) | Supported via data-state="indeterminate" | checked="indeterminate" prop | indeterminate prop |
| Animation | Ripple effect on toggle | CSS transition on check icon | BYO animation | Ant Motion check animation |
| Group component | FormGroup wrapper | Manual with <fieldset> | No group primitive | Checkbox.Group with options array |
| Tri-state handling | Manual | Manual | onCheckedChange(checked: boolean | "indeterminate") | Manual with onChange |
| Error state | Via FormField wrapper | Manual styling | Not built-in | Via Form.Item wrapper |
| Accessibility | Custom ARIA | Radix handles role + aria-checked | Full — role="checkbox", aria-checked, keyboard | Native <input> under the hood |
Radix Checkbox is a <button> with role="checkbox" rather than a native <input type="checkbox">. This is intentional — it gives full styling control (native checkboxes are notoriously hard to style consistently across browsers) while maintaining accessibility. The trade-off: it doesn't participate in native form submission without a hidden <input> companion, which Radix handles internally.
Material 3 adds a ripple effect to checkbox interactions, consistent with their touch feedback philosophy. The ripple radiates from the point of click — a nice detail for touch interfaces. They also support an "error" state where the checkbox border turns red, which is less common but useful for required agreement checkboxes.
Ant Design's Checkbox.Group is remarkably convenient — you pass an options array and get a fully managed group with a value array and onChange callback. This declarative approach reduces boilerplate significantly for common "pick multiple" forms.
Native vs. custom: The eternal debate. Native <input type="checkbox"> is accessible by default and participates in form submission, but is nearly impossible to style consistently. Custom implementations (<button role="checkbox"> or <div role="checkbox">) offer full visual control but require manual accessibility work. Most modern design systems opt for custom with careful ARIA implementation. The native accent-color CSS property (supported in all modern browsers as of 2026) provides a middle ground — it lets you change the checkbox color while keeping native behavior.