Throw
Slide-to-commit track and swipe-action rows. Throw: drag the knob (or arrow to 100% and press Enter) past the threshold and it snaps home, floods the track and turns into a check; the label shimmers and fades as the knob passes, early releases spring back. ThrowRow: swipe a list row to reveal leading/trailing actions with rubber-banding and scale-in icons; a full swipe past 60% fires the primary and the row collapses on a height spring. Slider semantics, arrow-key reveal, ref handles; tweens only under reduced motion.
motionfree
"use client";
import {
AnimatePresence,
animate,
motion,
useInView,
useMotionValue,
useMotionValueEvent,
useTransform,
type AnimationPlaybackControls,
} from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
/* -------------------------------------------------------------------------- */
/* shared */
/* -------------------------------------------------------------------------- */
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Picks a legible ink (near-black or white) for text drawn on `hex`. */
function inkOn(color: string): string {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
if (!m) return "#0a0a0a";
let h = m[1];
if (h.length === 3) h = h.replace(/./g, (c) => c + c);
const lin = (i: number) => {
const c = parseInt(h.slice(i, i + 2), 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const L = 0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4);
// Contrast against black vs white — whichever wins.
return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? "#0a0a0a" : "#ffffff";
}
const SPRING = { type: "spring", stiffness: 520, damping: 38, mass: 0.9 } as const;
const SOFT = { type: "spring", stiffness: 300, damping: 30 } as const;
const QUICK = { duration: 0.14, ease: "easeOut" } as const;
/* -------------------------------------------------------------------------- */
/* Throw */
/* -------------------------------------------------------------------------- */
/** Imperative handle for {@link Throw}. */
export interface ThrowHandle {
/** Return the knob to the start and clear the committed state. */
reset(): void;
/** Move keyboard focus to the knob. */
focus(): void;
}
export interface ThrowProps {
/** Instruction text inside the track; also the slider's accessible name. @default "Slide to confirm" */
label?: string;
/** Text shown in the filled track once committed. @default "Done" */
committedLabel?: string;
/** Fires once when the knob is thrown past the threshold (or Enter is pressed at the end). */
onCommit?: () => void;
/** Fraction of the travel (0–1) the knob must pass on release to commit. @default 0.85 */
threshold?: number;
/** Fill colour behind the knob and of the committed track. Any CSS colour; hex gets auto-contrast ink. @default "#f5f5f5" */
accentColor?: string;
/** Disable dragging and keys. @default false */
disabled?: boolean;
/** Idle hint — the knob nudges forward every few seconds until first touched. @default true */
hint?: boolean;
/** Freeze the shimmer and idle hint. @default false */
paused?: boolean;
className?: string;
/** Imperative handle — `{ reset(), focus() }`. */
ref?: React.Ref<ThrowHandle>;
}
const KNOB = 48;
const PAD = 4;
/**
* Throw — a slide-to-commit track. Drag the knob (or use the arrow keys) to the
* end: past `threshold` on release it snaps home and commits, the track floods
* with the accent, the knob turns into a check. Released early it springs back.
* The instruction label shimmers and fades as the knob passes over it.
*
* Accessibility: the knob is a `role="slider"` (0–100, with valuetext);
* Arrow/PageUp/PageDown/Home/End move it and Enter or Space commits once it sits
* at 100%. Reduced motion drops the shimmer, springs and idle hint.
*/
export function Throw({
label = "Slide to confirm",
committedLabel = "Done",
onCommit,
threshold = 0.85,
accentColor = "#f5f5f5",
disabled = false,
hint = true,
paused = false,
className,
ref,
}: ThrowProps) {
const reduced = useReducedMotion();
const still = reduced || paused;
const trackRef = React.useRef<HTMLDivElement>(null);
const knobRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(trackRef, { amount: 0.4 });
const descId = React.useId();
const x = useMotionValue(0);
const max = useMotionValue(0);
const progress = useTransform([x, max], ([a, b]: number[]) => (b > 0 ? clamp(a / b, 0, 1) : 0));
const fillW = useTransform(x, (v) => v + KNOB);
const labelOpacity = useTransform(progress, [0, 0.55], [1, 0]);
const labelShift = useTransform(progress, [0, 1], [0, 18]);
const [committed, setCommitted] = React.useState(false);
const [pct, setPct] = React.useState(0);
const [dragging, setDragging] = React.useState(false);
const [armed, setArmed] = React.useState(false);
const [flash, setFlash] = React.useState(0);
const anim = React.useRef<AnimationPlaybackControls | null>(null);
const touched = React.useRef(false);
const drag = React.useRef<{ id: number; sx: number; start: number } | null>(null);
const committedRef = React.useRef(false);
const onCommitRef = React.useRef(onCommit);
React.useEffect(() => {
onCommitRef.current = onCommit;
});
const run = React.useCallback(
(to: number | number[], t: object) => {
anim.current?.stop();
anim.current = animate(x, to as number, t);
},
[x]
);
// Track width → travel. A committed knob stays pinned to the end on resize.
React.useLayoutEffect(() => {
const el = trackRef.current;
if (!el) return;
const measure = () => {
const m = Math.max(0, el.clientWidth - KNOB - PAD * 2);
max.set(m);
if (committedRef.current) x.set(m);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [max, x]);
useMotionValueEvent(progress, "change", (p) => {
if (drag.current) setArmed(p >= threshold);
});
const commit = React.useCallback(() => {
if (committedRef.current) return;
committedRef.current = true;
touched.current = true;
setCommitted(true);
setArmed(false);
setPct(100);
setFlash((n) => n + 1);
run(max.get(), still ? QUICK : SOFT);
onCommitRef.current?.();
}, [max, run, still]);
const goTo = React.useCallback(
(p: number) => {
const next = clamp(p, 0, 1);
setPct(Math.round(next * 100));
run(next * max.get(), still ? QUICK : SPRING);
},
[max, run, still]
);
const reset = React.useCallback(() => {
committedRef.current = false;
setCommitted(false);
setArmed(false);
setPct(0);
run(0, still ? QUICK : SOFT);
}, [run, still]);
React.useImperativeHandle(
ref,
() => ({ reset, focus: () => knobRef.current?.focus() }),
[reset]
);
// Idle hint: a seeded-cadence nudge that teaches the gesture. Also serves as
// the designed intro (first nudge lands shortly after the track enters view).
React.useEffect(() => {
if (!hint || still || disabled || committed || !inView) return;
let timer = 0;
const tease = () => {
const el = knobRef.current;
if (
touched.current ||
committedRef.current ||
drag.current ||
(el && document.activeElement === el)
)
return;
const m = max.get();
run([0, m * 0.24, 0], { duration: 1.15, times: [0, 0.38, 1], ease: ["easeOut", [0.5, 0, 0.2, 1]] });
timer = window.setTimeout(tease, 4200);
};
timer = window.setTimeout(tease, 700);
return () => window.clearTimeout(timer);
}, [hint, still, disabled, committed, inView, max, run]);
React.useEffect(() => () => anim.current?.stop(), []);
/* ------------------------------- pointer -------------------------------- */
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || committedRef.current || e.button !== 0) return;
e.preventDefault();
touched.current = true;
anim.current?.stop();
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.focus({ preventScroll: true });
drag.current = { id: e.pointerId, sx: e.clientX, start: x.get() };
setDragging(true);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const m = max.get();
const nx = clamp(d.start + e.clientX - d.sx, 0, m);
x.set(nx);
setPct(m > 0 ? Math.round((nx / m) * 100) : 0);
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
setDragging(false);
setArmed(false);
if (progress.get() >= threshold) commit();
else goTo(0);
};
/* ------------------------------ keyboard -------------------------------- */
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (committedRef.current) return;
const p = pct / 100;
const big = e.shiftKey ? 0.25 : 0.1;
let next: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = p + big;
break;
case "ArrowLeft":
case "ArrowDown":
next = p - big;
break;
case "PageUp":
next = p + 0.25;
break;
case "PageDown":
next = p - 0.25;
break;
case "Home":
next = 0;
break;
case "End":
next = 1;
break;
case "Enter":
case " ":
e.preventDefault();
if (pct >= 100) commit();
return;
default:
return;
}
e.preventDefault();
touched.current = true;
goTo(next);
};
// A half-thrown knob left by the keyboard slides home when focus leaves.
const onBlur = () => {
if (!committedRef.current && !drag.current && pct > 0) goTo(0);
};
const valueText = committed
? committedLabel
: pct >= 100
? "At the end — press Enter to commit"
: `${pct}%`;
const ink = inkOn(accentColor);
const tint = (a: number) => `color-mix(in srgb, ${accentColor} ${a}%, transparent)`;
return (
<div
ref={trackRef}
data-crucible="throw"
data-committed={committed || undefined}
className={cn(
"relative isolate h-14 w-full max-w-xs touch-pan-y select-none rounded-full",
"border border-white/[0.07] bg-neutral-900",
"shadow-[inset_0_2px_6px_rgba(0,0,0,0.55),inset_0_-1px_0_rgba(255,255,255,0.04),0_1px_0_rgba(255,255,255,0.03)]",
disabled && "opacity-50",
className
)}
>
{/* Instruction label — shimmers, then fades and drifts as the knob passes. */}
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 pr-4 text-sm font-medium tracking-wide"
style={{ opacity: labelOpacity, x: labelShift }}
>
<motion.span
className="truncate"
style={
still
? { color: "#a3a3a3" }
: {
color: "transparent",
backgroundImage:
"linear-gradient(105deg, #6b6b6b 0%, #6b6b6b 38%, #fafafa 50%, #6b6b6b 62%, #6b6b6b 100%)",
backgroundSize: "260% 100%",
WebkitBackgroundClip: "text",
backgroundClip: "text",
}
}
animate={still || !inView ? undefined : { backgroundPosition: ["100% 0%", "0% 0%"] }}
transition={{ duration: 2.6, ease: "linear", repeat: Infinity, repeatDelay: 0.4 }}
>
{label}
</motion.span>
</motion.span>
{/* Fill — trails the knob; floods solid on commit. */}
<motion.span
aria-hidden
className="pointer-events-none absolute rounded-full"
style={{
top: PAD - 1,
bottom: PAD - 1,
left: PAD - 1,
width: committed ? `calc(100% - ${(PAD - 1) * 2}px)` : fillW,
background: committed
? accentColor
: `linear-gradient(90deg, ${tint(6)}, ${tint(armed ? 42 : 26)})`,
boxShadow: committed ? `0 0 24px -6px ${tint(70)}` : `inset 0 0 0 1px ${tint(14)}`,
transition: still ? "none" : "background 240ms ease, box-shadow 240ms ease",
}}
/>
{/* Committed label, inked for contrast on the accent. */}
<AnimatePresence>
{committed && (
<motion.span
aria-hidden
key="done"
className="pointer-events-none absolute inset-0 flex items-center justify-center pr-12 text-sm font-semibold tracking-wide"
style={{ color: ink }}
initial={still ? { opacity: 0 } : { opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={still ? QUICK : { duration: 0.32, ease: "easeOut", delay: 0.08 }}
>
{committedLabel}
</motion.span>
)}
</AnimatePresence>
{/* One-shot ring flash on commit. */}
{!still && flash > 0 && (
<motion.span
key={flash}
aria-hidden
className="pointer-events-none absolute -inset-px rounded-full"
style={{ boxShadow: `0 0 0 1.5px ${accentColor}, 0 0 28px ${tint(55)}` }}
initial={{ opacity: 0.9, scale: 1 }}
animate={{ opacity: 0, scale: 1.06 }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
)}
{/* Knob — the slider itself. */}
<motion.div
ref={knobRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-describedby={descId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={valueText}
aria-disabled={disabled || undefined}
aria-readonly={committed || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
onBlur={onBlur}
className={cn(
"absolute flex items-center justify-center rounded-full outline-none",
"focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
disabled ? "cursor-not-allowed" : committed ? "cursor-default" : dragging ? "cursor-grabbing" : "cursor-grab"
)}
style={{
top: PAD,
left: PAD,
width: KNOB,
height: KNOB,
x,
touchAction: "none",
background: committed
? "#0a0a0a"
: "linear-gradient(180deg, #ffffff 0%, #e7e7e7 100%)",
color: committed ? accentColor : "#171717",
boxShadow: committed
? "0 1px 2px rgba(0,0,0,0.4), inset 0 0 0 1px rgba(255,255,255,0.08)"
: "0 4px 14px -2px rgba(0,0,0,0.65), 0 1px 2px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.9)",
}}
animate={{ scale: still ? 1 : dragging ? (armed ? 1.06 : 0.97) : 1 }}
transition={still ? { duration: 0 } : SPRING}
>
<AnimatePresence initial={false} mode="popLayout">
{committed ? (
<motion.svg
key="check"
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: -30 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
exit={{ opacity: 0 }}
transition={still ? QUICK : SPRING}
>
<motion.path
d="M5 12.5l4.5 4.5L19 7.5"
initial={still ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={still ? { duration: 0 } : { duration: 0.34, ease: "easeOut", delay: 0.06 }}
/>
</motion.svg>
) : (
<motion.svg
key="arrow"
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-5 w-5"
initial={still ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={still ? QUICK : SPRING}
>
<path d="M6 7l5 5-5 5" opacity={0.45} />
<path d="M12 7l5 5-5 5" />
</motion.svg>
)}
</AnimatePresence>
</motion.div>
<span id={descId} className="sr-only">
Drag the handle to the end, or use the arrow keys to reach 100% and press Enter.
</span>
<span role="status" aria-live="polite" className="sr-only">
{committed ? committedLabel : ""}
</span>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* ThrowRow */
/* -------------------------------------------------------------------------- */
/** One swipe action. The first action on a side is that side's primary. */
export interface ThrowAction {
/** Visible caption and accessible name. */
label: string;
/** Icon node (≈18px, uses `currentColor`). */
icon?: React.ReactNode;
/** Background colour of the action well. @default "#404040" (primary) / "#262626" */
color?: string;
/** Called when the action is chosen (after the row collapses, when it collapses). */
onAction: () => void;
}
type Side = "leading" | "trailing";
/** Imperative handle for {@link ThrowRow}. */
export interface ThrowRowHandle {
/** Slide a side's actions into view. */
open(side: Side): void;
/** Slide the row closed. */
close(): void;
/** Un-collapse a row whose action has fired and close it. */
reset(): void;
}
export interface ThrowRowProps {
/** Row content — the sliding foreground surface. */
children: React.ReactNode;
/** Actions revealed by swiping right (they sit on the left edge). */
leading?: ThrowAction | ThrowAction[];
/** Actions revealed by swiping left (they sit on the right edge). */
trailing?: ThrowAction | ThrowAction[];
/** A swipe past 60% of the row fires that side's primary action. @default true */
fullSwipe?: boolean;
/** Collapse the row (height spring) before firing an action. @default true */
collapseOnAction?: boolean;
/** Idle hint — the row briefly peeks its trailing (else leading) actions until touched. @default false */
peek?: boolean;
/** Accessible name for the row. Defaults to its text content. */
label?: string;
/** Class for the outer wrapper. */
className?: string;
/** Class for the sliding foreground surface. */
contentClassName?: string;
/** Imperative handle — `{ open(side), close(), reset() }`. */
ref?: React.Ref<ThrowRowHandle>;
}
const ACTION_W = 76;
const FULL = 0.6;
const asList = (a?: ThrowAction | ThrowAction[]) => (a ? (Array.isArray(a) ? a : [a]) : []);
/** Exponential rubber band: grows quickly at first, saturates near `limit`. */
const rubber = (over: number, limit: number) => limit * (1 - Math.exp(-over / (limit * 1.6)));
/**
* ThrowRow — a list row that slides to reveal actions. Swipe left for trailing
* actions, right for leading ones; travel past the actions is rubber-banded,
* the icons scale in as they're uncovered, and a full swipe past 60% fires the
* side's primary action: the row flies out and collapses on a height spring.
*
* Keyboard: the row is focusable; ArrowLeft / ArrowRight reveal actions and
* move focus into them (Enter activates), Escape or the opposite arrow closes.
* Reduced motion: no rubber band or springs — short tweens only.
*/
export function ThrowRow({
children,
leading,
trailing,
fullSwipe = true,
collapseOnAction = true,
peek = false,
label,
className,
contentClassName,
ref,
}: ThrowRowProps) {
const reduced = useReducedMotion();
const lead = asList(leading);
const trail = asList(trailing);
const rootRef = React.useRef<HTMLDivElement>(null);
const rowRef = React.useRef<HTMLDivElement>(null);
const leadRef = React.useRef<HTMLDivElement>(null);
const trailRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(rootRef, { amount: 0.6 });
const descId = React.useId();
const x = useMotionValue(0);
const leadW = useTransform(x, (v) => Math.max(0, v));
const trailW = useTransform(x, (v) => Math.max(0, -v));
const leadIcon = useTransform(leadW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });
const trailIcon = useTransform(trailW, [0, ACTION_W * 0.9], [0.35, 1], { clamp: true });
const leadOp = useTransform(leadW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });
const trailOp = useTransform(trailW, [8, ACTION_W * 0.7], [0, 1], { clamp: true });
// Captions wait until each action has room for them (no cramped half-words).
const leadN = useMotionValue(lead.length);
const trailN = useMotionValue(trail.length);
React.useEffect(() => {
leadN.set(lead.length);
trailN.set(trail.length);
}, [lead.length, trail.length, leadN, trailN]);
const cap = ([w, n]: number[]) =>
clamp((w / Math.max(1, n) - ACTION_W * 0.62) / (ACTION_W * 0.3), 0, 1);
const leadCap = useTransform([leadW, leadN], cap);
const trailCap = useTransform([trailW, trailN], cap);
const [open, setOpen] = React.useState<Side | null>(null);
const [armed, setArmed] = React.useState<Side | null>(null);
const [firing, setFiring] = React.useState<{ side: Side; index: number } | null>(null);
const [collapsed, setCollapsed] = React.useState(false);
const anim = React.useRef<AnimationPlaybackControls | null>(null);
const touched = React.useRef(false);
const dragged = React.useRef(false);
const focusInto = React.useRef<Side | null>(null);
const pending = React.useRef<ThrowAction | null>(null);
const drag = React.useRef<{
id: number;
sx: number;
sy: number;
start: number;
decided: boolean;
w: number;
lx: number;
lt: number;
v: number;
} | null>(null);
const actionsFor = (s: Side) => (s === "leading" ? lead : trail);
const sign = (s: Side) => (s === "leading" ? 1 : -1);
const run = React.useCallback(
(to: number | number[], t: object) => {
anim.current?.stop();
anim.current = animate(x, to as number, t);
},
[x]
);
const close = React.useCallback(() => {
setOpen(null);
setArmed(null);
run(0, reduced ? QUICK : SPRING);
}, [reduced, run]);
const openSide = React.useCallback(
(s: Side) => {
const n = (s === "leading" ? lead : trail).length;
if (!n) return;
setOpen(s);
setArmed(null);
run(sign(s) * n * ACTION_W, reduced ? QUICK : SPRING);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[lead.length, trail.length, reduced, run]
);
const fire = (s: Side, index: number) => {
const action = actionsFor(s)[index];
if (!action || firing) return;
touched.current = true;
setFiring({ side: s, index });
setArmed(null);
if (!collapseOnAction) {
action.onAction();
window.setTimeout(() => {
setFiring(null);
close();
}, reduced ? 60 : 220);
return;
}
pending.current = action;
const w = rowRef.current?.offsetWidth ?? 320;
run(sign(s) * w, {
...(reduced ? QUICK : { duration: 0.24, ease: [0.4, 0, 0.6, 1] }),
onComplete: () => setCollapsed(true),
});
};
const reset = React.useCallback(() => {
pending.current = null;
setFiring(null);
setCollapsed(false);
setOpen(null);
setArmed(null);
anim.current?.stop();
x.set(0);
}, [x]);
React.useImperativeHandle(ref, () => ({ open: openSide, close, reset }), [openSide, close, reset]);
// Focus the revealed panel's first action after a keyboard reveal.
React.useEffect(() => {
const s = focusInto.current;
if (!s || open !== s) return;
focusInto.current = null;
(s === "leading" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>("button")?.focus();
}, [open]);
// Tap / press outside closes an open row.
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) close();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, close]);
// Idle peek — teaches that the row swipes.
const peekSide: Side | null = trail.length ? "trailing" : lead.length ? "leading" : null;
React.useEffect(() => {
if (!peek || reduced || !inView || !peekSide || collapsed) return;
let timer = 0;
const go = () => {
if (touched.current || drag.current || open || firing) return;
const d = sign(peekSide) * ACTION_W * 0.85;
run([0, d, d, 0], { duration: 1.6, times: [0, 0.3, 0.55, 1], ease: ["easeOut", "linear", [0.5, 0, 0.2, 1]] });
timer = window.setTimeout(go, 6000);
};
timer = window.setTimeout(go, 1200);
return () => window.clearTimeout(timer);
}, [peek, reduced, inView, peekSide, collapsed, open, firing, run]);
React.useEffect(() => () => anim.current?.stop(), []);
/* ------------------------------- pointer -------------------------------- */
const shape = (raw: number, w: number) => {
const s: Side = raw >= 0 ? "leading" : "trailing";
const n = actionsFor(s).length;
const a = Math.abs(raw);
if (!n) return reduced ? 0 : sign(s) * rubber(a, 28);
const reach = fullSwipe ? w * 0.82 : n * ACTION_W;
if (reduced) return sign(s) * Math.min(a, reach);
return sign(s) * (a <= reach ? a : reach + rubber(a - reach, 40));
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (firing || e.button !== 0) return;
anim.current?.stop();
dragged.current = false;
drag.current = {
id: e.pointerId,
sx: e.clientX,
sy: e.clientY,
start: x.get(),
decided: false,
w: rowRef.current?.offsetWidth ?? 320,
lx: e.clientX,
lt: e.timeStamp,
v: 0,
};
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
const dx = e.clientX - d.sx;
const dy = e.clientY - d.sy;
if (!d.decided) {
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
if (Math.abs(dy) > Math.abs(dx)) {
drag.current = null; // vertical — let the page scroll
return;
}
d.decided = true;
dragged.current = true;
touched.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
const dt = Math.max(1, e.timeStamp - d.lt);
d.v = d.v * 0.6 + ((e.clientX - d.lx) / dt) * 1000 * 0.4;
d.lx = e.clientX;
d.lt = e.timeStamp;
const nx = shape(d.start + dx, d.w);
x.set(nx);
const s: Side = nx >= 0 ? "leading" : "trailing";
const isArmed = fullSwipe && actionsFor(s).length > 0 && Math.abs(nx) > d.w * FULL;
setArmed(isArmed ? s : null);
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.decided) return;
const cur = x.get();
const s: Side = cur >= 0 ? "leading" : "trailing";
const n = actionsFor(s).length;
const a = Math.abs(cur);
const toward = Math.sign(d.v) === sign(s);
if (n && fullSwipe && a > d.w * FULL) return fire(s, 0);
if (n && ((a > (n * ACTION_W) / 2 && !(Math.abs(d.v) > 500 && !toward)) || (toward && Math.abs(d.v) > 500)))
return openSide(s);
close();
};
/* ------------------------------ keyboard -------------------------------- */
const onRowKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || firing) return;
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
touched.current = true;
const s: Side = e.key === "ArrowLeft" ? "trailing" : "leading";
if (open && open !== s) return close();
if (!actionsFor(s).length) return;
focusInto.current = s;
if (open === s) {
(s === "leading" ? leadRef : trailRef).current?.querySelector<HTMLButtonElement>("button")?.focus();
focusInto.current = null;
} else openSide(s);
} else if (e.key === "Escape" && open) {
e.preventDefault();
close();
}
};
const onActionKey = (s: Side) => (e: React.KeyboardEvent<HTMLButtonElement>) => {
const closeKey = s === "trailing" ? "ArrowRight" : "ArrowLeft";
const buttons = Array.from(
(s === "leading" ? leadRef : trailRef).current?.querySelectorAll<HTMLButtonElement>("button") ?? []
);
const i = buttons.indexOf(e.currentTarget);
if (e.key === "Escape") {
e.preventDefault();
close();
rowRef.current?.focus();
} else if (e.key === closeKey) {
e.preventDefault();
// Step back toward the content; past the first action, close.
if (i > 0) buttons[i - 1].focus();
else {
close();
rowRef.current?.focus();
}
} else if (e.key === (s === "trailing" ? "ArrowLeft" : "ArrowRight")) {
e.preventDefault();
buttons[i + 1]?.focus();
}
};
/* -------------------------------- render -------------------------------- */
const panel = (s: Side) => {
const acts = actionsFor(s);
if (!acts.length) return null;
const isLead = s === "leading";
const hot = firing?.side === s ? firing.index : armed === s ? 0 : -1;
const visible = open === s;
return (
<motion.div
ref={isLead ? leadRef : trailRef}
inert={!visible}
className={cn(
"absolute inset-y-0 flex overflow-hidden",
isLead ? "left-0 flex-row" : "right-0 flex-row-reverse"
)}
style={{
width: isLead ? leadW : trailW,
background: hot >= 0 ? (acts[hot].color ?? "#404040") : "#171717",
transition: reduced ? "none" : "background 160ms ease",
}}
>
{acts.map((a, i) => {
const bg = a.color ?? (i === 0 ? "#404040" : "#262626");
const grow = hot < 0 ? 1 : hot === i ? 1 : 0;
return (
<button
key={a.label}
type="button"
tabIndex={visible ? 0 : -1}
aria-label={a.label}
onClick={() => fire(s, i)}
onKeyDown={onActionKey(s)}
className={cn(
"relative flex min-w-0 basis-0 flex-col items-center justify-center gap-1 overflow-hidden px-1 text-[11px] font-medium outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/80"
)}
style={{
background: bg,
color: inkOn(bg),
flexGrow: grow,
transition: reduced ? "none" : "flex-grow 240ms cubic-bezier(.3,.7,.2,1)",
}}
>
<motion.span
className="flex w-[68px] flex-col items-center justify-center gap-1"
style={{
scale: isLead ? leadIcon : trailIcon,
opacity: isLead ? leadOp : trailOp,
}}
>
{a.icon && (
<motion.span
aria-hidden
className="flex h-5 w-5 items-center justify-center [&>svg]:h-[18px] [&>svg]:w-[18px]"
animate={{ scale: !reduced && hot === i ? 1.18 : 1 }}
transition={reduced ? { duration: 0 } : SPRING}
>
{a.icon}
</motion.span>
)}
<motion.span
className="truncate"
style={{ opacity: hot === i ? 1 : isLead ? leadCap : trailCap }}
>
{a.label}
</motion.span>
</motion.span>
</button>
);
})}
</motion.div>
);
};
const hasActions = lead.length + trail.length > 0;
const hintText = [
trail.length ? `Left arrow: ${trail.map((a) => a.label).join(", ")}` : null,
lead.length ? `Right arrow: ${lead.map((a) => a.label).join(", ")}` : null,
]
.filter(Boolean)
.join(". ");
return (
<motion.div
ref={rootRef}
data-crucible="throw-row"
className={cn("relative overflow-hidden", className)}
initial={false}
animate={collapsed ? { height: 0, opacity: 0 } : { height: "auto", opacity: 1 }}
transition={reduced ? { duration: 0.12 } : { height: SOFT, opacity: { duration: 0.2 } }}
onAnimationComplete={() => {
if (!collapsed || !pending.current) return;
const a = pending.current;
pending.current = null;
a.onAction();
}}
>
{panel("leading")}
{panel("trailing")}
<motion.div
ref={rowRef}
tabIndex={collapsed ? -1 : 0}
role="group"
aria-roledescription="swipeable row"
aria-label={label}
aria-describedby={hasActions ? descId : undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClickCapture={(e) => {
if (dragged.current) {
e.preventDefault();
e.stopPropagation();
dragged.current = false;
} else if (open) {
e.stopPropagation();
close();
}
}}
onKeyDown={onRowKey}
className={cn(
"relative z-10 touch-pan-y select-none bg-neutral-900 outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
contentClassName
)}
style={{ x }}
>
{children}
</motion.div>
{hasActions && (
<span id={descId} className="sr-only">
{hintText}. Escape closes.
</span>
)}
</motion.div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/throwManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Slide to confirm" | Instruction text inside the track; also the slider's accessible name. |
| committedLabel | string | "Done" | Text shown in the filled track once committed. |
| onCommit | () => void | Fires once when the knob is thrown past the threshold (or Enter is pressed at the end). | |
| threshold | number | 0.85 | Fraction of the travel (0–1) the knob must pass on release to commit. |
| accentColor | string | "#f5f5f5" | Fill colour behind the knob and of the committed track. Any CSS colour; hex gets auto-contrast ink. |
| disabled | boolean | false | Disable dragging and keys. |
| hint | boolean | true | Idle hint — the knob nudges forward every few seconds until first touched. |
| paused | boolean | false | Freeze the shimmer and idle hint. |
| className | string | ||
| ref | React.Ref<ThrowHandle> | Imperative handle — { reset(), focus() }. |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.