1"use client";
2
3import React, { useId, useState } from "react";
4
5interface ToggleProps {
6 checked?: boolean;
7 defaultChecked?: boolean;
8 onChange?: (checked: boolean) => void;
9 label?: string;
10 description?: string;
11 disabled?: boolean;
12}
13
14export function Toggle({
15 checked,
16 defaultChecked = false,
17 onChange,
18 label,
19 description,
20 disabled = false,
21}: ToggleProps) {
22 const [internal, setInternal] = useState(defaultChecked);
23 const [pressed, setPressed] = useState(false);
24 const uid = useId();
25 const isOn = checked !== undefined ? checked : internal;
26
27 const toggle = () => {
28 if (disabled) return;
29 const next = !isOn;
30 setInternal(next);
31 onChange?.(next);
32 };
33
34 return (
35 <div
36 className={[
37 "flex items-center justify-between gap-4 select-none",
38 disabled ? "opacity-35 pointer-events-none" : "cursor-pointer",
39 ].join(" ")}
40 onClick={toggle}
41 >
42 {(label || description) && (
43 <div className="flex flex-col gap-0.5 min-w-0">
44 {label && (
45 <span id={`${uid}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">
46 {label}
47 </span>
48 )}
49 {description && (
50 <span id={`${uid}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">
51 {description}
52 </span>
53 )}
54 </div>
55 )}
56
57 {/* Clicks bubble to the row, so the switch itself has no onClick */}
58 <button
59 type="button"
60 role="switch"
61 aria-checked={isOn}
62 aria-labelledby={label ? `${uid}-label` : undefined}
63 aria-describedby={description ? `${uid}-desc` : undefined}
64 disabled={disabled}
65 onPointerDown={() => setPressed(true)}
66 onPointerUp={() => setPressed(false)}
67 onPointerLeave={() => setPressed(false)}
68 className={[
69 "relative shrink-0 w-[46px] h-[26px] rounded-[13px] cursor-pointer",
70 "transition-[background-color,box-shadow] duration-200 ease-out",
71 "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
72 isOn ? "bg-neutral-900" : "bg-neutral-200/70",
73 ].join(" ")}
74 style={{
75 /* The track is a recessed channel; the knob is a raised face inside it */
76 boxShadow: isOn
77 ? "inset 0 1px 3px rgba(0,0,0,0.5), inset 0 0 2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(255,255,255,0.48)"
78 : "inset 0 1px 2px rgba(17,17,17,0.12), inset 0 0 2px rgba(17,17,17,0.12), 0 1px 2px -1px rgba(255,255,255,0.64)",
79 }}
80 >
81 <span
82 aria-hidden
83 className="absolute top-1/2 h-5 rounded-[10px]"
84 style={{
85 left: 3,
86 /* The knob stretches while held, like a finger dragging it */
87 width: pressed ? 24 : 20,
88 background: "var(--face-light)",
89 boxShadow:
90 "0 0 3px rgba(17,17,17,0.119), 0 1px 2px rgba(0,0,0,0.14), 0 3px 6px -1px rgba(0,0,0,0.12), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(0,0,0,0.04)",
91 transform: `translateX(${isOn ? (pressed ? 16 : 20) : 0}px) translateY(-50%)`,
92 transition:
93 "transform 260ms var(--ease-spring), width 200ms var(--ease-out-soft)",
94 }}
95 />
96 </button>
97 </div>
98 );
99}