Teseria
All elements

Slide to confirm

Drag the knob; past 55% it finishes itself and stretches into a "Confirmed" pill. Keys work too.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

SlideConfirm.tsx159 lines
"use client";
// SlideConfirm: drag the knob; past 55% it finishes itself and stretches into a "Confirmed" pill. Keys work too.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "framer-motion";
import { Check } from "lucide-react";
import { ArrowRight } from "lucide-react";

export type SlideConfirmCue = "done" | "grab" | "snap" | "pip";

export type SlideConfirmProps = {
  /** Spring stiffness knob, 0..100. Damping follows so it settles without ringing. */
  speed?: number;
  /** Track width in px. */
  width?: number;
  /** Text on the track before sliding. Also the slider's accessible name. */
  label?: string;
  /** Text on the knob once confirmed. */
  doneLabel?: string;
  /** How long the confirmed state holds before it resets, ms. */
  resetAfter?: number;
  /** Called when the slide completes. */
  onConfirm?: () => void;
  /** Sound hook: "grab" on grab, "pip" while nudged by keys (`at` = position 0..1), "snap" when a cancelled drag springs back, "done" on confirm. */
  onSound?: (cue: SlideConfirmCue, opts?: { at?: number }) => void;
  className?: string;
  style?: CSSProperties;
};

const TRACK_H = 56;
const INSET = 4;
const KNOB = 48;
const COMMIT_AT = 0.55;
const OUT_CURVE = [0.22, 1, 0.36, 1] as const;
const SETTLE_CURVE = [0.33, 0.55, 0.2, 1] as const;
function limit(n: number, lo: number, hi: number) {
  return n < lo ? lo : n > hi ? hi : n;
}
const HOME_SPRING = { type: "spring", stiffness: 380, damping: 34, mass: 0.9 } as const;
const PULSE = { duration: 0.42, times: [0, 0.62, 1], ease: SETTLE_CURVE, delay: 0.1 };
const REVEAL = { duration: 0.16, ease: SETTLE_CURVE } as const;

type Hold = { pointer: number; shift: number; travelled: boolean };

