Overview

A near-imperceptible boolean control — tens of taps a day. The thumb springs across the track with bounce: 0 (~280ms). Press scales to 0.96. Under prefers-reduced-motion, the travel snaps shorter instead of vanishing.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/toggle-switch.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/toggle-switch.tsx
tsx
"use client";
import React, { useCallback, useId, useState } from "react";import { cn } from "@/lib/utils";
export type ToggleSwitchProps = {  checked?: boolean;  defaultChecked?: boolean;  onCheckedChange?: (checked: boolean) => void;  label?: string;  disabled?: boolean;  className?: string;};
export function ToggleSwitch({  checked: checkedProp,  defaultChecked = false,  onCheckedChange,  label,  disabled,  className,}: ToggleSwitchProps) {  const id = useId();  const isControlled = checkedProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultChecked);  const [init, setInit] = useState(false);  const checked = isControlled ? checkedProp : uncontrolled;
  const setChecked = useCallback(    (next: boolean) => {      if (!isControlled) setUncontrolled(next);      onCheckedChange?.(next);    },    [isControlled, onCheckedChange],  );
  const toggle = () => {    if (disabled) return;    if (!init) setInit(true);    setChecked(!checked);  };
  return (    <label      htmlFor={id}      className={cn(        "inline-flex items-center gap-3",        disabled && "cursor-not-allowed opacity-50",        !disabled && "cursor-pointer",        className,      )}    >      <button        id={id}        type="button"        role="switch"        aria-checked={checked}        aria-label={label ?? "Toggle"}        disabled={disabled}        data-on={checked ? "true" : "false"}        onClick={toggle}        onKeyDown={(e) => {          if (e.key === " " || e.key === "Enter") {            e.preventDefault();            toggle();          }        }}        className={cn(          "t-toggle relative h-7 w-12 shrink-0 rounded-full outline-none",          "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-offset-2",          init && "is-init",          checked            ? "bg-[var(--color-ink)]"            : "bg-[var(--color-paper-2)] ring-1 ring-[var(--color-rule)]",        )}        style={          {            // Track is 48×28 with a 24px thumb inset 2px → 20px travel            "--toggle-travel": "20px",          } as React.CSSProperties        }      >        <span          className={cn(            "t-toggle-thumb absolute top-0.5 left-0.5 size-6 rounded-full",            "bg-[var(--color-paper)]",            "shadow-[0_1px_3px_rgba(0,0,0,0.18)]",          )}        />      </button>      {label ? (        <span className="text-sm font-medium text-[var(--color-ink)]">          {label}        </span>      ) : null}    </label>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { ToggleSwitch } from "@/components/ui/toggle-switch";
<ToggleSwitch  label="Daily ops digest"  checked={on}  onCheckedChange={setOn}/>

Props

PropTypeRequiredDefaultDescription
checkedbooleanNoControlled checked state.
defaultCheckedbooleanNofalseUncontrolled initial state.
onCheckedChange(checked: boolean) => voidNoFires when the switch toggles.
labelstringNoVisible label beside the track.
disabledbooleanNoDisables interaction.

Best Practices

  1. Keep the spring short — this control fires often.
  2. Use role="switch" semantics (already wired) and Space/Enter.
  3. Prefer a visible label; aria-label falls back when omitted.