Checkbox
A toggle for boolean values, used individually for opt-in controls or in groups for multi-selection. Built with native checkbox inputs and Tailwind styling for accessibility and consistency.
Use Cases
- Terms of service and privacy policy agreement
- Email notification preferences and opt-in toggles
- Multi-select filters on product listing pages
- "Remember me" on login forms
- Bulk selection in data tables with "select all"
- Feature toggles in settings panels
- Task completion checkboxes in to-do lists
Simplest Implementation
"use client";
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Checkbox({ label, checked, onChange }: CheckboxProps) {
return (
<label className="inline-flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{label}</span>
</label>
);
}A minimal checkbox with a text label. The onChange callback returns a boolean directly so the parent does not need to unwrap e.target.checked. Wrapping both elements inside a <label> makes the entire row clickable.
Variations
Single Checkbox
"use client";
interface CheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
name?: string;
}
export function Checkbox({ checked, onChange, name }: CheckboxProps) {
return (
<input
type="checkbox"
name={name}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
);
}A standalone checkbox without a label, useful when the label is rendered separately (such as in table rows or custom layouts). The name prop allows it to participate in native form submissions.
Checkbox with Label
"use client";
import { useId } from "react";
interface CheckboxProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Checkbox({ label, checked, onChange, disabled = false }: CheckboxProps) {
const id = useId();
return (
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50"
/>
<label
htmlFor={id}
className={`text-sm ${disabled ? "cursor-not-allowed text-gray-400" : "cursor-pointer text-gray-700"}`}
>
{label}
</label>
</div>
);
}Uses useId to generate a stable ID linking the <label> and <input> via htmlFor. This approach keeps the label and input as siblings, giving more flexibility for layout than wrapping the input inside the label.
Checkbox Group
"use client";
interface Option {
value: string;
label: string;
}
interface CheckboxGroupProps {
label: string;
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function CheckboxGroup({ label, options, selected, onChange }: CheckboxGroupProps) {
function toggleValue(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
}
return (
<fieldset>
<legend className="text-sm font-medium text-gray-700">{label}</legend>
<div className="mt-2 space-y-2">
{options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={() => toggleValue(opt.value)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
))}
</div>
</fieldset>
);
}A group of checkboxes managed as an array of selected values. The <fieldset> and <legend> elements provide semantic grouping for screen readers. Toggling a value either adds it to or removes it from the selected array.
Indeterminate State
"use client";
import { useRef, useEffect } from "react";
interface IndeterminateCheckboxProps {
label: string;
checked: boolean;
indeterminate: boolean;
onChange: (checked: boolean) => void;
}
export function IndeterminateCheckbox({
label,
checked,
indeterminate,
onChange,
}: IndeterminateCheckboxProps) {
const checkboxRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<label className="inline-flex cursor-pointer items-center gap-2">
<input
ref={checkboxRef}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm font-medium text-gray-700">{label}</span>
</label>
);
}The indeterminate state is a third visual state (a dash instead of a checkmark) that is only settable via JavaScript, not HTML attributes. This is typically used for a "select all" checkbox when only some child items are checked. The useEffect sets indeterminate on the DOM element directly.
With Description Text
"use client";
import { useId } from "react";
interface CheckboxProps {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
}
export function Checkbox({ label, description, checked, onChange }: CheckboxProps) {
const id = useId();
const descriptionId = `${id}-description`;
return (
<div className="flex items-start gap-3">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-describedby={descriptionId}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<div>
<label htmlFor={id} className="cursor-pointer text-sm font-medium text-gray-700">
{label}
</label>
<p id={descriptionId} className="text-sm text-gray-500">
{description}
</p>
</div>
</div>
);
}Adds a secondary description below the label for additional context. The checkbox is aligned to the top of the text block with items-start and mt-0.5. The aria-describedby attribute links the description to the input for screen readers.
Controlled with Form Integration
"use client";
import { useActionState } from "react";
interface FormState {
message: string;
error?: string;
}
async function submitPreferences(
_prev: FormState,
formData: FormData
): Promise<FormState> {
const accepted = formData.get("terms") === "on";
if (!accepted) {
return { message: "", error: "You must accept the terms to continue." };
}
return { message: "Preferences saved successfully!" };
}
export function PreferencesForm() {
const [state, formAction, isPending] = useActionState(submitPreferences, {
message: "",
});
return (
<form action={formAction} className="space-y-4">
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-gray-700">Notifications</legend>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="email_updates"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">Email updates</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="marketing"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">Marketing emails</span>
</label>
</fieldset>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
name="terms"
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
/>
<span className="text-sm text-gray-700">I accept the terms and conditions</span>
</label>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Saving..." : "Save Preferences"}
</button>
{state.message && <p className="text-sm text-green-600">{state.message}</p>}
</form>
);
}Uses React 19 useActionState to handle form submission. Checkboxes are uncontrolled with name attributes so the browser collects them into FormData. A checked checkbox sends "on" as its value; unchecked checkboxes are absent from form data entirely.
Complex Implementation
"use client";
import { forwardRef, useId, useRef, useEffect, useCallback } from "react";
type CheckboxSize = "sm" | "md" | "lg";
interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size" | "onChange"> {
label?: string;
description?: string;
error?: string;
size?: CheckboxSize;
indeterminate?: boolean;
onCheckedChange?: (checked: boolean) => void;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}
const sizeClasses: Record<CheckboxSize, { box: string; label: string; desc: string }> = {
sm: { box: "h-3.5 w-3.5", label: "text-xs", desc: "text-xs" },
md: { box: "h-4 w-4", label: "text-sm", desc: "text-sm" },
lg: { box: "h-5 w-5", label: "text-base", desc: "text-sm" },
};
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
{
label,
description,
error,
size = "md",
indeterminate = false,
onCheckedChange,
onChange,
disabled,
className,
id: externalId,
...rest
},
ref
) {
const generatedId = useId();
const checkboxId = externalId ?? generatedId;
const errorId = `${checkboxId}-error`;
const descriptionId = `${checkboxId}-desc`;
const internalRef = useRef<HTMLInputElement>(null);
const checkboxRef = (ref as React.RefObject<HTMLInputElement>) ?? internalRef;
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate, checkboxRef]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
onCheckedChange?.(e.target.checked);
},
[onChange, onCheckedChange]
);
const hasError = !!error;
const sizes = sizeClasses[size];
const describedBy = [
description ? descriptionId : null,
hasError ? errorId : null,
]
.filter(Boolean)
.join(" ") || undefined;
return (
<div className="flex items-start gap-3">
<input
ref={checkboxRef}
id={checkboxId}
type="checkbox"
disabled={disabled}
onChange={handleChange}
aria-invalid={hasError}
aria-describedby={describedBy}
className={[
"mt-0.5 rounded border-gray-300 text-blue-600",
"focus:ring-2 focus:ring-blue-500 focus:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-50",
hasError ? "border-red-500" : "",
sizes.box,
className ?? "",
]
.filter(Boolean)
.join(" ")}
{...rest}
/>
{(label || description || hasError) && (
<div className="flex flex-col">
{label && (
<label
htmlFor={checkboxId}
className={[
"font-medium",
disabled ? "cursor-not-allowed text-gray-400" : "cursor-pointer text-gray-700",
sizes.label,
].join(" ")}
>
{label}
</label>
)}
{description && (
<p id={descriptionId} className={`text-gray-500 ${sizes.desc}`}>
{description}
</p>
)}
{hasError && (
<p id={errorId} className={`mt-0.5 text-red-600 ${sizes.desc}`} role="alert">
{error}
</p>
)}
</div>
)}
</div>
);
});Key aspects:
- forwardRef -- allows parent components to attach refs for focus management, form libraries, or imperative access to the underlying checkbox input.
- useId for accessibility -- generates a stable unique ID to link the label, description, and error text to the input without requiring the consumer to provide IDs.
- Indeterminate support -- the
indeterminateprop is applied viauseEffecton the DOM element since HTML has noindeterminateattribute. This enables "select all" patterns in data tables. - Dual onChange API -- supports both the native
onChangeevent handler and a simplifiedonCheckedChangeboolean callback, making it compatible with form libraries and simple state alike. - aria-describedby chaining -- links the checkbox to both description and error text elements so screen readers announce contextual information in the correct order.
- Size variants -- three sizes adjust the checkbox dimensions, label font size, and description text size together for visual consistency.
- Error border -- when an error is present, the checkbox border turns red in addition to showing the error message, giving both visual and textual feedback.
Gotchas
-
Checkbox value in FormData is "on", not true -- When using native form submission, a checked checkbox sends
"on"as its value. Unchecked checkboxes are completely absent fromFormData, not"off". UseformData.has("name")to check existence. -
indeterminate is not an HTML attribute -- You cannot set indeterminate state via JSX props. It must be set through a ref with
el.indeterminate = true. Forgetting this leads to the indeterminate state never appearing. -
Controlled checkbox needs both checked and onChange -- Providing
checkedwithoutonChangemakes the checkbox read-only and triggers a React warning. If you want a read-only checkbox, also passreadOnly. -
defaultChecked vs checked -- Using
defaultCheckedmakes the checkbox uncontrolled. Switching betweendefaultCheckedandcheckedat runtime causes unpredictable behavior. Pick one approach per component instance. -
Checkbox group serialization -- When multiple checkboxes share the same
name,FormData.getAll("name")returns an array of"on"strings. Give each checkbox a uniquenameor use a value attribute:<input type="checkbox" name="colors" value="red" />. -
Click area too small -- A bare checkbox is a small target. Always wrap it in a
<label>or use sufficient padding around it to meet minimum touch target sizes (at least 44x44px on mobile).
Related
- Input -- Text input with shared form styling conventions
- Radio Group -- Mutually exclusive selection for single-choice scenarios
- Button -- Submit buttons used alongside checkboxes in forms
- Forms -- Form patterns and validation strategies
- Event Handling -- onChange and event typing