Turns a setting on or off immediately. Click it, or swipe the thumb across. It stretches while pressed and springs into place.
pnpm dlx shadcn@latest add @latheui/switchFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { animate, motion, useMotionValue } from "motion/react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { spring, useMotionSafe } from "@/lib/motion"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
export type SwitchProps = Omit<
React.ComponentProps<typeof SwitchPrimitive.Root>,
"checked" | "defaultChecked" | "onCheckedChange"
> & {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
size?: "sm" | "md"
}
/** Thumb size and how far it travels, in px, for each size. */
const metrics = {
sm: { thumb: 12, travel: 12 },
md: { thumb: 16, travel: 16 },
} as const
/** How much wider the thumb gets while pressed. */
const STRETCH = 4
/** Pixels the pointer must move before a press becomes a swipe. */
const SLOP = 3
function Switch({
checked: checkedProp,
defaultChecked = false,
onCheckedChange,
size = "md",
className,
disabled,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onClick,
...props
}: SwitchProps) {
const [checked, setChecked] = useControllableState({
value: checkedProp,
defaultValue: defaultChecked,
onChange: onCheckedChange,
})
const { reduced } = useMotionSafe()
const { thumb, travel } = metrics[size]
const [pressed, setPressed] = React.useState(false)
const stretch = pressed && !reduced ? STRETCH : 0
const x = useMotionValue<number>(checked ? travel : 0)
const width = useMotionValue<number>(thumb)
const drag = React.useRef<{
pointerId: number
startX: number
origin: number
moved: boolean
} | null>(null)
// A swipe already decided the state, so the click that follows it must not toggle again.
const swallowClick = React.useRef(false)
// At rest the thumb sits at its end; pressed, it stretches toward the other end.
React.useEffect(() => {
if (drag.current?.moved) return
const to = { x: checked ? travel - stretch : 0, width: thumb + stretch }
if (reduced) {
x.jump(to.x)
width.jump(to.width)
return
}
const a = animate(x, to.x, spring.snappy)
const b = animate(width, to.width, { duration: 0.15, ease: "easeOut" })
return () => {
a.stop()
b.stop()
}
}, [checked, stretch, travel, thumb, reduced, x, width])
const release = (pointerId: number, cancel = false) => {
const state = drag.current
if (!state || state.pointerId !== pointerId) return
drag.current = null
setPressed(false)
if (!state.moved) return
swallowClick.current = true
const next = cancel ? checked : x.get() + width.get() / 2 > (travel + thumb) / 2
if (next !== checked) setChecked(next)
else {
// Let go on the side it started from: spring back.
const to = next ? travel : 0
if (reduced) x.jump(to)
else animate(x, to, spring.snappy)
animate(width, thumb, { duration: 0.15, ease: "easeOut" })
}
}
return (
<SwitchPrimitive.Root
data-slot="switch"
checked={checked}
onCheckedChange={setChecked}
disabled={disabled}
className={cn(
"group inline-flex shrink-0 cursor-pointer touch-pan-y items-center rounded-full bg-input p-0.5 outline-none select-none",
"transition-colors duration-200 data-[state=checked]:bg-primary",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "h-4 w-7" : "h-5 w-9",
className
)}
onPointerDown={(event) => {
onPointerDown?.(event)
if (disabled || event.button !== 0 || event.defaultPrevented) return
event.currentTarget.setPointerCapture(event.pointerId)
swallowClick.current = false
drag.current = {
pointerId: event.pointerId,
startX: event.clientX,
origin: x.get(),
moved: false,
}
setPressed(true)
}}
onPointerMove={(event) => {
onPointerMove?.(event)
const state = drag.current
if (!state || state.pointerId !== event.pointerId) return
const dx = event.clientX - state.startX
if (!state.moved) {
if (Math.abs(dx) < SLOP) return
state.moved = true
// Take over from the stretch animation that started on press.
x.stop()
}
// The thumb stays under the finger, held inside the track.
const max = travel + thumb - width.get()
x.set(Math.min(max, Math.max(0, state.origin + dx)))
}}
onPointerUp={(event) => {
onPointerUp?.(event)
release(event.pointerId)
}}
onPointerCancel={(event) => {
onPointerCancel?.(event)
release(event.pointerId, true)
}}
onClick={(event) => {
onClick?.(event)
if (swallowClick.current) {
swallowClick.current = false
event.preventDefault()
}
}}
{...props}
>
<SwitchPrimitive.Thumb {...slot}>
<motion.span
style={{ x, width }}
className={cn(
"block rounded-full bg-background shadow-sm",
size === "sm" ? "h-3" : "h-4"
)}
/>
</SwitchPrimitive.Thumb>
</SwitchPrimitive.Root>
)
}
export { Switch }Depends on
Also accepts Radix Switch.Root props such as name, value, required and disabled.
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | — | Controlled state. |
| defaultChecked | boolean | false | Initial state when uncontrolled. |
| onCheckedChange | (checked: boolean) => void | — | Called when the switch is toggled. |
| size | "sm" | "md" | "md" | Track size. |
| disabled | boolean | false | Prevents interaction. |
<Switch className="data-[state=checked]:bg-chart-4" />