Swipeable list row that reveals actions with rubber-band resistance and a flick-velocity snap, falling back to hover reveal on fine pointers.
npx shadcn@latest add @paragon/swipe-actions"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/** Flick speed (px/ms) that snaps the row open or shut regardless of distance. */
const FLICK_VELOCITY = 0.11;
/** Movement (px) before a gesture commits to a horizontal swipe. */
const INTENT_SLOP = 6;
/** Rubber-band: asymptotic resistance for distance dragged past the limit. */
function resist(over: number) {
return over / (1 + over / 48);
}
export interface SwipeActionsProps extends React.ComponentProps<"div"> {
/** Action buttons revealed behind the row — compose from <SwipeAction>. */
actions: React.ReactNode;
/** Styles the sliding row surface (padding, layout). */
contentClassName?: string;
/** Disables swiping and reveal transitions; actions stay focus-reachable. */
static?: boolean;
}
/**
* Mobile-pattern swipeable list row. Touch drags are pointer-captured and
* single-touch guarded; movement past the action tray rubber-bands, a flick
* past ~0.11 px/ms snaps open regardless of distance, and tapping anywhere
* else closes. Vertical intent hands the gesture back to the scroller
* (touch-action: pan-y). On fine pointers the tray reveals on hover instead,
* and keyboard focus reveals it everywhere. Transforms are written straight
* to the row — no per-frame React renders.
*/
export function SwipeActions({
actions,
children,
className,
contentClassName,
static: isStatic = false,
...props
}: SwipeActionsProps) {
const reducedMotion = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const actionsRef = React.useRef<HTMLDivElement>(null);
const [open, setOpen] = React.useState(false);
const drag = React.useRef({
pointerId: null as number | null,
startX: 0,
startY: 0,
startOffset: 0,
offset: 0,
lastX: 0,
lastTime: 0,
velocity: 0,
intent: null as "x" | "y" | null,
});
// Timestamp of the last horizontal drag, to swallow the click it synthesizes.
const lastDragEnd = React.useRef(0);
// Tray width, published as --swipe-w for the CSS hover/focus reveal.
React.useEffect(() => {
const tray = actionsRef.current;
const root = rootRef.current;
if (!tray || !root) return;
const observer = new ResizeObserver(() => {
root.style.setProperty("--swipe-w", `${tray.offsetWidth}px`);
});
observer.observe(tray);
return () => observer.disconnect();
}, []);
const settle = React.useCallback(
(opening: boolean) => {
const el = contentRef.current;
const width = actionsRef.current?.offsetWidth ?? 0;
if (el) {
// Include `translate` so the CSS hover/focus reveal (which drives the
// translate property) keeps its transition after inline overrides.
el.style.transition = reducedMotion
? "none"
: `transform ${opening ? "250ms" : "200ms"} var(--ease-out), translate 250ms var(--ease-out)`;
el.style.transform = `translate3d(${opening ? -width : 0}px, 0, 0)`;
}
setOpen(opening);
},
[reducedMotion],
);
// Tapping anywhere outside the row closes an open tray.
React.useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) settle(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, settle]);
const endDrag = (pointerId: number, commit: boolean) => {
const state = drag.current;
if (state.pointerId !== pointerId) return;
state.pointerId = null;
rootRef.current?.removeAttribute("data-dragging");
if (state.intent !== "x") {
// A tap (or abandoned gesture) — resume whatever state was frozen.
settle(open);
return;
}
lastDragEnd.current = Date.now();
const width = actionsRef.current?.offsetWidth ?? 0;
const opening = commit
? state.velocity <= -FLICK_VELOCITY
? true
: state.velocity >= FLICK_VELOCITY
? false
: state.offset < -width / 2
: false;
settle(opening);
};
return (
<>
<style href="paragon-swipe-actions" precedence="paragon">{`
[data-swipe-content] {
transition: translate 150ms var(--ease-exit);
}
[data-swipe-root]:has(:focus-visible) [data-swipe-content] {
translate: calc(var(--swipe-w, 0px) * -1) 0;
transition: translate 250ms var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
[data-swipe-root]:not([data-state="open"]):not([data-dragging]):hover [data-swipe-content] {
translate: calc(var(--swipe-w, 0px) * -1) 0;
transition: translate 250ms var(--ease-out);
}
}
[data-swipe-root][data-static] [data-swipe-content] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
[data-swipe-content] { transition: none !important; }
}
`}</style>
<div
ref={rootRef}
data-swipe-root=""
data-slot="swipe-actions"
data-state={open ? "open" : "closed"}
data-static={isStatic || undefined}
className={cn("relative overflow-hidden", className)}
onKeyDown={(event) => {
if (event.key === "Escape" && open) settle(false);
}}
{...props}
>
<div
ref={actionsRef}
data-slot="swipe-actions-tray"
className="absolute inset-y-0 right-0 flex items-stretch"
>
{actions}
</div>
<div
ref={contentRef}
data-swipe-content=""
data-slot="swipe-actions-content"
className={cn(
"relative touch-pan-y bg-card select-none",
contentClassName,
)}
style={{ transform: "translate3d(0px, 0px, 0px)" }}
onPointerDown={(event) => {
const state = drag.current;
// Fine pointers get the hover reveal; guard multi-touch.
if (isStatic || event.pointerType === "mouse") return;
if (state.pointerId !== null) return;
const el = contentRef.current;
if (!el) return;
const computed = getComputedStyle(el).transform;
state.pointerId = event.pointerId;
state.startX = event.clientX;
state.startY = event.clientY;
state.startOffset =
computed && computed !== "none"
? new DOMMatrixReadOnly(computed).m41
: 0;
state.offset = state.startOffset;
state.lastX = event.clientX;
state.lastTime = event.timeStamp;
state.velocity = 0;
state.intent = null;
// Freeze any in-flight snap at its current position so grabbing
// mid-animation is exact and interruptible.
el.style.transition = "none";
el.style.transform = `translate3d(${state.startOffset}px, 0, 0)`;
}}
onPointerMove={(event) => {
const state = drag.current;
if (state.pointerId !== event.pointerId) return;
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
if (state.intent === null) {
if (Math.abs(dx) < INTENT_SLOP && Math.abs(dy) < INTENT_SLOP)
return;
if (Math.abs(dy) > Math.abs(dx)) {
// Vertical intent — hand the gesture to the scroller.
state.pointerId = null;
return;
}
state.intent = "x";
event.currentTarget.setPointerCapture(event.pointerId);
rootRef.current?.setAttribute("data-dragging", "");
}
const width = actionsRef.current?.offsetWidth ?? 0;
let offset = state.startOffset + dx;
if (offset > 0) offset = resist(offset);
else if (offset < -width) offset = -width - resist(-(offset + width));
state.offset = offset;
const dt = event.timeStamp - state.lastTime;
if (dt > 0) {
const instant = (event.clientX - state.lastX) / dt;
state.velocity = state.velocity * 0.2 + instant * 0.8;
}
state.lastX = event.clientX;
state.lastTime = event.timeStamp;
const el = contentRef.current;
if (el) {
el.style.transition = "none";
el.style.transform = `translate3d(${offset}px, 0, 0)`;
}
}}
onPointerUp={(event) => endDrag(event.pointerId, true)}
onPointerCancel={(event) => endDrag(event.pointerId, false)}
onClick={(event) => {
// Swallow the click a horizontal drag synthesizes on release.
if (Date.now() - lastDragEnd.current < 400) {
event.preventDefault();
event.stopPropagation();
return;
}
// Tap on the row itself while open closes instead of activating.
if (open) {
event.preventDefault();
event.stopPropagation();
settle(false);
}
}}
>
{children}
</div>
</div>
</>
);
}
export interface SwipeActionProps extends React.ComponentProps<"button"> {
variant?: "default" | "destructive";
}
/** One action in the tray — full-height, icon-over-label. */
export function SwipeAction({
variant = "default",
className,
...props
}: SwipeActionProps) {
return (
<button
type="button"
data-slot="swipe-action"
className={cn(
"flex w-16 flex-col items-center justify-center gap-1 text-xs font-medium transition-[background-color] duration-150 ease-out [&_svg]:size-4 [&_svg]:shrink-0",
variant === "destructive"
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
className,
)}
{...props}
/>
);
}