export function SlideConfirm({ speed = 50, width = 280, label = "Slide to confirm", doneLabel = "Confirmed", resetAfter = 1500, onConfirm, onSound, className, style }: SlideConfirmProps) {
  const reach = width - 2 * INSET - KNOB;
  const calm = !!useReducedMotion();
  const [confirmed, setConfirmed] = useState(false);
  const [gripping, setGripping] = useState(false);
  const rail = useRef<HTMLDivElement>(null);
  const knobEl = useRef<HTMLButtonElement>(null);
  const hold = useRef<Hold | null>(null);
  const resetTimer = useRef(0);
  const lastTock = useRef(-Infinity);

  const raw = useMotionValue(0);
  const origin = useMotionValue(0); // where the knob stood when it confirmed; it stretches back from there
  const bump = useMotionValue(1);
  const live = useMotionValue(1); // 1 while armed, 0 once confirmed
  const x = useTransform(raw, (v) => limit(v, 0, reach));
  const knobW = useTransform(() => KNOB + limit(origin.get() - x.get(), 0, reach));
  const washW = useTransform([x, knobW], ([p, w]: number[]) => p + w);
  const hint = useTransform(x, [0, reach * COMMIT_AT], [1, 0]);
  const arrow = useTransform(() => live.get() * limit(1 - (x.get() - reach * COMMIT_AT) / (reach * 0.4), 0, 1));

  const stiff = 260 + (limit(speed, 0, 100) / 100) * 640;
  const critical = 2 * Math.sqrt(stiff * 0.9);
  const forward = calm ? { duration: 0 } : { type: "spring" as const, stiffness: stiff, damping: critical, mass: 0.9 };
  const rewind = calm ? { duration: 0 } : { ...forward, damping: critical * 0.62 };

  // the value lives in motion values; mirror it to aria without re-rendering
  useMotionValueEvent(x, "change", (v) => knobEl.current?.setAttribute("aria-valuenow", String(Math.round((v / reach) * 100))));
  useEffect(() => () => clearTimeout(resetTimer.current), []);

  const complete = () => {
    onSound?.("done");
    onConfirm?.();
    setConfirmed(true);
    origin.set(raw.get());
    animate(live, 0, { duration: 0.12, ease: OUT_CURVE });
    animate(raw, 0, forward);
    if (!calm) animate(bump, [1, 0.974, 1], PULSE);
    resetTimer.current = window.setTimeout(() => {
      setConfirmed(false);
      animate(origin, 0, calm ? { duration: 0 } : HOME_SPRING);
      animate(live, 1, { duration: 0.22, delay: 0.12, ease: OUT_CURVE });
    }, resetAfter);
  };
  const letGo = () => {
    hold.current = null;
    setGripping(false);
    animate(raw, 0, rewind);
  };

  const localX = (clientX: number) => {
    const box = rail.current?.getBoundingClientRect();
    return box ? (clientX - box.left) * (width / box.width) : 0;
  };
  const down = (e: PointerEvent<HTMLDivElement>) => {
    if (confirmed) return;
    hold.current = { pointer: e.pointerId, shift: localX(e.clientX) - raw.get(), travelled: false };
    setGripping(true);
    onSound?.("grab");
    try {
      e.currentTarget.setPointerCapture(e.pointerId);
    } catch {}
  };
  const move = (e: PointerEvent<HTMLDivElement>) => {
    const h = hold.current;
    if (!h || h.pointer !== e.pointerId) return;
    const next = limit(localX(e.clientX) - h.shift, 0, reach);
    if (Math.abs(next - raw.get()) > 0.5) h.travelled = true;
    raw.set(next);
  };
  const up = () => {
    const h = hold.current;
    if (!h) return;
    hold.current = null;
    setGripping(false);
    if (raw.get() >= reach * COMMIT_AT) return complete();
    if (h.travelled) onSound?.("snap");
    animate(raw, 0, rewind);
  };
  const key = (e: KeyboardEvent) => {
    if (confirmed) return;
    const here = raw.get();
    const nudge = reach * 0.1;
    const dest: Record<string, number> = { ArrowRight: here + nudge, ArrowUp: here + nudge, ArrowLeft: here - nudge, ArrowDown: here - nudge, Home: 0, End: reach, Enter: reach, " ": reach };
    if (!(e.key in dest)) return;
    e.preventDefault();
    const to = limit(dest[e.key], 0, reach);
    if (to >= reach * COMMIT_AT) return complete();
    const now = performance.now();
    if (now - lastTock.current >= 40) {
      lastTock.current = now;
      onSound?.("pip", { at: to / reach });
    }
    animate(raw, to, forward);
  };

  return (
    <div className={`slc-root${className ? ` ${className}` : ""}`} style={{ width, height: TRACK_H, ...style }}>
      <motion.div ref={rail} className="slc-rail" data-held={gripping || undefined} data-done={confirmed || undefined} style={{ scale: bump }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={letGo} onLostPointerCapture={letGo}>
        <motion.i className="slc-wash" style={{ width: washW }} aria-hidden />
        <motion.span className="slc-hint" style={{ opacity: hint }}>
          {label}
        </motion.span>
        <motion.button ref={knobEl} type="button" className="slc-knob" role="slider" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} aria-valuetext={confirmed ? doneLabel : undefined} style={{ x, width: knobW }} onKeyDown={key}>
          <motion.span className="slc-glyph" style={{ opacity: arrow }} aria-hidden>
            <ArrowRight size={20} strokeWidth={2} />
          </motion.span>
          <motion.span className="slc-glyph" aria-hidden initial={false} animate={{ opacity: confirmed ? 1 : 0, scale: confirmed ? 1 : 0.7 }} transition={REVEAL}>
            <Check size={20} strokeWidth={2} />
            {doneLabel}
          </motion.span>
        </motion.button>
      </motion.div>
    </div>
  );
}
SlideConfirm.css76 lines
/* SlideConfirm. Prefix slc-. */
.slc-root, .slc-root * { box-sizing: border-box; }
.slc-root {
  font-family: var(--ts-font, system-ui, sans-serif);
}
.slc-rail {
  background: var(--ts-raised);
  border-radius: 999px;
  box-shadow: var(--ts-edge);
  cursor: grab;
  height: 100%;
  overflow: hidden;
  position: relative;
  touch-action: none;
  user-select: none;
  width: 100%;
}
.slc-rail[data-held] {
  cursor: grabbing;
}
.slc-rail:has(.slc-knob:focus-visible) {
  box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.slc-wash {
  background: var(--ts-fg);
  border-radius: 24px;
  bottom: 4px;
  left: 4px;
  opacity: 0.12;
  pointer-events: none;
  position: absolute;
  top: 4px;
}
.slc-hint {
  color: rgba(var(--ts-fg-rgb), 0.55);
  display: grid;
  font-size: 14px;
  font-weight: 500;
  inset: 0;
  padding-left: 48px;
  place-items: center;
  pointer-events: none;
  position: absolute;
}
.slc-knob {
  background: var(--ts-fg);
  border: 0;
  border-radius: 999px;
  color: var(--ts-raised);
  cursor: inherit;
  display: grid;
  height: 48px;
  left: 4px;
  margin: 0;
  overflow: hidden;
  padding: 0;
  place-items: center;
  pointer-events: none;
  position: absolute;
  top: 4px;
  white-space: nowrap;
  width: 48px;
}
.slc-knob:focus-visible {
  outline: 0;
}
.slc-glyph {
  align-items: center;
  display: flex;
  font-size: 14px;
  font-weight: 500;
  gap: 8px;
  inset: 0;
  justify-content: center;
  position: absolute;
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { SlideConfirm } from "./SlideConfirm";
import "./SlideConfirm.css";

export default function Demo() {
  return <SlideConfirm />;
}