Swipe a row to reveal actions; swipe far and the tray fills to commit.
01Preview
Inbox
2 unread- 9:41
Q3 forecast review
Updated the numbers in q3-forecast.xlsx, can you check the EMEA tab before Thursday?
- 9:12
Production deploy finished
web · main · 3f9c2e1 built in 48s and is live on all regions
- Yesterday
Invoice INV-2041 is due Friday
€4,280.00 for the September retainer. PDF attached for your records.
- Mon
Design review notes
Left comments on the onboarding flow, mostly copy and the empty states.
02Install
Add it with the shadcn CLI. The source lands in your project and becomes yours: no package to update, no wrapper between you and the markup.
npx shadcn@latest add @stealth/swipe-actionsOr copy the source below by hand. It needs:
npm install motion @base-ui/react03Usage
import { SwipeActions } from "@/components/ui/swipe-actions";
<SwipeActions
startActions={[{ id: "read", label: "Mark read", icon: <MailOpen />, onAction: markRead }]}
endActions={[
{ id: "archive", label: "Archive", icon: <Archive />, removes: true, onAction: archive },
{ id: "delete", label: "Delete", icon: <Trash />, tone: "danger", removes: true, onAction: remove },
]}
>
<MessageRow message={message} />
</SwipeActions>04Source
"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type MotionValue } from "motion/react";
import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type SwipeSide = "start" | "end";
export type SwipeTone = "neutral" | "danger" | "success" | "warning" | "info";
export type SwipeAction = {
id: string;
/** A verb: "Archive", "Delete", "Mark read". Also the tooltip and accessible name. */
label: string;
icon: React.ReactNode;
tone?: SwipeTone;
/** The row leaves when this runs: it slides out and folds shut before `onAction` fires. */
removes?: boolean;
onAction: () => void;
};
export type SwipeActionsProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The row itself. Keep its own click target inside; drags never reach it as a click. */
children: React.ReactNode;
/** Revealed by swiping toward the end edge (right in LTR). The first is the outermost and the full-swipe action. */
startActions?: SwipeAction[];
/** Revealed by swiping toward the start edge (left in LTR). The first is the outermost and the full-swipe action. */
endActions?: SwipeAction[];
/** Swiping far enough runs the outermost action without a second tap. `true` for both sides. */
fullSwipe?: boolean | SwipeSide;
/** Which tray is open. `null` when the row rests closed. */
open?: SwipeSide | null;
defaultOpen?: SwipeSide | null;
onOpenChange?: (open: SwipeSide | null) => void;
/** The icon buttons that appear on hover and focus, for mouse and keyboard users. */
toolbar?: boolean;
/** Where the hover toolbar sits: centered on the row, or over its first line (where a timestamp usually is). */
toolbarAlign?: "center" | "top";
/** Width of each revealed action in px. */
actionWidth?: number;
disabled?: boolean;
/** Classes for the sliding layer. It inherits the row's background so the trays stay hidden behind it. */
contentClassName?: string;
};
type Phase = "idle" | "dragging" | "committing";
// Tray colors are soft while you're still deciding and turn solid once a full swipe will commit.
const tones: Record<SwipeTone, { soft: string; solid: string; hover: string }> = {
neutral: { soft: "bg-fg/[0.07] text-fg", solid: "bg-fg text-frame", hover: "hover:bg-fg/[0.11]" },
danger: { soft: "bg-danger-soft text-danger", solid: "bg-danger text-frame", hover: "hover:bg-danger/20" },
success: { soft: "bg-success-soft text-success", solid: "bg-success text-frame", hover: "hover:bg-success/20" },
warning: { soft: "bg-warning-soft text-warning", solid: "bg-warning text-frame", hover: "hover:bg-warning/20" },
info: { soft: "bg-info-soft text-info", solid: "bg-info text-frame", hover: "hover:bg-info/20" },
};
/** The resistance of a scroll view pulled past its end: fast at first, never past `dim`. */
const rubber = (over: number, dim: number) => (over * dim * 0.55) / (dim + over * 0.55);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const OPEN_EVENT = "swipe-actions:engage";
const settleSpring = { type: "spring", stiffness: 420, damping: 40, mass: 0.8 } as const;
export function SwipeActions({
children,
startActions = [],
endActions = [],
fullSwipe = true,
open: openProp,
defaultOpen = null,
onOpenChange,
toolbar = true,
actionWidth = 72,
disabled = false,
className,
contentClassName,
toolbarAlign = "center",
onKeyDown,
...rest
}: SwipeActionsProps) {
const uid = useId();
const reduce = useReducedMotion();
const [open, setOpen] = useControllableState<SwipeSide | null>({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
const [phase, setPhase] = useState<Phase>("idle");
const [armed, setArmedState] = useState<SwipeSide | null>(null);
// Which action on the armed side takes over the tray: the outermost for a full swipe, the pressed one for a tap.
const [expand, setExpand] = useState(0);
const [commits, setCommits] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const actions = { start: startActions, end: endActions };
const reveal = (side: SwipeSide) => actions[side].length * actionWidth;
const full = (side: SwipeSide) => fullSwipe === true || fullSwipe === side;
// x is physical pixels; dir flips it to logical so "start" means the start edge in RTL too.
const x = useMotionValue(defaultOpen === "start" ? startActions.length * actionWidth : defaultOpen === "end" ? -endActions.length * actionWidth : 0);
const dir = useMotionValue(1);
const logical = useTransform(() => x.get() * dir.get());
const startRevealed = useTransform(() => Math.max(0, logical.get()));
const endRevealed = useTransform(() => Math.max(0, -logical.get()));
const armStart = useMotionValue(0);
const armEnd = useMotionValue(0);
const anim = useRef<AnimationPlaybackControls | null>(null);
const armedRef = useRef<SwipeSide | null>(null);
const targetRef = useRef<SwipeSide | null>(defaultOpen);
const suppressClick = useRef(false);
const g = useRef({ id: -1, x0: 0, y0: 0, base: 0, lock: null as null | "x" | "y", width: 0, samples: [] as { t: number; v: number }[] });
const wheel = useRef({ active: false, base: 0, acc: 0, timer: 0 });
useEffect(() => {
if (rootRef.current && getComputedStyle(rootRef.current).direction === "rtl") dir.set(-1);
return () => {
anim.current?.stop();
window.clearTimeout(wheel.current.timer);
};
}, [dir]);
const width = () => rootRef.current?.getBoundingClientRect().width ?? 360;
const armAt = (side: SwipeSide, w: number) => Math.max(reveal(side) + 56, w * 0.5);
function setArmed(side: SwipeSide | null, index = 0) {
if (side) setExpand(index);
if (armedRef.current === side) return;
armedRef.current = side;
setArmedState(side);
for (const [s, mv] of [["start", armStart], ["end", armEnd]] as const) {
const to = s === side ? 1 : 0;
if (reduce) mv.set(to);
else animate(mv, to, spring.snappy);
}
// A tick under the thumb where the platform allows it. Coarse, Android only, harmless elsewhere.
if (side) navigator.vibrate?.(8);
}
/** Maps a raw drag offset to where the row may actually be. */
function constrain(l: number, w: number) {
const side: SwipeSide = l >= 0 ? "start" : "end";
const mag = Math.abs(l);
const sign = Math.sign(l) || 1;
const r = reveal(side);
let out: number;
if (!r) out = rubber(mag, 28);
else if (mag <= r) out = mag;
else if (full(side)) out = Math.min(mag, w);
else out = r + rubber(mag - r, 56);
if (armedRef.current && armedRef.current !== side) setArmed(null);
if (r && full(side)) {
const at = armAt(side, w);
if (out >= at) setArmed(side);
else if (armedRef.current === side && out < at - 24) setArmed(null);
}
return sign * out;
}
function moveTo(l: number) {
x.set(l * dir.get());
}
function settle(side: SwipeSide | null, velocity = 0) {
anim.current?.stop();
setArmed(null);
const target = (side === "start" ? reveal("start") : side === "end" ? -reveal("end") : 0) * dir.get();
if (reduce) x.set(target);
else anim.current = animate(x, target, { ...settleSpring, velocity: velocity * dir.get() });
targetRef.current = side;
setOpen(side);
}
function focusNextRow() {
const root = rootRef.current;
if (!root || !root.contains(document.activeElement)) return;
const scope = root.closest("ul,ol,[role=list],[role=grid]") ?? document;
const rows = Array.from(scope.querySelectorAll<HTMLElement>("[data-swipe-actions]")).filter((r) => r.dataset.state !== "committing" || r === root);
const i = rows.indexOf(root);
const next = rows[i + 1] ?? rows[i - 1];
next?.querySelector<HTMLElement>('[data-swipe-content] :is(a[href],button,[tabindex="0"]):not([tabindex="-1"]):not(:disabled)')?.focus();
}
async function remove(side: SwipeSide, action: SwipeAction, how: "gesture" | "pointer" | "keyboard", velocity = 0) {
const root = rootRef.current;
if (!root) return;
anim.current?.stop();
setPhase("committing");
const w = width();
if (!reduce && how !== "keyboard") {
setArmed(side, Math.max(0, actions[side].indexOf(action)));
const target = (side === "start" ? w : -w) * dir.get();
// A throw keeps its speed; a tap slides out on its own.
anim.current =
how === "gesture"
? animate(x, target, { type: "spring", stiffness: 380, damping: 42, velocity: velocity * dir.get(), restDelta: 2 })
: animate(x, target, { duration: 0.2, ease: ease.in });
await anim.current;
}
focusNextRow();
await animate(root, reduce ? { opacity: 0 } : { height: [root.offsetHeight, 0], opacity: how === "keyboard" ? 0 : 1 }, { duration: reduce ? 0.12 : 0.22, ease: ease.inOut });
action.onAction();
}
function run(side: SwipeSide, action: SwipeAction, how: "gesture" | "pointer" | "keyboard", velocity = 0) {
if (action.removes) return void remove(side, action, how, velocity);
action.onAction();
setCommits((c) => c + 1);
settle(null, velocity);
}
function release(velocity: number) {
const l = logical.get();
const side: SwipeSide = l >= 0 ? "start" : "end";
if (armedRef.current) {
const s = armedRef.current;
const primary = actions[s][0];
if (primary) return run(s, primary, "gesture", velocity);
}
const r = reveal(side);
if (!r) return settle(null, velocity);
const outward = side === "start" ? velocity : -velocity;
let openIt = Math.abs(l) > r * 0.5;
if (outward > 350) openIt = true;
else if (outward < -350) openIt = false;
settle(openIt ? side : null, velocity);
}
const velocityOf = (samples: { t: number; v: number }[]) => {
const now = performance.now();
const recent = samples.filter((s) => now - s.t < 100);
if (recent.length < 2) return 0;
const a = recent[0];
const b = recent[recent.length - 1];
return b.t === a.t ? 0 : ((b.v - a.v) / (b.t - a.t)) * 1000;
};
// ---- pointer ---------------------------------------------------------------------------
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (disabled || phase === "committing" || (e.pointerType === "mouse" && e.button !== 0)) return;
anim.current?.stop();
g.current = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: logical.get(), lock: null, width: width(), samples: [] };
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
const s = g.current;
if (s.id !== e.pointerId || s.lock === "y") return;
const dx = (e.clientX - s.x0) * dir.get();
const dy = e.clientY - s.y0;
if (!s.lock) {
if (Math.hypot(dx, dy) < 6) return;
// Mostly sideways is a swipe; anything else belongs to the page's scroll.
if (Math.abs(dx) < Math.abs(dy) * 1.2) {
s.lock = "y";
return;
}
s.lock = "x";
e.currentTarget.setPointerCapture(e.pointerId);
window.getSelection()?.removeAllRanges();
window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: uid }));
setPhase("dragging");
// Pick up from the finger, not from the 6px threshold, so the row never jumps.
s.x0 = e.clientX;
s.base = logical.get();
}
const l = constrain(s.base + (e.clientX - s.x0) * dir.get(), s.width);
moveTo(l);
s.samples.push({ t: performance.now(), v: l });
if (s.samples.length > 12) s.samples.shift();
}
function onPointerEnd(e: React.PointerEvent<HTMLDivElement>) {
const s = g.current;
if (s.id !== e.pointerId) return;
s.id = -1;
if (s.lock !== "x") return;
suppressClick.current = true;
setPhase("idle");
release(e.type === "pointercancel" ? 0 : velocityOf(s.samples));
}
// ---- trackpad: two-finger horizontal scroll swipes the row, like a desktop mail client ----
const onWheel = useEffectEvent((e: WheelEvent) => {
const w = wheel.current;
if (disabled || phase === "committing") return;
if (!w.active) {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY) || Math.abs(e.deltaX) < 2) return;
anim.current?.stop();
w.active = true;
w.base = logical.get();
w.acc = 0;
g.current.samples = [];
g.current.width = width();
window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: uid }));
setPhase("dragging");
}
e.preventDefault();
w.acc -= e.deltaX * dir.get();
const l = constrain(w.base + w.acc, g.current.width);
moveTo(l);
g.current.samples.push({ t: performance.now(), v: l });
window.clearTimeout(w.timer);
w.timer = window.setTimeout(() => {
w.active = false;
setPhase("idle");
release(0);
}, 140);
});
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const handler = (e: WheelEvent) => onWheel(e);
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, []);
// ---- closing from elsewhere ------------------------------------------------------------
const closeFromOutside = useEffectEvent(() => {
if (open && phase !== "committing") settle(null);
});
useEffect(() => {
const onEngage = (e: Event) => {
if ((e as CustomEvent).detail !== uid) closeFromOutside();
};
window.addEventListener(OPEN_EVENT, onEngage);
return () => window.removeEventListener(OPEN_EVENT, onEngage);
}, [uid]);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) closeFromOutside();
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [open]);
// A controlled `open` that changes from outside moves the row there.
const follow = useEffectEvent((next: SwipeSide | null) => {
if (phase !== "idle" || next === targetRef.current) return;
targetRef.current = next;
const target = (next === "start" ? reveal("start") : next === "end" ? -reveal("end") : 0) * dir.get();
if (Math.abs(x.get() - target) < 0.5) return;
anim.current?.stop();
if (reduce) x.set(target);
else anim.current = animate(x, target, settleSpring);
});
useEffect(() => follow(open), [open]);
const state = phase === "committing" ? "committing" : phase === "dragging" ? "dragging" : open ? "open" : "closed";
const showToolbar = toolbar && (startActions.length > 0 || endActions.length > 0);
const toolbarItems = [
...startActions.map((a) => ({ side: "start" as const, action: a })),
...[...endActions].reverse().map((a) => ({ side: "end" as const, action: a })),
];
return (
<div
ref={rootRef}
data-swipe-actions=""
data-state={state}
data-side={open ?? undefined}
data-armed={armed ?? undefined}
data-disabled={disabled || undefined}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.key === "Escape" && open) {
e.stopPropagation();
anim.current?.stop();
// Keyboard closes instantly.
x.set(0);
targetRef.current = null;
setOpen(null);
}
}}
className={cn("group/swipe relative isolate overflow-hidden bg-raised", className)}
{...rest}
>
{(["start", "end"] as const).map((side) =>
actions[side].length ? (
<div
key={side}
aria-hidden
className={cn("absolute inset-y-0 flex", side === "start" ? "start-0" : "end-0 flex-row-reverse")}
>
{actions[side].map((action, i) => (
<TrayAction
key={action.id}
action={action}
side={side}
index={i}
count={actions[side].length}
width={actionWidth}
revealed={side === "start" ? startRevealed : endRevealed}
arm={side === "start" ? armStart : armEnd}
dir={dir}
expand={armed === side ? expand : 0}
solid={armed === side && i === expand}
commits={i === 0 ? commits : 0}
reduce={!!reduce}
onPress={() => run(side, action, "pointer")}
/>
))}
</div>
) : null,
)}
<motion.div
ref={contentRef}
data-swipe-content=""
style={{ x }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onDragStart={(e) => e.preventDefault()}
onClickCapture={(e) => {
// A drag never lands as a click, and a tap on an open row just closes it.
if (suppressClick.current || (open && phase === "idle")) {
e.preventDefault();
e.stopPropagation();
if (!suppressClick.current) settle(null);
}
suppressClick.current = false;
}}
className={cn(
"group/row relative z-10 bg-inherit touch-pan-y",
state === "dragging" && "select-none",
state === "committing" && "pointer-events-none",
contentClassName,
)}
>
{children}
{showToolbar && (
<Toolbar
items={toolbarItems}
visible={state === "closed"}
disabled={disabled}
align={toolbarAlign}
onRun={(side, action, keyboard) => run(side, action, keyboard ? "keyboard" : "pointer")}
/>
)}
</motion.div>
</div>
);
}
/* -------------------------------------------------------------------------------------------------
* The revealed tray
* -----------------------------------------------------------------------------------------------*/
function TrayAction({
action,
side,
index,
count,
expand,
width,
revealed,
arm,
dir,
solid,
commits,
reduce,
onPress,
}: {
action: SwipeAction;
side: SwipeSide;
index: number;
count: number;
expand: number;
width: number;
revealed: MotionValue<number>;
arm: MotionValue<number>;
dir: MotionValue<number>;
solid: boolean;
commits: number;
reduce: boolean;
onPress: () => void;
}) {
// Each action takes its share of the revealed width. Once a full swipe arms (or a tap commits),
// that action takes all of it and the rest fold away beneath the row.
const share = useTransform(() => {
const r = revealed.get();
const a = arm.get();
return index === expand ? r * (1 / count + (1 - 1 / count) * a) : (r * (1 - a)) / count;
});
const progress = useTransform(() => clamp(share.get() / width));
// Centered while narrower than its slot; once stretched, it hugs the row's edge and follows the finger.
const shift = useTransform(() => {
const w = share.get();
const off = w < width ? (w - width) / 2 : 0;
return (side === "end" ? off : -off) * dir.get();
});
const iconOpacity = useTransform(() => clamp((progress.get() - 0.35) / 0.5));
// Driven by the finger, so it stays under reduced motion; nothing moves on its own.
const iconScale = useTransform(() => 0.72 + 0.28 * progress.get());
const tone = tones[action.tone ?? "neutral"];
return (
<motion.button
type="button"
tabIndex={-1}
style={{ width: share }}
onClick={onPress}
className={cn(
"group/act relative flex h-full shrink-0 overflow-hidden outline-none",
side === "start" ? "justify-end" : "justify-start",
"transition-colors duration-150",
solid ? tone.solid : cn(tone.soft, tone.hover),
)}
>
<motion.span style={{ x: shift, width }} className="flex h-full shrink-0 flex-col items-center justify-center gap-1 transition-transform duration-100 group-active/act:scale-95">
<motion.span style={{ opacity: iconOpacity, scale: iconScale }} className="flex flex-col items-center gap-1">
<motion.span
key={commits}
initial={commits && !reduce ? { scale: 1.2 } : false}
animate={solid && !reduce ? { scale: [1, 1.16, 1] } : { scale: 1 }}
transition={{ duration: 0.3, ease: ease.out }}
className="grid size-4 place-items-center"
>
{action.icon}
</motion.span>
<span className="max-w-full truncate px-1 text-[11px] font-medium leading-none tracking-[-0.005em]">{action.label}</span>
</motion.span>
</motion.span>
</motion.button>
);
}
/* -------------------------------------------------------------------------------------------------
* Hover and focus toolbar: the same actions for a mouse, a keyboard and a screen reader
* -----------------------------------------------------------------------------------------------*/
function Toolbar({
items,
visible,
disabled,
align,
onRun,
}: {
items: { side: SwipeSide; action: SwipeAction }[];
visible: boolean;
disabled: boolean;
align: "center" | "top";
onRun: (side: SwipeSide, action: SwipeAction, keyboard: boolean) => void;
}) {
return (
<Tooltip.Provider delay={500} timeout={400}>
<div
role="group"
aria-label="Row actions"
className={cn(
"absolute end-2 z-10 flex items-center gap-0.5 rounded-lg border border-line-2 bg-raised p-0.5 shadow-[var(--shadow)]",
"origin-right transition-[opacity,scale,translate] duration-150 ease-out-expo motion-reduce:transition-opacity",
// Hidden until the row is hovered or something in it has keyboard focus. Never on touch alone.
"pointer-events-none translate-x-1 scale-96 opacity-0",
visible &&
"group-hover/row:pointer-events-auto group-hover/row:translate-x-0 group-hover/row:scale-100 group-hover/row:opacity-100 group-has-focus-visible/row:pointer-events-auto group-has-focus-visible/row:translate-x-0 group-has-focus-visible/row:scale-100 group-has-focus-visible/row:opacity-100",
align === "top" ? "top-1.5" : "inset-y-0 my-auto h-fit",
)}
>
{items.map(({ side, action }) => (
<Tooltip.Root key={`${side}-${action.id}`}>
<Tooltip.Trigger
render={
<button
type="button"
disabled={disabled}
aria-label={action.label}
onClick={(e) => onRun(side, action, e.detail === 0)}
className={cn(
"grid size-7 place-items-center rounded-md text-fg-2 outline-none",
"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75",
"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
"disabled:pointer-events-none disabled:opacity-50",
action.tone === "danger" && "hover:bg-danger-soft hover:text-danger",
)}
/>
}
>
{action.icon}
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Positioner side="top" sideOffset={8} collisionPadding={8} className="z-(--z-tooltip)">
<Tooltip.Popup
className={cn(
"origin-(--transform-origin) rounded-lg border border-line-2 bg-raised px-2 py-[3px] text-[12px] leading-5 text-fg shadow-pop",
"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100",
"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-instant:transition-none",
)}
>
{action.label}
</Tooltip.Popup>
</Tooltip.Positioner>
</Tooltip.Portal>
</Tooltip.Root>
))}
</div>
</Tooltip.Provider>
);
}05Props
SwipeActions
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | ReactNode | — | The row. Its own links and buttons keep working; a drag never lands on them as a click. |
| startActions | SwipeAction[] | [] | Revealed by swiping toward the end edge (right in LTR). The first sits outermost and is the one a full swipe runs. |
| endActions | SwipeAction[] | [] | Revealed by swiping toward the start edge (left in LTR). The first sits outermost and is the one a full swipe runs. |
| fullSwipe | boolean | "start" | "end" | true | Whether swiping past the tray runs the outermost action on release. Pass a side to allow it on one side only. |
| open | "start" | "end" | null | — | The open tray, controlled. Changing it from outside springs the row there. |
| defaultOpen | "start" | "end" | null | null | The tray open on first render, uncontrolled. |
| onOpenChange | (open: "start" | "end" | null) => void | — | Called when a swipe, a tap, Escape or another row closes or opens the tray. |
| toolbar | boolean | true | Show the same actions as icon buttons on hover and keyboard focus. |
| toolbarAlign | "center" | "top" | "center" | Center the toolbar on the row, or sit it on the first line where a timestamp usually is. |
| actionWidth | number | 72 | Width of each revealed action in px. |
| disabled | boolean | false | Ignores swipes and disables the toolbar. |
| contentClassName | string | — | Classes for the sliding layer, which inherits the row's background. |
SwipeAction
| Prop | Type | Default | Description |
|---|---|---|---|
| id* | string | — | Stable key for the action. |
| label* | string | — | A verb. Shown under the icon in the tray, and the tooltip and accessible name in the toolbar. |
| icon* | ReactNode | — | A 16px icon. |
| tone | "neutral" | "danger" | "success" | "warning" | "info" | "neutral" | Soft while revealed, solid once a full swipe will commit. |
| removes | boolean | false | The row slides out and folds its height shut before onAction runs, so the list closes the gap without a jump. |
| onAction* | () => void | — | Runs the action. For removing actions, drop the item here without an exit animation of your own. |
06Notes
Behavior
- Sideways movement past 6px takes the gesture; anything more vertical is left to the page, so lists still scroll with touch-action: pan-y.
- Past the tray, a full swipe arms at half the row's width: the chosen action takes the whole tray, turns solid and follows the row's edge. Pull back 24px to disarm. A fling alone never commits.
- Opening one row closes any other, as does tapping outside, tapping the row itself or pressing Escape. Two-finger trackpad swipes move the row like a drag.
- A removing action run from the toolbar by keyboard folds the row shut and moves focus to the next row, or the previous one if it was last.
Motion
- The row follows the finger 1:1, with rubber-band resistance (0.55 coefficient) past a tray that can't full-swipe and on a side with no actions.
- Release settles on a spring (stiffness 420, damping 40) that inherits the throw's velocity. Arming snaps the tray with the snappy spring and pops the icon to 1.16.
- Removal throws the row off on its release velocity, then folds its height in 220ms on the in-out curve. Reduced motion snaps between positions and fades the row out instead of folding it.
Accessibility
- The revealed tray is a touch affordance and is hidden from assistive tech; the toolbar carries the same actions as labeled buttons with tooltips, in the tab order.
- The toolbar appears on hover and whenever something in the row has keyboard focus, and never intercepts taps while hidden.
- Escape closes an open tray instantly, without animation.