Checkbox
v0.4.0A 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.
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";56export type { CheckedState } from "@radix-ui/react-checkbox";78export 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}1516/**17 * Bare checkbox control — the Radix checkbox button is this component's own18 * root element, no built-in label/hint/error. Wrap it in `FormField` for19 * that chrome: `<FormField label="..." htmlFor="x"><Checkbox id="x" /></FormField>`.20 */21export const Checkbox = ({22 className,23 id,24 checked,25 ...rest26}: CheckboxProps) => {27 const generatedId = useId();28 const checkboxId = id ?? generatedId;2930 const [rootClass, getChildClass] = getClassName({31 className,32 rootClass: "BHT__Checkbox",33 styles,34 });3536 return (37 <RadixCheckbox.Root38 {...rest}39 id={checkboxId}40 checked={checked}41 className={rootClass}42 >43 <RadixCheckbox.Indicator className={getChildClass("indicator")}>44 {checked === "indeterminate" ? (45 <svg46 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 <svg57 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 tint4// (`currentColor` in ::after), so it tracks the box: on-surface-variant when5// unchecked, primary when checked, error when invalid.67.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 solid14 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);2829 // Touch target: a 48px square hit area around the 16px box, without30 // 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 }4041 // 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 }5758 &:hover:not(:disabled)::after {59 opacity: var(--md-sys-state-hover-opacity, 0.08);60 }6162 &:focus-visible::after {63 opacity: var(--md-sys-state-focus-opacity, 0.1);64 }6566 &:active:not(:disabled)::after {67 opacity: var(--md-sys-state-pressed-opacity, 0.1);68 }6970 &[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 }7677 &:focus-visible {78 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));79 outline-offset: 2px;80 }8182 &:disabled {83 opacity: 0.5;84 cursor: not-allowed;85 }8687 &[aria-invalid="true"] {88 border-color: var(--md-sys-color-error, var(--color-error));89 color: var(--md-sys-color-error, var(--color-error));9091 &[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));9596 .BHT__Checkbox {97 &__indicator {98 color: var(--md-sys-color-on-error, var(--color-on-primary));99 }100 }101 }102 }103104 &__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;111112 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";45export const BasicCheckbox = () => {6 const [checked, setChecked] = useState(false);7 return (8 <Checkbox9 checked={checked}10 onCheckedChange={(val) => setChecked(val === true)}11 />12 );13};1415export const CheckboxWithFormField = () => {16 const [checked, setChecked] = useState(false);17 return (18 <FormField19 label="Accept terms and conditions"20 htmlFor="accept-terms"21 hint="You can withdraw consent at any time."22 >23 <Checkbox24 id="accept-terms"25 checked={checked}26 onCheckedChange={(val) => setChecked(val === true)}27 />28 </FormField>29 );30};3132export const CheckboxWithError = () => (33 <FormField34 label="Agree to terms"35 htmlFor="agree"36 error="You must agree to continue"37 >38 <Checkbox39 id="agree"40 aria-invalid="true"41 checked={false}42 onCheckedChange={() => {}}43 />44 </FormField>45);4647export const IndeterminateCheckbox = () => (48 <Checkbox checked="indeterminate" onCheckedChange={() => {}} />49);5051export const DisabledCheckbox = () => (52 <Checkbox disabled checked={false} onCheckedChange={() => {}} />53);5455export const RequiredCheckbox = () => {56 const [checked, setChecked] = useState(false);57 return (58 <FormField59 label="I have read the privacy policy"60 htmlFor="privacy"61 required62 >63 <Checkbox64 id="privacy"65 required66 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";45describe("Checkbox", () => {6 it("renders as a checkbox role", () => {7 render(<Checkbox />);8 expect(screen.getByRole("checkbox")).toBeTruthy();9 });1011 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 });1718 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 });2425 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 });3132 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 });3839 it("applies additional className", () => {40 render(<Checkbox className="custom" />);41 expect(screen.getByRole("checkbox").className).toContain("custom");42 });43});