Slide to confirm
Drag the knob; past 55% it finishes itself and stretches into a "Confirmed" pill. Keys work too.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"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. 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;
}
/*
* 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
import { SlideConfirm } from "./SlideConfirm";
import "./SlideConfirm.css";
export default function Demo() {
return <SlideConfirm />;
}