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

This is the snapshot of v0.5.2, the current version, as recorded on 2026-11-10. The live page runs its examples →

registry/behivetech/atoms/checkbox at v0.5.2, snapshotted 2026-11-10

checkbox.tsx
1import * as RadixCheckbox from "@radix-ui/react-checkbox";
2import { useId, useState } from "react";
3import { getClassName } from "@behivetech/get-class-name";
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};