Components
Checkbox
A yes or a no: an option that is on or off, independent of any other.
MarkdownA checkbox holds a yes or a no: an option that is on or off, independent of any other.
Some, all, none
The first checkbox stands for the three beneath it: on when all are, off when none is, and mixed in between.
Code
"use client";
import { Checkbox } from "@publictheta/ui/components/checkbox/checkbox.tsx";
import { useState } from "react";
const KINDS = ["Mentions", "Replies", "Weekly summary"] as const;
export default function Notifications() {
const [on, setOn] = useState<ReadonlySet<string>>(new Set(["Mentions"]));
const all = on.size === KINDS.length ? true : on.size === 0 ? false : null;
return (
<div className="flex flex-col gap-2 text-sm text-fg-strong">
<label className="flex items-center gap-2 font-medium">
<Checkbox checked={all} onCheckedChange={(checked) => setOn(new Set(checked ? KINDS : []))} />
All notifications
</label>
<div className="ms-6 flex flex-col gap-2">
{KINDS.map((kind) => (
<label key={kind} className="flex items-center gap-2">
<Checkbox
checked={on.has(kind)}
onCheckedChange={(checked) =>
setOn((current) => {
const next = new Set(current);
if (checked) next.add(kind);
else next.delete(kind);
return next;
})
}
/>
{kind}
</label>
))}
<label className="flex items-center gap-2 text-fg-disabled">
<Checkbox disabled />
Text messages (no phone number)
</label>
</div>
</div>
);
}When to use
Use a checkbox for a setting that takes effect with the rest of a form, and for choosing any number of items from a list. For a choice of exactly one among several, use a radio group or a select. For a setting that takes effect at once, a switch says so more plainly.
Best practices
Label what being checked means. Phrase the label so that checked is yes: "Send me updates", not "Don't send updates".
Make the label part of the target. A click or tap on the label changes the checkbox, as one on the box does.
Show a third state only when it is true. A checkbox is mixed when it stands for several things that disagree, or for a value not yet given. Choosing it turns it on; people cannot return it to mixed themselves.
Disable rather than hide a checkbox that does not apply at the moment.
Platform considerations
Keyboard. Space turns the checkbox on and off; Tab moves to and past it.
Touch. The box is larger, and a tap near it reaches it: its target is at least 44 CSS pixels across.
Assistive technology. It is a checkbox, named by its label, stating whether it is checked, unchecked or mixed.
Behaviour
Semantics
Is a checkbox named by its label, saying whether it is checked
const screen = await render(<Option defaultChecked />);
await expect.element(box(screen)).toBeChecked();Neither on nor off is stated as mixed
const screen = await render(<Option checked={null} />);
await expect.element(box(screen)).toBePartiallyChecked();Has no accessibility violations in any state
const screen = await render(
<>
<Option />
<label>
<Checkbox defaultChecked /> On
</label>
<label>
<Checkbox checked={null} /> Mixed
</label>
<label>
<Checkbox disabled defaultChecked /> Unavailable
</label>
</>,
);
await expectAccessible(screen.container);Changing
A click turns it on, and another turns it off
const screen = await render(<Option />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
await userEvent.click(box(screen));
await expect.element(box(screen)).not.toBeChecked();A click on its label does the same
const screen = await render(<Option />);
await userEvent.click(screen.getByText("Subscribe"));
await expect.element(box(screen)).toBeChecked();Space turns it on and off
const screen = await render(
<>
<input aria-label="Before" />
<Option />
</>,
);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
element(screen).focus();
await userEvent.keyboard(" ");
await expect.element(box(screen)).toBeChecked();
await userEvent.keyboard(" ");
await expect.element(box(screen)).not.toBeChecked();From mixed, a click turns it on
const screen = await render(<Option defaultChecked={null} />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
expect(element(screen).indeterminate).toBe(false);When disabled it cannot be changed
const onCheckedChange = vi.fn();
const screen = await render(<Option disabled onCheckedChange={onCheckedChange} />);
await userEvent.click(box(screen), { force: true });
expect(onCheckedChange).not.toHaveBeenCalled();
await expect.element(box(screen)).not.toBeChecked();Touch
Under a finger, a tap 20 pixels from its centre still reaches itChromium ยท touch, WebKit ยท touch
Not yet written.
API
onCheckedChange hears each change with the new state
const onCheckedChange = vi.fn();
const screen = await render(<Option onCheckedChange={onCheckedChange} />);
await userEvent.click(box(screen));
await userEvent.click(box(screen));
expect(onCheckedChange.mock.calls).toEqual([[true], [false]]);A controlled checked changes only when its owner changes it
function Controlled() {
const [checked, setChecked] = useState<boolean | null>(null);
return (
<>
<Option checked={checked} onCheckedChange={(next) => setChecked(next ? true : null)} />
<output>{String(checked)}</output>
</>
);
}
const screen = await render(<Controlled />);
await userEvent.click(box(screen));
await expect.element(box(screen)).toBeChecked();
await userEvent.click(box(screen));
await expect.element(box(screen)).toBePartiallyChecked();
expect(element(screen).indeterminate).toBe(true);React
Install
npx shadcn@latest add https://ui.publictheta.com/r/checkbox.jsonSource
src/components/checkbox/checkbox.tsx
"use client";
import { useLayoutEffect, useRef } from "react";
import type { ComponentProps, ReactElement } from "react";
import { focusRingClassName, invalidClassName } from "../../lib/focus-class.ts";
import { mergeRefs } from "../../lib/merge-refs.ts";
import { cn } from "../../lib/utils.ts";
import { useControllableState } from "../../primitives/use-controllable-state.ts";
export type CheckboxProps = Omit<
ComponentProps<"input">,
"type" | "checked" | "defaultChecked" | "onChange"
> & {
/** On, off, or `null` for neither: a mixed selection, or a value not yet given. */
checked?: boolean | null;
defaultChecked?: boolean | null;
onCheckedChange?: (checked: boolean) => void;
};
/**
* A checkbox: the platform's own, so it submits with forms, answers to Space
* and to its label, and is announced as what it is. Only its looks are ours.
*/
export function Checkbox({
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
className,
ref,
...props
}: CheckboxProps): ReactElement {
const input = useRef<HTMLInputElement>(null);
const [checked, setChecked] = useControllableState<boolean | null>(
checkedProp,
defaultChecked,
onCheckedChange as ((checked: boolean | null) => void) | undefined,
);
// Neither on nor off is a property of the element, not an attribute React sets.
// After every render: a click clears it, even where the owner then keeps the value as it was.
useLayoutEffect(() => {
if (input.current !== null) input.current.indeterminate = checked === null;
});
return (
<span
className={cn("relative inline-flex size-4 shrink-0 align-middle pointer-coarse:size-6", className)}
>
<input
type="checkbox"
ref={mergeRefs(input, ref)}
checked={checked === true}
onChange={(event) => setChecked(event.target.checked)}
className={cn(
"peer size-full appearance-none rounded border border-border bg-surface [-webkit-tap-highlight-color:transparent]",
"enabled:hover:border-fg-muted enabled:active:bg-surface-strong",
"checked:border-transparent checked:bg-selected indeterminate:border-transparent indeterminate:bg-selected",
"enabled:checked:active:bg-selected enabled:indeterminate:active:bg-selected",
// Where a system forces its own colours, a box that is on is filled with the system's highlight.
"forced-colors:checked:bg-[Highlight] forced-colors:indeterminate:bg-[Highlight]",
focusRingClassName,
invalidClassName,
"disabled:border-border-muted disabled:bg-surface-muted disabled:checked:bg-surface-strong disabled:indeterminate:bg-surface-strong",
// Under a finger, the target reaches beyond the box to 44 pixels across.
"pointer-coarse:before:absolute pointer-coarse:before:-inset-2.5 pointer-coarse:before:content-['']",
)}
{...props}
/>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute inset-0 hidden size-full text-on-selected peer-checked:block peer-indeterminate:hidden peer-disabled:text-fg-muted forced-colors:text-[HighlightText]"
>
<path d="M3.75 8.25 6.75 11.25 12.25 5" />
</svg>
<svg
aria-hidden
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="pointer-events-none absolute inset-0 hidden size-full text-on-selected peer-indeterminate:block peer-disabled:text-fg-muted forced-colors:text-[HighlightText]"
>
<path d="M4 8h8" />
</svg>
</span>
);
}