Behive Tech Registry

Checkbox

v0.4.0

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 · depends on @behivetech/get-class-name

checkbox.tsx
1import * as RadixCheckbox from "@radix-ui/react-checkbox";
2import { useId } from "react";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./checkbox.module.scss";
5
6export type { CheckedState } from "@radix-ui/react-checkbox";
7
8export interface CheckboxProps extends Omit<
9 RadixCheckbox.CheckboxProps,
10 "className"
11> {
12 /** Additional class names to merge with the component root element */
13 className?: string;
14}
15
16/**
17 * Bare checkbox control — the Radix checkbox button is this component's own
18 * root element, no built-in label/hint/error. Wrap it in `FormField` for
19 * that chrome: `<FormField label="..." htmlFor="x"><Checkbox id="x" /></FormField>`.
20 */
21export const Checkbox = ({
22 className,
23 id,
24 checked,
25 ...rest
26}: CheckboxProps) => {
27 const generatedId = useId();
28 const checkboxId = id ?? generatedId;
29
30 const [rootClass, getChildClass] = getClassName({
31 className,
32 rootClass: "BHT__Checkbox",
33 styles,
34 });
35
36 return (
37 <RadixCheckbox.Root
38 {...rest}
39 id={checkboxId}
40 checked={checked}
41 className={rootClass}
42 >
43 <RadixCheckbox.Indicator className={getChildClass("indicator")}>
44 {checked === "indeterminate" ? (
45 <svg
46 viewBox="0 0 12 4"
47 fill="none"
48 stroke="currentColor"
49 strokeWidth="2.5"
50 strokeLinecap="round"
51 aria-hidden="true"
52 >
53 <path d="M1 2H11" />
54 </svg>
55 ) : (
56 <svg
57 viewBox="0 0 12 10"
58 fill="none"
59 stroke="currentColor"
60 strokeWidth="2.5"
61 strokeLinecap="round"
62 strokeLinejoin="round"
63 aria-hidden="true"
64 >
65 <path d="M1 5L4.5 8.5L11 1" />
66 </svg>
67 )}
68 </RadixCheckbox.Indicator>
69 </RadixCheckbox.Root>
70 );
71};
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 &[data-state="checked"],
71 &[data-state="indeterminate"] {
72 background: var(--md-sys-color-primary, var(--color-primary));
73 border-color: var(--md-sys-color-primary, var(--color-primary));
74 color: var(--md-sys-color-primary, var(--color-primary));
75 }
76
77 &:focus-visible {
78 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));
79 outline-offset: 2px;
80 }
81
82 &:disabled {
83 opacity: 0.5;
84 cursor: not-allowed;
85 }
86
87 &[aria-invalid="true"] {
88 border-color: var(--md-sys-color-error, var(--color-error));
89 color: var(--md-sys-color-error, var(--color-error));
90
91 &[data-state="checked"],
92 &[data-state="indeterminate"] {
93 background: var(--md-sys-color-error, var(--color-error));
94 border-color: var(--md-sys-color-error, var(--color-error));
95
96 .BHT__Checkbox {
97 &__indicator {
98 color: var(--md-sys-color-on-error, var(--color-on-primary));
99 }
100 }
101 }
102 }
103
104 &__indicator {
105 color: var(--md-sys-color-on-primary, var(--color-on-primary));
106 display: flex;
107 align-items: center;
108 justify-content: center;
109 width: 0.625rem;
110 height: 0.625rem;
111
112 svg {
113 width: 100%;
114 height: 100%;
115 }
116 }
117}
index.ts
1export { Checkbox } from "./checkbox.js";
2export type { CheckboxProps, CheckedState } from "./checkbox.js";
checkbox.composition.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
39 id="agree"
40 aria-invalid="true"
41 checked={false}
42 onCheckedChange={() => {}}
43 />
44 </FormField>
45);
46
47export const IndeterminateCheckbox = () => (
48 <Checkbox checked="indeterminate" onCheckedChange={() => {}} />
49);
50
51export const DisabledCheckbox = () => (
52 <Checkbox disabled checked={false} onCheckedChange={() => {}} />
53);
54
55export const RequiredCheckbox = () => {
56 const [checked, setChecked] = useState(false);
57 return (
58 <FormField
59 label="I have read the privacy policy"
60 htmlFor="privacy"
61 required
62 >
63 <Checkbox
64 id="privacy"
65 required
66 checked={checked}
67 onCheckedChange={(val) => setChecked(val === true)}
68 />
69 </FormField>
70 );
71};
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("sets data-state to indeterminate", () => {
33 render(<Checkbox checked="indeterminate" onCheckedChange={() => {}} />);
34 expect(screen.getByRole("checkbox").getAttribute("data-state")).toBe(
35 "indeterminate",
36 );
37 });
38
39 it("applies additional className", () => {
40 render(<Checkbox className="custom" />);
41 expect(screen.getByRole("checkbox").className).toContain("custom");
42 });
43});