Toggle
v0.4.0A bare toggle/switch control (built on Radix's `Switch`) — the Radix switch 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/toggle · depends on @behivetech/get-class-name
toggle.tsx
1import * as RadixSwitch from "@radix-ui/react-switch";2import { useId } from "react";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./toggle.module.scss";56export interface ToggleProps extends Omit<7 RadixSwitch.SwitchProps,8 "className"9> {10 /** Additional class names to merge with the component root element */11 className?: string;12}1314/**15 * Bare toggle/switch control — the Radix switch button is this component's16 * own root element, no built-in label/hint/error. Wrap it in `FormField` for17 * that chrome: `<FormField label="..." htmlFor="x"><Toggle id="x" /></FormField>`.18 */19export const Toggle = ({ className, id, ...rest }: ToggleProps) => {20 const generatedId = useId();21 const toggleId = id ?? generatedId;2223 const [rootClass, getChildClass] = getClassName({24 className,25 rootClass: "BHT__Toggle",26 styles,27 });2829 return (30 <RadixSwitch.Root {...rest} id={toggleId} className={rootClass}>31 <RadixSwitch.Thumb className={getChildClass("thumb")} />32 </RadixSwitch.Root>33 );34};
toggle.module.scss
1// Material Design 3 switch.2//3// The unchecked track's 2px outline is an inset shadow rather than a border so4// it adds no width — a real border would shrink the padding box the thumb is5// positioned in and throw off the travel calculation below.6//7// `color` on the root is not used for text — it is the thumb state layer's8// tint (`currentColor`), so hover reads on-surface-variant off and primary on.910// Computed: (1.5rem - 1.125rem) / 2 = 0.1875rem11$toggle-width: 2.75rem;12$toggle-height: 1.5rem;13$thumb-size: 1.125rem;14$thumb-offset: 0.1875rem;1516.BHT__Toggle {17 // Radix Switch.Root renders as <button role="switch">18 position: relative;19 width: $toggle-width;20 height: $toggle-height;21 background: var(22 --md-sys-color-surface-container-highest,23 var(--color-surface-light)24 );25 border: none;26 box-shadow: inset 0 0 0 2px27 var(--md-sys-color-outline, var(--color-border-strong));28 border-radius: var(--md-sys-shape-corner-full, 9999px);29 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));30 cursor: pointer;31 padding: 0;32 flex-shrink: 0;33 transition:34 background var(--md-sys-motion-duration-short-3, 150ms)35 var(--md-sys-motion-easing-standard, ease),36 box-shadow var(--md-sys-motion-duration-short-3, 150ms)37 var(--md-sys-motion-easing-standard, ease);3839 // Touch target: extends the hit area to 48px tall without changing the40 // drawn 24px track. px on purpose — a finger doesn't scale with type.41 &::before {42 content: "";43 position: absolute;44 left: 0;45 right: 0;46 top: 50%;47 translate: 0 -50%;48 height: 100%;49 min-height: 48px;50 }5152 &[data-state="checked"] {53 background: var(--md-sys-color-primary, var(--color-primary));54 box-shadow: none;55 color: var(--md-sys-color-primary, var(--color-primary));56 }5758 &:hover:not(:disabled) {59 .BHT__Toggle {60 &__thumb::after {61 opacity: var(--md-sys-state-hover-opacity, 0.08);62 }63 }64 }6566 &:focus-visible {67 outline: 3px solid var(--md-sys-color-secondary, var(--color-border-focus));68 outline-offset: 2px;6970 .BHT__Toggle {71 &__thumb::after {72 opacity: var(--md-sys-state-focus-opacity, 0.1);73 }74 }75 }7677 &:active:not(:disabled) {78 .BHT__Toggle {79 &__thumb::after {80 opacity: var(--md-sys-state-pressed-opacity, 0.1);81 }82 }83 }8485 &:disabled {86 opacity: 0.5;87 cursor: not-allowed;88 }8990 &[aria-invalid="true"] {91 outline: 1px solid var(--md-sys-color-error, var(--color-error));92 outline-offset: 1px;93 color: var(--md-sys-color-error, var(--color-error));9495 &[data-state="checked"] {96 background: var(--md-sys-color-error, var(--color-error));9798 .BHT__Toggle {99 &__thumb {100 background: var(--md-sys-color-on-error, var(--color-on-primary));101 }102 }103 }104 }105106 &__thumb {107 // Radix Switch.Thumb108 display: block;109 position: absolute;110 top: $thumb-offset;111 left: $thumb-offset;112 width: $thumb-size;113 height: $thumb-size;114 background: var(--md-sys-color-outline, var(--color-border-strong));115 border-radius: var(--md-sys-shape-corner-full, 9999px);116 pointer-events: none;117 transition:118 transform var(--md-sys-motion-duration-short-3, 150ms)119 var(--md-sys-motion-easing-standard, ease),120 background var(--md-sys-motion-duration-short-3, 150ms)121 var(--md-sys-motion-easing-standard, ease);122123 // State layer: MD3 draws it as a circle around the handle, not the track.124 &::after {125 content: "";126 position: absolute;127 top: 50%;128 left: 50%;129 translate: -50% -50%;130 width: 2.5rem;131 height: 2.5rem;132 border-radius: var(--md-sys-shape-corner-full, 9999px);133 background: currentColor;134 opacity: 0;135 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)136 var(--md-sys-motion-easing-standard, ease);137 }138139 &[data-state="checked"] {140 background: var(--md-sys-color-on-primary, var(--color-on-primary));141 transform: translateX(142 calc($toggle-width - $thumb-size - ($thumb-offset * 2))143 );144 }145 }146}
index.ts
1export { Toggle } from './toggle.js';2export type { ToggleProps } from './toggle.js';
toggle.composition.tsx
1import { useState } from "react";2import { FormField } from "@behivetech/forms.form-field";3import { Toggle } from "./toggle.js";45export const BasicToggle = () => {6 const [checked, setChecked] = useState(false);7 return <Toggle checked={checked} onCheckedChange={setChecked} />;8};910export const ToggleWithFormField = () => {11 const [checked, setChecked] = useState(false);12 return (13 <FormField14 label="Dark mode"15 htmlFor="dark-mode"16 hint="Switch between light and dark color themes"17 >18 <Toggle id="dark-mode" checked={checked} onCheckedChange={setChecked} />19 </FormField>20 );21};2223export const ToggleWithError = () => (24 <FormField25 label="Required setting"26 htmlFor="required-setting"27 error="This setting must be enabled"28 >29 <Toggle30 id="required-setting"31 aria-invalid="true"32 checked={false}33 onCheckedChange={() => {}}34 />35 </FormField>36);3738export const DisabledToggle = () => (39 <Toggle disabled checked={false} onCheckedChange={() => {}} />40);
toggle.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Toggle } from "./toggle.js";45describe("Toggle", () => {6 it("renders as a switch role", () => {7 render(<Toggle checked={false} onCheckedChange={() => {}} />);8 expect(screen.getByRole("switch")).toBeTruthy();9 });1011 it("calls onCheckedChange when clicked", async () => {12 const handleChange = vi.fn();13 render(<Toggle checked={false} onCheckedChange={handleChange} />);14 await userEvent.click(screen.getByRole("switch"));15 expect(handleChange).toHaveBeenCalledWith(true);16 });1718 it("does not fire onCheckedChange when disabled", async () => {19 const handleChange = vi.fn();20 render(<Toggle disabled checked={false} onCheckedChange={handleChange} />);21 await userEvent.click(screen.getByRole("switch"));22 expect(handleChange).not.toHaveBeenCalled();23 });2425 it("passes aria-invalid through to the root element", () => {26 render(27 <Toggle aria-invalid="true" checked={false} onCheckedChange={() => {}} />,28 );29 expect(screen.getByRole("switch").getAttribute("aria-invalid")).toBe(30 "true",31 );32 });3334 it("reflects checked state via data-state", () => {35 render(<Toggle checked={true} onCheckedChange={() => {}} />);36 expect(screen.getByRole("switch").getAttribute("data-state")).toBe(37 "checked",38 );39 });4041 it("applies additional className", () => {42 render(43 <Toggle className="custom" checked={false} onCheckedChange={() => {}} />,44 );45 expect(screen.getByRole("switch").className).toContain("custom");46 });47});