Toggle Switch Slide
The knob travels across the track while the accent fill comes up under it, settling once.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Toggle Switch Slide
*
* The knob travels across the track while the accent fill comes up
* underneath it, and the whole control settles once. A real switch:
* `role="switch"`, Space/Enter to flip, arrow keys to set a side.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so the row reads correctly on a
* light page and on a dark one.
* Works with zero props; tune via `variant`, `label`, `description`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ToggleSwitchSlideProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Row title. Also the switch's accessible name. */
label?: string;
/** Optional second line under the title. */
description?: string;
/** Starting state. */
defaultOn?: boolean;
/** Fill color of the engaged track. */
accent?: string;
/** Renders the row inert and dimmed. */
disabled?: boolean;
/** Fires with the new state on every flip. */
onToggle?: (on: boolean) => void;
};
type VariantConfig = {
/** Carries the knob across the track. */
knob: { type: "spring"; stiffness: number; damping: number };
/** Seconds for the accent fill to come up under the knob. */
fill: number;
/** How far the control gives under the finger. */
press: number;
};
// Quality rule: every spring here sits at or above a 0.8 damping ratio,
// so the knob lands with at most one soft settle. A switch that wobbles
// at the end of its travel reads as loose rather than lively, and this
// control fires dozens of times in a settings screen. Variants differ in
// tempo and press depth, never in bounce count.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Lands flat, no settle. For long preference lists.
subtle: {
knob: { type: "spring", stiffness: 750, damping: 55 },
fill: 0.1,
press: 0.99,
},
// One soft settle at the end of the travel. All-purpose.
default: {
knob: { type: "spring", stiffness: 500, damping: 40 },
fill: 0.18,
press: 0.96,
},
// A longer throw with a touch more give under the finger, for a
// handful of switches that each mean something.
playful: {
knob: { type: "spring", stiffness: 300, damping: 28 },
fill: 0.29,
press: 0.92,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a track, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const TRACK_WIDTH = 46;
const TRACK_HEIGHT = 28;
const KNOB = 22;
const INSET = (TRACK_HEIGHT - KNOB) / 2;
const TRAVEL = TRACK_WIDTH - KNOB - INSET * 2;
export default function ToggleSwitchSlide({
variant = "default",
label = "Weekly summary",
description = "A digest of workspace activity every Monday.",
defaultOn = false,
accent = "#5B5BD6",
disabled = false,
onToggle,
}: ToggleSwitchSlideProps) {
const [on, setOn] = useState(defaultOn);
const [ring, setRing] = useState(false);
const labelId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const set = (next: boolean) => {
if (disabled || next === on) return;
setOn(next);
onToggle?.(next);
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
width: 300,
color: "inherit",
opacity: disabled ? 0.45 : 1,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div id={labelId} style={{ fontSize: 13.5, fontWeight: 600 }}>
{label}
</div>
{description ? (
<div style={{ fontSize: 12, opacity: 0.55, marginTop: 3, lineHeight: 1.35 }}>
{description}
</div>
) : null}
</div>
<motion.button
type="button"
// `switch` is the role that makes a screen reader announce
// "on"/"off" rather than "checked"; the button element brings
// Space and Enter for free.
role="switch"
aria-checked={on}
aria-labelledby={labelId}
disabled={disabled}
onClick={() => set(!on)}
onKeyDown={(event) => {
// Arrow keys address a side directly, the way a physical
// switch does — pressing "off" twice should not turn it on.
if (event.key === "ArrowRight") {
event.preventDefault();
set(true);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
set(false);
}
}}
// The ring is for keyboard users only. `:focus-visible` is the
// browser's own answer to "was this focus deliberate?" — read it
// instead of guessing at the input modality.
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
whileTap={disabled || reduceMotion ? undefined : { scale: cfg.press }}
style={{
position: "relative",
flexShrink: 0,
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
padding: 0,
borderRadius: TRACK_HEIGHT,
border: "none",
background: tone(16),
color: "inherit",
cursor: disabled ? "not-allowed" : "pointer",
outline: "none",
boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
transition: "box-shadow 140ms ease-out",
WebkitTapHighlightColor: "transparent",
}}
>
{/* The engaged track is its own layer that fades in, so the
running animation stays on opacity and transform rather than
interpolating a background color on the main thread. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: on ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : cfg.fill, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: TRACK_HEIGHT,
background: accent,
}}
/>
<motion.span
aria-hidden
initial={false}
animate={{ x: on ? TRAVEL : 0 }}
// Reduced motion: the knob still changes sides, because its
// position is the state — it just gets there without travel.
transition={reduceMotion ? { duration: 0 } : cfg.knob}
style={{
position: "absolute",
top: INSET,
left: INSET,
width: KNOB,
height: KNOB,
borderRadius: KNOB,
// The knob stays light in both themes: it reads as a physical
// cap riding on the track, which is how every platform switch
// draws it. The track around it is the theme-adaptive part.
background: "#FFFFFF",
boxShadow: "0 1px 3px rgba(0,0,0,0.28), 0 0 0 0.5px rgba(0,0,0,0.06)",
}}
/>
</motion.button>
</div>
);
}About this pattern
The workhorse of every settings screen, built so it survives being fired thirty times in a row. The knob crosses on a spring damped just enough to land with a single soft settle — a switch that wobbles at the end of its throw reads as loose hardware — and the engaged track is a separate layer that fades in underneath rather than a background color interpolated on the main thread. Arrow keys address a side directly, so pressing off twice never turns it on, and the keyboard ring appears only for keyboard focus.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Settings
The knob slides and the track changes colour as a preference flips.
Related patterns
- Checkbox Check DrawThe box fills from its center and the tick strokes itself in over the fill.
- Toggle Group SelectOne highlight slides between grouped buttons and resizes to each label.
- Multi Select ChipsPicked options become chips in the row above the list, and the chips already there slide over to take each new one in.