ui
FreeSlideToggle
Two-option toggle with a draggable, spring-loaded thumb that squashes and stretches with velocity, leans toward the side you hover, and works with or without labels. Three sizes.
// preview
LightDark
selected: dark — tap, drag the thumb, or press space
// settings
Size (px)
Omit both labels to use it as a bare on/off switch. Hover a side and the thumb leans toward it; drag it and the labels crossfade with the live position.
// source
TSJS
TailwindCSS
/*!
* SlideToggle — a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/slide-toggle
* MIT licensed — keep this notice in copies and adaptations.
*/
"use client";
import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
export interface SlideToggleOption<T extends string> {
value: T;
/** Optional — omit labels on both options for a bare on/off switch. */
label?: ReactNode;
}
export interface SlideToggleProps<T extends string> {
/** Exactly two options; the thumb slides between them. */
options: readonly [SlideToggleOption<T>, SlideToggleOption<T>];
/** Controlled value. Omit to let the toggle manage its own state. */
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
/** Control height in px; everything else scales from it. */
size?: number;
disabled?: boolean;
/** Accessible name for the group, e.g. "Language". */
ariaLabel?: string;
className?: string;
}
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
// Tight spring while the pointer is dragging the thumb, loose spring on
// release so it overshoots its slot and wobbles back — same motion language
// as our Magnet component.
const DRAG_STIFFNESS = 0.4;
const DRAG_DAMPING = 0.6;
const RELEASE_STIFFNESS = 0.14;
const RELEASE_DAMPING = 0.78;
// How far the thumb leans toward the other side while you hover over it —
// a small "come here" affordance before any click.
const HOVER_LEAN = 0.08;
const THUMB_INSET = 3; // px padding between thumb and track edge
// Label ink crossfades between these as the thumb slides underneath:
// dark ink on the solid white thumb, soft white off it.
const INK_ON_THUMB = { r: 20, g: 16, b: 29, a: 1 };
const INK_OFF_THUMB = { r: 255, g: 255, b: 255, a: 0.65 };
/** Everything scales off the control height so any size stays proportioned. */
function sizeStyles(size: number, hasLabels: boolean) {
return {
container: {
height: `${size}px`,
minWidth: hasLabels ? `${size * 4.6}px` : `${size * 2}px`,
},
label: {
fontSize: `${Math.max(9, Math.round(size * 0.38))}px`,
padding: `0 ${Math.round(size * 0.45)}px`,
},
};
}
export default function SlideToggle<T extends string>({
options,
value,
defaultValue,
onChange,
size = 28,
disabled = false,
ariaLabel,
className = "",
}: SlideToggleProps<T>) {
const [internalValue, setInternalValue] = useState<T>(defaultValue ?? options[0].value);
const selected = value ?? internalValue;
const selectedIndex = selected === options[1].value ? 1 : 0;
const hasLabels = options[0].label != null || options[1].label != null;
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([null, null]);
// Spring state lives outside React so drag/animation never re-renders.
const physics = useRef({
position: selectedIndex, // 0..1 = left..right slot
velocity: 0,
target: selectedIndex,
dragging: false,
frame: 0,
running: false,
reducedMotion: false,
});
const select = useCallback(
(next: T) => {
if (next !== (value ?? internalValue)) {
setInternalValue(next);
onChange?.(next);
// A tiny tactile tick on devices that support it.
if (typeof navigator !== "undefined") navigator.vibrate?.(8);
}
},
[value, internalValue, onChange],
);
/** Paint thumb + label ink for a given spring position/velocity. */
const render = useCallback((position: number, velocity: number) => {
const track = trackRef.current;
const thumb = thumbRef.current;
if (!track || !thumb) return;
const travel = track.clientWidth / 2 - THUMB_INSET;
// Velocity-based squash & stretch: a fast thumb goes long and flat.
const stretch = clamp(Math.abs(velocity) * 1.4, 0, 0.22);
thumb.style.transform =
`translateX(${position * travel}px) scaleX(${1 + stretch}) scaleY(${1 - stretch * 0.6})`;
// Label ink follows the live thumb position, not the committed state,
// so a drag crossfades the labels in real time.
for (let i = 0; i < 2; i++) {
const label = labelRefs.current[i];
if (!label) continue;
const p = clamp(i === 0 ? 1 - position : position, 0, 1);
const r = Math.round(INK_OFF_THUMB.r + (INK_ON_THUMB.r - INK_OFF_THUMB.r) * p);
const g = Math.round(INK_OFF_THUMB.g + (INK_ON_THUMB.g - INK_OFF_THUMB.g) * p);
const b = Math.round(INK_OFF_THUMB.b + (INK_ON_THUMB.b - INK_OFF_THUMB.b) * p);
const a = INK_OFF_THUMB.a + (INK_ON_THUMB.a - INK_OFF_THUMB.a) * p;
label.style.color = `rgba(${r}, ${g}, ${b}, ${a})`;
}
}, []);
/** Single owner of the animation loop; safe to call repeatedly. */
const wake = useCallback(() => {
const state = physics.current;
if (state.reducedMotion) {
state.position = state.target;
render(state.position, 0);
return;
}
if (state.running) return;
state.running = true;
const tick = () => {
const k = state.dragging ? DRAG_STIFFNESS : RELEASE_STIFFNESS;
const d = state.dragging ? DRAG_DAMPING : RELEASE_DAMPING;
state.velocity = (state.velocity + (state.target - state.position) * k) * d;
state.position += state.velocity;
render(state.position, state.velocity);
const settled =
!state.dragging &&
Math.abs(state.velocity) < 0.001 &&
Math.abs(state.target - state.position) < 0.001;
if (settled) {
state.position = state.target;
render(state.position, 0);
state.running = false;
return;
}
state.frame = requestAnimationFrame(tick);
};
state.frame = requestAnimationFrame(tick);
}, [render]);
useEffect(() => {
const state = physics.current;
state.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
state.target = selectedIndex;
wake();
return () => {
cancelAnimationFrame(state.frame);
state.running = false;
};
}, [selectedIndex, wake]);
// Pointer interaction: tap either side to select it, or grab the thumb
// and slide — release commits to whichever slot is nearest.
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const track = trackRef.current;
if (!track) return;
const state = physics.current;
const rect = track.getBoundingClientRect();
const startX = event.clientX;
let moved = false;
const onMove = (e: PointerEvent) => {
if (!moved && Math.abs(e.clientX - startX) < 4) return;
moved = true;
state.dragging = true;
// Map the pointer to the thumb-center position along the track.
const rel = (e.clientX - rect.left - rect.width / 4) / (rect.width / 2);
state.target = clamp(rel, 0, 1);
wake();
};
const onUp = (e: PointerEvent) => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
if (moved) {
state.dragging = false;
const nearest = state.position >= 0.5 ? 1 : 0;
state.target = nearest;
select(options[nearest].value);
} else {
const side = e.clientX < rect.left + rect.width / 2 ? 0 : 1;
select(options[side].value);
}
wake();
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}
// Hover lean: while hovering the inactive side, the thumb tips slightly
// toward it — a pre-click hint that the surface responds.
function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {
if (disabled) return;
const state = physics.current;
if (state.dragging) return;
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const side = event.clientX < rect.left + rect.width / 2 ? 0 : 1;
state.target =
side === selectedIndex
? selectedIndex
: selectedIndex + (side - selectedIndex) * HOVER_LEAN;
wake();
}
function onPointerLeave() {
const state = physics.current;
if (state.dragging) return;
state.target = selectedIndex;
wake();
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (disabled) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
select(options[selectedIndex === 0 ? 1 : 0].value);
}
}
return (
<div
ref={trackRef}
role="radiogroup"
aria-label={ariaLabel}
tabIndex={disabled ? -1 : 0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
style={sizeStyles(size, hasLabels).container}
className={`relative inline-grid cursor-pointer select-none touch-none grid-cols-2 rounded-full border border-white/10 bg-white/5 p-[3px] outline-none transition-colors focus-visible:border-white/30 focus-visible:ring-2 focus-visible:ring-white/20 ${
disabled ? "cursor-not-allowed opacity-40" : ""
} ${className}`}
>
<div
ref={thumbRef}
aria-hidden="true"
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-white shadow-[0_1px_4px_rgba(0,0,0,0.35)] will-change-transform"
/>
{options.map((option, index) => (
<span
key={option.value}
role="radio"
aria-checked={index === selectedIndex}
aria-label={typeof option.label === "string" ? undefined : option.value}
ref={(el) => {
labelRefs.current[index] = el;
}}
style={sizeStyles(size, hasLabels).label}
className="relative z-10 flex items-center justify-center text-center font-mono uppercase tracking-widest"
>
{option.label}
</span>
))}
</div>
);
}
// install
npx shadcn@latest add "https://designpass.dev/r/SlideToggle-TS-TW.json"Need the license details? Read the component license.