Swipe row
Swipe-to-reveal row actions with progressive arming, momentum projection, and velocity handed into the settle spring.
Loading preview…
Show code
"use client";
import {
useEffect,
useRef,
useState,
type ComponentType,
type FocusEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type SVGProps,
} from "react";
import { animate } from "animejs";
import { prefersReducedMotion, spring } from "@/lib/openkit/anime/tokens";
import { nearestSnap, project } from "@/lib/openkit/anime/project";
import { cn } from "@/lib/utils";
// Swipe-to-reveal row actions. A drag gesture is rare on the web but right for
// acting on a single item: drag a row to reveal save / add-to-kit on one edge
// and a constructive action on the other, then tap. Pointer events drive
// translateX, constrained to one axis with a stiff edge so the row lands on a
// snap point we choose, not where a fling throws it, and anime.js springs it
// home on release. Progressive reveal arms each action at a pixel threshold.
type SwipeTone = "default" | "accent" | "destructive";
export interface SwipeAction {
id: string;
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
tone?: SwipeTone;
onAction: () => void;
/** When true, swap to the active glyph/label (e.g. saved). */
active?: boolean;
activeIcon?: ComponentType<SVGProps<SVGSVGElement>>;
activeLabel?: string;
}
const ACTION_W = 76; // px per action; comfortably past the 44px touch target
const REVEAL_1 = 44; // first action arms here
const REVEAL_2 = 88; // second action arms here
const OPEN_THRESHOLD = 38; // past this on release, snap open instead of closed
const FLING_VELOCITY = 110; // px/s, Apple's fling threshold
const EDGE_RESIST = 0.2; // rubber-band past the bounds
const toneClass: Record<SwipeTone, string> = {
default: "bg-muted font-medium text-foreground",
accent: "bg-primary font-medium text-primary-foreground",
destructive: "bg-destructive font-medium text-white",
};
export function SwipeRow({
leading,
trailing,
initialOpen,
children,
className,
}: {
/** Revealed by swiping right; sits on the left edge. */
leading?: SwipeAction;
/** Revealed by swiping left; sits on the right edge (up to two, progressive). */
trailing?: SwipeAction[];
/** Rest open on mount without animating (docs states, onboarding hints). */
initialOpen?: "leading" | "trailing";
children: ReactNode;
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const trailingWidth = Math.min(trailing?.length ?? 0, 2) * ACTION_W;
const leadingWidth = leading ? ACTION_W : 0;
const restX =
initialOpen === "trailing" ? -trailingWidth : initialOpen === "leading" ? leadingWidth : 0;
// Current translate, the running snap animation, and the live drag session.
const x = useRef(restX);
const anim = useRef<ReturnType<typeof animate> | null>(null);
const drag = useRef({ active: false, startX: 0, base: 0, lastX: 0, lastT: 0, v: 0 });
// Progressive reveal: arm each action once the drag passes its threshold.
const [tLevel, setTLevel] = useState(() =>
restX <= -REVEAL_2 ? 2 : restX <= -REVEAL_1 ? 1 : 0,
);
const [lArmed, setLArmed] = useState(() => restX >= REVEAL_1);
const applyArms = (v: number) => {
if (trailing?.length) {
const lvl = v <= -REVEAL_2 ? 2 : v <= -REVEAL_1 ? 1 : 0;
setTLevel((p) => (p === lvl ? p : lvl));
}
if (leading) {
const armed = v >= REVEAL_1;
setLArmed((p) => (p === armed ? p : armed));
}
};
const render = () => {
const el = contentRef.current;
if (el) el.style.transform = `translateX(${x.current}px)`;
applyArms(x.current);
};
const stop = () => {
anim.current?.pause();
anim.current = null;
};
const snap = (target: number, velocity = 0) => {
stop();
if (prefersReducedMotion()) {
x.current = target;
render();
return;
}
anim.current = animate(x, {
current: target,
ease: spring({ velocity }),
onUpdate: render,
});
};
const close = (velocity = 0) => snap(0, velocity);
const openTrailing = (velocity = 0) => snap(-trailingWidth, velocity);
const openLeading = (velocity = 0) => snap(leadingWidth, velocity);
useEffect(() => stop, []);
const onPointerDown = (e: ReactPointerEvent) => {
if (!e.isPrimary) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
stop();
contentRef.current?.setPointerCapture(e.pointerId);
drag.current = {
active: true,
startX: e.clientX,
base: x.current,
lastX: e.clientX,
lastT: performance.now(),
v: 0,
};
};
const onPointerMove = (e: ReactPointerEvent) => {
const d = drag.current;
if (!d.active) return;
let next = d.base + (e.clientX - d.startX);
if (next < -trailingWidth) next = -trailingWidth + (next + trailingWidth) * EDGE_RESIST;
else if (next > leadingWidth) next = leadingWidth + (next - leadingWidth) * EDGE_RESIST;
x.current = next;
render();
const now = performance.now();
const dt = now - d.lastT;
if (dt > 0) d.v = ((e.clientX - d.lastX) / dt) * 1000; // px/s
d.lastX = e.clientX;
d.lastT = now;
};
const endDrag = (e: ReactPointerEvent) => {
const d = drag.current;
if (!d.active) return;
d.active = false;
contentRef.current?.releasePointerCapture?.(e.pointerId);
const pos = x.current;
const fling = d.v;
// Fling: project resting point, then snap to nearest open/closed target.
if (Math.abs(fling) >= FLING_VELOCITY) {
const points = [0];
if (leading) points.push(leadingWidth);
if (trailing?.length) points.push(-trailingWidth);
snap(nearestSnap(pos + project(fling), points), fling);
return;
}
// Slow release: position threshold.
if (trailing?.length && pos <= -OPEN_THRESHOLD) openTrailing(fling);
else if (leading && pos >= OPEN_THRESHOLD) openLeading(fling);
else close(fling);
};
const fire = (action: SwipeAction) => {
action.onAction();
close();
};
// Keyboard parity: a swipe is pointer-only, so when an action button takes
// focus we open its side, and close once focus leaves the row entirely.
const handleBlur = (e: FocusEvent) => {
if (!rootRef.current?.contains(e.relatedTarget as Node | null)) close();
};
return (
<div ref={rootRef} className={cn("relative overflow-clip", className)} onBlur={handleBlur}>
{leading ? (
<div className="absolute inset-y-0 left-0 flex">
<ActionButton
action={leading}
armed={lArmed}
width={ACTION_W}
origin="left"
onFocus={openLeading}
onClick={() => fire(leading)}
/>
</div>
) : null}
{trailing?.length ? (
<div className="absolute inset-y-0 right-0 flex">
{trailing.slice(0, 2).map((action, i) => (
<ActionButton
key={action.id}
action={action}
armed={tLevel >= i + 1}
width={ACTION_W}
origin="right"
onFocus={openTrailing}
onClick={() => fire(action)}
/>
))}
</div>
) : null}
<div
ref={contentRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
style={restX ? { transform: `translateX(${restX}px)` } : undefined}
className="relative z-10 cursor-grab touch-pan-y bg-background active:cursor-grabbing"
>
{children}
</div>
</div>
);
}
function ActionButton({
action,
armed,
width,
origin,
onFocus,
onClick,
}: {
action: SwipeAction;
armed: boolean;
width: number;
origin: "left" | "right";
onFocus: () => void;
onClick: () => void;
}) {
const tone = action.tone ?? "default";
const Glyph = action.active && action.activeIcon ? action.activeIcon : action.Icon;
const label = action.active && action.activeLabel ? action.activeLabel : action.label;
return (
<button
type="button"
onClick={onClick}
onFocus={onFocus}
aria-label={label}
style={{ width, transformOrigin: origin === "right" ? "right center" : "left center" }}
// Arm with opacity + scale, growing from the revealing edge. CSS
// transitions keep it interruptible as the drag arms and disarms.
className={cn(
"flex h-full flex-col items-center justify-center gap-1 text-sm outline-none",
"transition-[opacity,scale] duration-200 ease-out active:scale-[0.96]",
armed ? "scale-100 opacity-100" : "scale-[0.8] opacity-35",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70",
toneClass[tone],
)}
>
<Glyph width={18} height={18} className="shrink-0" />
<span>{label}</span>
</button>
);
}