Switch
On and off switch with a quick slide and no bounce. Inside a label, the whole row is the tap target.
"use client";
import { Switch } from "@/components/motion/switch";
const SETTINGS = [
{ id: "digest", title: "Weekly digest", detail: "A summary every Monday", on: true },
{ id: "mentions", title: "Mentions", detail: "When someone tags you", on: true },
{ id: "sounds", title: "Sounds", detail: "Play a chime for new messages", on: false },
];
export function SwitchPreview() {
return (
<div className="flex w-full max-w-xs flex-col rounded-2xl bg-background p-1.5 shadow-[0_0_0_1px_var(--border)]">
{SETTINGS.map((setting) => (
<label
key={setting.id}
htmlFor={`setting-${setting.id}`}
className="flex cursor-pointer items-center justify-between gap-4 rounded-xl px-3 py-2.5 transition-colors duration-150 hover:bg-muted"
>
<span className="flex flex-col text-sm">
<span className="font-medium text-foreground">{setting.title}</span>
<span className="text-xs text-muted-foreground">{setting.detail}</span>
</span>
<Switch id={`setting-${setting.id}`} defaultChecked={setting.on} />
</label>
))}
</div>
);
}
"use client";
// easeui.dev/components/motion/switch
import { type ButtonHTMLAttributes, forwardRef, useState } from "react";
import { cn } from "@/lib/utils";
export type SwitchSize = "sm" | "md";
export interface SwitchProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "defaultValue"> {
/** Controlled on state. */
checked?: boolean;
/** Starting state when uncontrolled. Default false. */
defaultChecked?: boolean;
/** Called with the next state each time the switch is toggled. */
onCheckedChange?: (checked: boolean) => void;
/** Track size. Default "md". */
size?: SwitchSize;
}
const SIZES: Record<SwitchSize, { track: string; thumb: string }> = {
sm: { track: "h-5 w-9", thumb: "h-4 w-4 group-data-[state=on]:translate-x-4" },
md: { track: "h-6 w-11", thumb: "h-5 w-5 group-data-[state=on]:translate-x-5" },
};
/**
* An on and off switch. Put it inside a label and the whole row becomes the
* tap target, with no dead space between the text and the track.
*/
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch(
{ checked, defaultChecked = false, onCheckedChange, size = "md", className, onClick, ...props },
ref,
) {
const [uncontrolled, setUncontrolled] = useState(defaultChecked);
const isControlled = checked !== undefined;
const on = isControlled ? checked : uncontrolled;
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={on}
data-state={on ? "on" : "off"}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (!isControlled) setUncontrolled(!on);
onCheckedChange?.(!on);
}}
className={cn(
"group relative inline-flex shrink-0 touch-manipulation items-center rounded-full p-0.5 outline-none",
"bg-foreground/15 transition-colors duration-150 ease-out data-[state=on]:bg-accent",
"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
// Grows the hit area to about 44px tall without changing the layout.
"after:absolute after:-inset-x-1 after:-inset-y-2.5",
SIZES[size].track,
className,
)}
{...props}
>
<span
aria-hidden="true"
className={cn(
"block rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.25)] transition-[translate] duration-150 ease-out motion-reduce:transition-none",
SIZES[size].thumb,
)}
/>
</button>
);
});
Installation
$ bunx --bun shadcn add @easeui/switch
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx tailwind-merge - 3
Add the source files
components/motion/switch.tsx "use client"; // easeui.dev/components/motion/switch import { type ButtonHTMLAttributes, forwardRef, useState } from "react"; import { cn } from "@/lib/utils"; export type SwitchSize = "sm" | "md"; export interface SwitchProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onChange" | "value" | "defaultValue"> { /** Controlled on state. */ checked?: boolean; /** Starting state when uncontrolled. Default false. */ defaultChecked?: boolean; /** Called with the next state each time the switch is toggled. */ onCheckedChange?: (checked: boolean) => void; /** Track size. Default "md". */ size?: SwitchSize; } const SIZES: Record<SwitchSize, { track: string; thumb: string }> = { sm: { track: "h-5 w-9", thumb: "h-4 w-4 group-data-[state=on]:translate-x-4" }, md: { track: "h-6 w-11", thumb: "h-5 w-5 group-data-[state=on]:translate-x-5" }, }; /** * An on and off switch. Put it inside a label and the whole row becomes the * tap target, with no dead space between the text and the track. */ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(function Switch( { checked, defaultChecked = false, onCheckedChange, size = "md", className, onClick, ...props }, ref, ) { const [uncontrolled, setUncontrolled] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : uncontrolled; return ( <button ref={ref} type="button" role="switch" aria-checked={on} data-state={on ? "on" : "off"} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) return; if (!isControlled) setUncontrolled(!on); onCheckedChange?.(!on); }} className={cn( "group relative inline-flex shrink-0 touch-manipulation items-center rounded-full p-0.5 outline-none", "bg-foreground/15 transition-colors duration-150 ease-out data-[state=on]:bg-accent", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:cursor-not-allowed disabled:opacity-50", // Grows the hit area to about 44px tall without changing the layout. "after:absolute after:-inset-x-1 after:-inset-y-2.5", SIZES[size].track, className, )} {...props} > <span aria-hidden="true" className={cn( "block rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/0.25)] transition-[translate] duration-150 ease-out motion-reduce:transition-none", SIZES[size].thumb, )} /> </button> ); });lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| checked? | boolean | - | Controlled on state. |
| defaultChecked? | boolean | false | Starting state when uncontrolled. Default false. |
| onCheckedChange? | ((checked: boolean) => void) | - | Called with the next state each time the switch is toggled. |
| size? | "sm" | "md" | md | Track size. Default "md". |
| className? | string | - | - |
Updated