Behive Tech Component Registry

Checkbox

v0.5.0Stable

A bare checkbox control (built on Radix's `Checkbox`) — the Radix checkbox button is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome.

pnpm add @behivetech/atoms.checkbox

registry/behivetech/atoms/checkbox

checkbox.tsx
1import * as RadixCheckbox from "@radix-ui/react-checkbox";
2import { useId, useState } from "react";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./checkbox.module.scss";
5
6import type { CheckedState } from "@radix-ui/react-checkbox";
7
8export type { CheckedState };
9
10export interface CheckboxProps extends Omit<
11 RadixCheckbox.CheckboxProps,
12 "className"
13> {
14 /** Additional class names to merge with the component root element */
15 className?: string;
16 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */
17 invalid?: boolean;
18}
19
20/**
21 * Bare checkbox control — the Radix checkbox button is this component's own
22 * root element, no built-in label/hint/error. Wrap it in `FormField` for
23 * that chrome: `<FormField label="..." htmlFor="x"><Checkbox id="x" /></FormField>`.
24 */
25export const Checkbox = ({
26 className,
27 id,
28 checked,
29 defaultChecked = false,
30 onCheckedChange,
31 invalid = false,
32 "aria-invalid": ariaInvalid,
33 ...rest
34}: CheckboxProps) => {
35 const generatedId = useId();
36 const checkboxId = id ?? generatedId;
37
38 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
39
40 // The controlled `checked` wins; otherwise the component tracks its own
41 // state so `--checked` / `--indeterminate` work uncontrolled too.
42 const [internalChecked, setInternalChecked] =
43 useState<CheckedState>(defaultChecked);
44 const currentChecked = checked ?? internalChecked;
45
46 const handleCheckedChange = (next: CheckedState) => {
47 if (checked === undefined) {
48 setInternalChecked(next);
49 }
50 onCheckedChange?.(next);
51 };
52
53 const [rootClass, getChildClass] = getClassName({
54 className,
55 modifiers: {
56 checked: currentChecked === true,
57 indeterminate: currentChecked === "indeterminate",
58 invalid: isInvalid,
59 },
60 rootClass: "BHT__Checkbox",
61 styles,
62 });
63
64 return (
65 <RadixCheckbox.Root
66 {...rest}
67 id={checkboxId}
68 checked={currentChecked}
69 onCheckedChange={handleCheckedChange}
70 className={rootClass}
71 aria-invalid={isInvalid ? true : ariaInvalid}
72 >
73 <RadixCheckbox.Indicator className={getChildClass("indicator")}>
74 {currentChecked === "indeterminate" ? (
75 <svg
76 viewBox="0 0 12 4"
77 fill="none"
78 stroke="currentColor"
79 strokeWidth="2.5"
80 strokeLinecap="round"
81 aria-hidden="true"
82 >
83 <path d="M1 2H11" />
84 </svg>
85 ) : (
86 <svg
87 viewBox="0 0 12 10"
88 fill="none"
89 stroke="currentColor"
90 strokeWidth="2.5"
91 strokeLinecap="round"
92 strokeLinejoin="round"
93 aria-hidden="true"
94 >
95 <path d="M1 5L4.5 8.5L11 1" />
96 </svg>
97 )}
98 </RadixCheckbox.Indicator>
99 </RadixCheckbox.Root>
100 );
101};
checkbox.module.scss
1// Material Design 3 checkbox.
2//
3// `color` on the root is not used for text — it is the state layer's tint
4// (`currentColor` in ::after), so it tracks the box: on-surface-variant when
5// unchecked, primary when checked, error when invalid.
6
7.BHT__Checkbox {
8 // Radix renders as <button role="checkbox">
9 position: relative;
10 width: 1rem;
11 height: 1rem;
12 flex-shrink: 0;
13 border: 2px solid
14 var(--md-sys-color-on-surface-variant, var(--color-border-strong));
15 border-radius: 2px;
16 background: transparent;
17 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
18 display: inline-flex;
19 align-items: center;
20 justify-content: center;
21 padding: 0;
22 cursor: pointer;
23 transition:
24 background var(--md-sys-motion-duration-short-3, 150ms)
25 var(--md-sys-motion-easing-standard, ease),
26 border-color var(--md-sys-motion-duration-short-3, 150ms)
27 var(--md-sys-motion-easing-standard, ease);
28
29 // Touch target: a 48px square hit area around the 16px box, without
30 // changing its drawn size. px on purpose — a finger doesn't scale with type.
31 &::before {
32 content: "";
33 position: absolute;
34 top: 50%;
35 left: 50%;
36 translate: -50% -50%;
37 width: 48px;
38 height: 48px;
39 }
40
41 // State layer: MD3 draws it as a circle around the box, not on the box.
42 &::after {
43 content: "";
44 position: absolute;
45 top: 50%;
46 left: 50%;
47 translate: -50% -50%;
48 width: 2.5rem;
49 height: 2.5rem;
50 border-radius: var(--md-sys-shape-corner-full, 9999px);
51 background: currentColor;
52 opacity: 0;
53 pointer-events: none;
54 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
55 var(--md-sys-motion-easing-standard, ease);
56 }
57
58 &:hover:not(:disabled)::after {
59 opacity: var(--md-sys-state-hover-opacity, 0.08);
60 }
61
62 &:focus-visible::after {
63 opacity: var(--md-sys-state-focus-opacity, 0.1);
64 }
65
66 &:active:not(:disabled)::after {
67 opacity: var(--md-sys-state-pressed-opacity, 0.1);
68 }
69
70 &--checked,
71 &--indeterminate {
72 &.BHT__Checkbox {
73 background: var(--md-sys-color-primary, var(--color-primary));
74 border-color: var(--md-sys-color-primary, var(--color-primary));
75 color: var(--md-sys-color-primary, var(--color-primary));
76 }
77 }
78
79 &:focus-visible {
80 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
81 outline-offset: 2px;
82 }
83
84 &:disabled {
85 opacity: 0.5;
86 cursor: not-allowed;
87 }
88
89 &--invalid {
90 &.BHT__Checkbox {
91 border-color: var(--md-sys-color-error, var(--color-error));
92 color: var(--md-sys-color-error, var(--color-error));
93
94 &.BHT__Checkbox--checked,
95 &.BHT__Checkbox--indeterminate {
96 background: var(--md-sys-color-error, var(--color-error));
97 border-color: var(--md-sys-color-error, var(--color-error));
98
99 .BHT__Checkbox {
100 &__indicator {
101 color: var(--md-sys-color-on-error, var(--color-on-primary));
102 }
103 }
104 }
105 }
106 }
107
108 &__indicator {
109 color: var(--md-sys-color-on-primary, var(--color-on-primary));
110 display: flex;
111 align-items: center;
112 justify-content: center;
113 width: 0.625rem;
114 height: 0.625rem;
115
116 svg {
117 width: 100%;
118 height: 100%;
119 }
120 }
121}
index.ts
1export { Checkbox } from "./checkbox.js";
2export type { CheckboxProps, CheckedState } from "./checkbox.js";
checkbox.example.tsx
1import { useState } from "react";
2import { FormField } from "@behivetech/forms.form-field";
3import { Checkbox } from "./checkbox.js";
4
5export const BasicCheckbox = () => {
6 const [checked, setChecked] = useState(false);
7 return (
8 <Checkbox
9 checked={checked}
10 onCheckedChange={(val) => setChecked(val === true)}
11 />
12 );
13};
14
15export const CheckboxWithFormField = () => {
16 const [checked, setChecked] = useState(false);
17 return (
18 <FormField
19 label="Accept terms and conditions"
20 htmlFor="accept-terms"
21 hint="You can withdraw consent at any time."
22 >
23 <Checkbox
24 id="accept-terms"
25 checked={checked}
26 onCheckedChange={(val) => setChecked(val === true)}
27 />
28 </FormField>
29 );
30};
31
32export const CheckboxWithError = () => (
33 <FormField
34 label="Agree to terms"
35 htmlFor="agree"
36 error="You must agree to continue"
37 >
38 <Checkbox id="agree" invalid checked={false} onCheckedChange={() => {}} />
39 </FormField>
40);
41
42export const IndeterminateCheckbox = () => (
43 <Checkbox checked="indeterminate" onCheckedChange={() => {}} />
44);
45
46export const DisabledCheckbox = () => (
47 <Checkbox disabled checked={false} onCheckedChange={() => {}} />
48);
49
50export const RequiredCheckbox = () => {
51 const [checked, setChecked] = useState(false);
52 return (
53 <FormField
54 label="I have read the privacy policy"
55 htmlFor="privacy"
56 required
57 >
58 <Checkbox
59 id="privacy"
60 required
61 checked={checked}
62 onCheckedChange={(val) => setChecked(val === true)}
63 />
64 </FormField>
65 );
66};
checkbox.spec.tsx
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Checkbox } from "./checkbox.js";
4
5describe("Checkbox", () => {
6 it("renders as a checkbox role", () => {
7 render(<Checkbox />);
8 expect(screen.getByRole("checkbox")).toBeTruthy();
9 });
10
11 it("calls onCheckedChange when clicked", async () => {
12 const handleChange = vi.fn();
13 render(<Checkbox onCheckedChange={handleChange} />);
14 await userEvent.click(screen.getByRole("checkbox"));
15 expect(handleChange).toHaveBeenCalledWith(true);
16 });
17
18 it("does not fire onCheckedChange when disabled", async () => {
19 const handleChange = vi.fn();
20 render(<Checkbox disabled onCheckedChange={handleChange} />);
21 await userEvent.click(screen.getByRole("checkbox"));
22 expect(handleChange).not.toHaveBeenCalled();
23 });
24
25 it("passes aria-invalid through to the root element", () => {
26 render(<Checkbox aria-invalid="true" />);
27 expect(screen.getByRole("checkbox").getAttribute("aria-invalid")).toBe(
28 "true",
29 );
30 });
31
32 it("adds the --invalid modifier and aria-invalid for the invalid prop", () => {
33 render(<Checkbox invalid />);
34 const el = screen.getByRole("checkbox");
35 expect(el.className).toContain("--invalid");
36 expect(el.getAttribute("aria-invalid")).toBe("true");
37 });
38
39 it("adds the --invalid modifier for a direct aria-invalid", () => {
40 render(<Checkbox aria-invalid="true" />);
41 expect(screen.getByRole("checkbox").className).toContain("--invalid");
42 });
43
44 it("has no --invalid modifier by default", () => {
45 render(<Checkbox />);
46 expect(screen.getByRole("checkbox").className).not.toContain("--invalid");
47 });
48
49 it("sets data-state to indeterminate", () => {
50 render(<Checkbox checked="indeterminate" onCheckedChange={() => {}} />);
51 expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe(
52 "indeterminate",
53 );
54 });
55
56 it("adds --checked and aria-checked when controlled", () => {
57 render(<Checkbox checked onCheckedChange={() => {}} />);
58 const el = screen.getByRole("checkbox");
59 expect(el.className).toContain("--checked");
60 expect(el.getAttribute("aria-checked")).toBe("true");
61 expect(el.getAttribute("data-state")).toBe("checked");
62 });
63
64 it("adds --checked from defaultChecked and drops it when toggled off", async () => {
65 const handleChange = vi.fn();
66 render(<Checkbox defaultChecked onCheckedChange={handleChange} />);
67 const el = screen.getByRole("checkbox");
68 expect(el.className).toContain("--checked");
69 expect(el.getAttribute("aria-checked")).toBe("true");
70 await userEvent.click(el);
71 expect(handleChange).toHaveBeenCalledWith(false);
72 expect(el.className).not.toContain("--checked");
73 expect(el.getAttribute("aria-checked")).toBe("false");
74 });
75
76 it("adds --checked after an uncontrolled click and forwards the change", async () => {
77 const handleChange = vi.fn();
78 render(<Checkbox onCheckedChange={handleChange} />);
79 const el = screen.getByRole("checkbox");
80 expect(el.className).not.toContain("--checked");
81 await userEvent.click(el);
82 expect(handleChange).toHaveBeenCalledWith(true);
83 expect(el.className).toContain("--checked");
84 expect(el.getAttribute("data-state")).toBe("checked");
85 });
86
87 it("keeps a controlled checkbox in step with its prop, not a click", async () => {
88 render(<Checkbox checked={false} onCheckedChange={() => {}} />);
89 const el = screen.getByRole("checkbox");
90 await userEvent.click(el);
91 expect(el.className).not.toContain("--checked");
92 });
93
94 it("adds --indeterminate when controlled", () => {
95 render(<Checkbox checked="indeterminate" onCheckedChange={() => {}} />);
96 const el = screen.getByRole("checkbox");
97 expect(el.className).toContain("--indeterminate");
98 expect(el.className).not.toContain("--checked");
99 expect(el.getAttribute("aria-checked")).toBe("mixed");
100 });
101
102 it("adds --indeterminate from defaultChecked and draws the dash", () => {
103 const { container } = render(<Checkbox defaultChecked="indeterminate" />);
104 expect(screen.getByRole("checkbox").className).toContain("--indeterminate");
105 expect(container.querySelector("svg path")?.getAttribute("d")).toBe(
106 "M1 2H11",
107 );
108 });
109
110 it("applies additional className", () => {
111 render(<Checkbox className="custom" />);
112 expect(screen.getByRole("checkbox").className).toContain("custom");
113 });
114});