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.
Search across all documentation pages
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
indeterminate prop is applied via useEffect on the DOM element since HTML has no indeterminate attribute. This enables "select all" patterns in data tables.onChange event handler and a simplified onCheckedChange boolean callback, making it compatible with form libraries and simple state alike.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 from FormData, not "off". Use formData.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 checked without onChange makes the checkbox read-only and triggers a React warning. If you want a read-only checkbox, also pass readOnly.
defaultChecked vs checked -- Using defaultChecked makes the checkbox uncontrolled. Switching between defaultChecked and checked at 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 unique name or 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).
Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