"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
type ToggleSize = "sm" | "md" | "lg";
export interface AnimatedToggleProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"onChange" | "value" | "defaultValue" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Controlled state. */
checked?: boolean;
/** Initial state when uncontrolled. */
defaultChecked?: boolean;
/** Called with the next state whenever the switch is toggled. */
onCheckedChange?: (checked: boolean) => void;
/** Visible label, associated with the switch via <label htmlFor>. Use aria-label when omitted. */
label?: React.ReactNode;
/** Optional helper text, linked with aria-describedby. */
description?: React.ReactNode;
/** Which side of the switch the label sits on. */
labelPosition?: "start" | "end";
size?: ToggleSize;
/** Track colour when on (any CSS colour). */
accent?: string;
/** Show a small check / cross glyph inside the thumb. */
icons?: boolean;
/** Spring for the thumb's travel and squash. */
spring?: { stiffness: number; damping: number; mass: number };
/** When set, a hidden input submits "on" with forms while checked. */
name?: string;
}
const SIZES: Record<ToggleSize, { w: number; h: number; pad: number; text: string }> = {
sm: { w: 34, h: 20, pad: 2, text: "text-[13px]" },
md: { w: 44, h: 26, pad: 3, text: "text-sm" },
lg: { w: 56, h: 32, pad: 3, text: "text-base" },
};
/**
* A tactile switch: a real <button role="switch"> whose thumb springs across the track and
* squashes in the direction of travel while pressed. Supports controlled and uncontrolled use.
*/
export const AnimatedToggle = React.forwardRef<HTMLButtonElement, AnimatedToggleProps>(function AnimatedToggle(
{
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
label,
description,
labelPosition = "end",
size = "md",
accent = "#FF7448",
icons = false,
spring = { stiffness: 520, damping: 30, mass: 0.8 },
name,
disabled,
id: idProp,
className = "",
style,
onClick,
...props
},
ref,
) {
const reduceMotion = useReducedMotion();
const autoId = React.useId();
const id = idProp ?? `toggle-${autoId}`;
const descriptionId = `${id}-description`;
const [uncontrolled, setUncontrolled] = React.useState(defaultChecked);
const isControlled = checkedProp !== undefined;
const checked = isControlled ? checkedProp : uncontrolled;
const [pressed, setPressed] = React.useState(false);
const dims = SIZES[size];
const thumb = dims.h - dims.pad * 2;
const travel = dims.w - thumb - dims.pad * 2;
const toggle = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
const next = !checked;
if (!isControlled) setUncontrolled(next);
onCheckedChange?.(next);
};
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, ...spring };
// Squash: the thumb stretches toward the side it is about to travel to.
const squash = pressed && !disabled && !reduceMotion;
const control = (
<button
{...props}
ref={ref}
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-describedby={description ? descriptionId : props["aria-describedby"]}
disabled={disabled}
onClick={toggle}
onPointerDown={(e) => {
props.onPointerDown?.(e);
if (e.button === 0) setPressed(true);
}}
onPointerUp={(e) => {
props.onPointerUp?.(e);
setPressed(false);
}}
onPointerLeave={(e) => {
props.onPointerLeave?.(e);
setPressed(false);
}}
onPointerCancel={(e) => {
props.onPointerCancel?.(e);
setPressed(false);
}}
onKeyDown={(e) => {
props.onKeyDown?.(e);
if (e.key === " " || e.key === "Enter") setPressed(true);
}}
onKeyUp={(e) => {
props.onKeyUp?.(e);
setPressed(false);
}}
onBlur={(e) => {
props.onBlur?.(e);
setPressed(false);
}}
className={`group/toggle relative inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-[background-color,border-color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--tg-accent)] disabled:cursor-not-allowed disabled:opacity-45 motion-reduce:transition-none ${
checked
? "border-transparent bg-[var(--tg-accent)]"
: "border-zinc-900/10 bg-zinc-900/[0.08] hover:bg-zinc-900/[0.12] dark:border-white/10 dark:bg-white/[0.08] dark:hover:bg-white/[0.12]"
}`}
style={{ width: dims.w, height: dims.h, padding: dims.pad - 1 }}
>
<motion.span
aria-hidden="true"
className="relative grid place-items-center rounded-full bg-white shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_6px_-1px_rgba(0,0,0,0.14)] dark:bg-zinc-50"
style={{ width: thumb, height: thumb, originX: checked ? 1 : 0 }}
initial={false}
animate={{
x: checked ? travel : 0,
scaleX: squash ? 1.22 : 1,
scaleY: squash ? 0.9 : 1,
}}
transition={transition}
>
{icons && (
<svg viewBox="0 0 12 12" className="size-[55%]" fill="none" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M2.5 6.3 5 8.6 9.5 3.6"
stroke="var(--tg-accent)"
initial={false}
animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.22, delay: checked ? 0.06 : 0 }}
/>
<motion.path
d="M3.5 3.5 8.5 8.5M8.5 3.5 3.5 8.5"
className="stroke-zinc-400"
initial={false}
animate={{ pathLength: checked ? 0 : 1, opacity: checked ? 0 : 1 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }}
/>
</svg>
)}
</motion.span>
</button>
);
// Mirrors checkbox semantics: the field is only submitted while on.
const hiddenInput = name && checked ? <input type="hidden" name={name} value="on" /> : null;
if (!label && !description) {
return (
<span
className={`inline-flex ${className}`}
style={{ ["--tg-accent" as string]: accent, ...style }}
>
{control}
{hiddenInput}
</span>
);
}
return (
<span
className={`inline-flex items-start gap-3 ${labelPosition === "start" ? "flex-row-reverse" : ""} ${className}`}
style={{ ["--tg-accent" as string]: accent, ...style }}
>
{control}
<span className="flex min-w-0 flex-col" style={{ paddingTop: Math.max(0, (dims.h - 20) / 2) }}>
{label && (
<label
htmlFor={id}
className={`select-none font-medium leading-5 tracking-tight text-zinc-900 dark:text-zinc-100 ${dims.text} ${
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
}`}
>
{label}
</label>
)}
{description && (
<span id={descriptionId} className={`mt-0.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400 ${disabled ? "opacity-60" : ""}`}>
{description}
</span>
)}
</span>
{hiddenInput}
</span>
);
});