All patterns

Toggle Switch Slide

The knob travels across the track while the accent fill comes up under it, settling once.

formsfriendlyminimalinteraction · finite · starter · ~0.3s
Interactive · click to play
Variant

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.

211 lines · react + motion only
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.

Settings rowNotification preferencesFeature flag controlPrivacy controls

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Settings
    General
    Notifications
    Members
    Billing
    SettingsNew
    Desktop notificationsAlert on mention and reply
    Weekly digestEvery Monday at 09:00
    SoundsPlay a tone for new messages
    Follow repliesTrack threads you post in
    Settings

    The knob slides and the track changes colour as a preference flips.

Related patterns