컴포넌트
스와이프 액션
목록 행을 옆으로 밀어 보관이나 삭제 같은 동작을 꺼내는 목록이에요.
- #arc
- #component
- #list
- #swipe
- #gesture
- #action
- #mobile
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/swipe-actions.json파일
components/arc/swipe-actions/swipe-actions.module.csscomponents/arc/swipe-actions/swipe-actions.tsx
npm 패키지
@radix-ui/react-dropdown-menu@^2.2.1motion@^14.2.0
함께 오는 항목
swipe-actions.module.cssswipe-actions.module.cssswipe-actions.module.csscss
/* The surface clips every row, so revealed actions never spill past its rounded corners. It hides once the last row has left. */
.surface { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
/* When the last row is on its way out, the frame fades with it instead of leaving a hairline behind. */
.surface:not(:has(> .list > .row:not([data-removing]))) { border-color: transparent; background-color: transparent; }
.surface:has(> .list:empty) { display: none; }
/* Pulled down one pixel so the last separator tucks under the frame, instead of toggling with :last-child while rows leave. */
.list { margin: 0 0 -1px; padding: 0; list-style: none; }
.list:focus-visible { outline: none; }
.row { position: relative; isolation: isolate; overflow: hidden; }
/* The content follows the finger by transform only; vertical pans stay with the page. */
.content { position: relative; z-index: 10; display: flex; min-height: 64px; align-items: center; gap: var(--space-2); padding: var(--space-3) var(--space-2) var(--space-3) var(--space-4); background: var(--surface); touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.content::after { position: absolute; right: 0; bottom: 0; left: var(--swipe-actions-inset, var(--space-4)); height: 1px; background: var(--border); content: ""; pointer-events: none; }
.content img { -webkit-user-drag: none; }
.body { min-width: 0; flex: 1 1 auto; }
.more { display: grid; width: 32px; height: 32px; flex: none; place-items: center; border: 0; border-radius: calc(var(--radius-control) - 6px); background: transparent; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
/* The button anchors a menu, so a press answers with colour, never scale. */
.more:active, .more[data-state="open"] { background: var(--surface-muted); color: var(--foreground); }
.more:focus-visible { outline: none; }
@media (hover: hover) and (pointer: fine) {
.content { cursor: grab; }
.more:hover { background: var(--surface-muted); color: var(--foreground); }
}
.row[data-dragging] .content { cursor: grabbing; }
.row[data-dragging] .more { background: transparent; color: var(--text-muted); }
/* Each action spans the full row from the edge it hides behind and slides in by transform. Outer actions stack above inner ones. */
.layer { position: absolute; top: 0; bottom: 0; width: 100%; padding: 0; border: 0; background: color-mix(in oklab, var(--foreground) 20%, var(--surface)); color: var(--foreground); font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.layer[data-side="trailing"] { left: 100%; }
.layer[data-side="leading"] { right: 100%; }
.layer[data-tone="accent"] { background: var(--accent); color: var(--accent-foreground); }
.layer[data-tone="danger"] { background: var(--danger); color: var(--background); }
.layer:focus { outline: none; }
.anchor { position: absolute; top: 0; bottom: 0; width: 0; }
.layer[data-side="trailing"] .anchor { left: 0; }
.layer[data-side="leading"] .anchor { right: 0; }
.glyph { position: absolute; top: 0; bottom: 0; left: calc(var(--swipe-action-width) / -2); display: flex; width: var(--swipe-action-width); flex-direction: column; align-items: center; justify-content: center; gap: 3px; }
.icon { display: grid; place-items: center; }
.icon svg { width: 20px; height: 20px; }
.label { font-size: var(--text-xs); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); white-space: nowrap; }
/* The menu grows from its trigger, matching the library dropdown. */
.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: 11rem; padding: 5px; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); }
.menu[data-side="top"] { --menu-y: 5px; }
.menu[data-state="open"] { animation: menu-fade-in var(--duration-fast) var(--ease-enter) both, menu-rise-in var(--duration-spring) var(--ease-spring) both; }
.menu[data-state="closed"] { animation: menu-out var(--duration-instant) var(--ease-standard) both; }
.menu[data-state="open"] .item { animation: menu-item-in var(--duration-standard) var(--ease-enter) both; animation-delay: calc(min(var(--i, 0), 4) * 35ms); }
.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: 10px; padding: 0 11px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); font-size: var(--text-sm); cursor: pointer; outline: none; transition: background-color var(--duration-instant) var(--ease-standard); }
.item[data-highlighted] { background: var(--surface-muted); }
.item[data-tone="danger"] { color: var(--danger); }
.item[data-tone="danger"][data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface-raised)); }
.itemIcon { display: inline-flex; width: 17px; color: var(--text-secondary); }
.itemIcon svg { width: 16px; height: 16px; }
.item[data-tone="danger"] .itemIcon { color: var(--danger); }
@keyframes menu-fade-in { from { opacity: 0; } }
@keyframes menu-rise-in { from { transform: translate(var(--menu-x), var(--menu-y)) scale(.97); } }
@keyframes menu-out { to { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); } }
@keyframes menu-item-in { from { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); } }
@keyframes menu-fade-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.surface, .more, .item { transition: none; }
.menu[data-state="open"] { animation: menu-fade-in var(--duration-instant) linear both; }
.menu[data-state="closed"] { animation: menu-fade-out var(--duration-instant) linear both; }
.menu[data-state="open"] .item { animation: none; }
}
swipe-actions.tsxswipe-actions.tsxswipe-actions.tsxtsx
"use client";
import { createContext, useContext, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, Dispatch, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, SetStateAction } from "react";
import * as Menu from "@radix-ui/react-dropdown-menu";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, MotionValue } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./swipe-actions.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface SwipeAction {
label: string;
icon: ReactNode;
/** Fill of the revealed action. Pair `danger` with a label that names the destructive result. */
tone?: "neutral" | "accent" | "danger";
/** Runs for a full swipe, a tap on the revealed action, or the More actions menu. Remove the item here unless `keepRow` is set. */
onSelect: () => void;
/** The row springs home after the action instead of sliding away and collapsing, for actions such as Mark as unread. */
keepRow?: boolean;
}
/**
* A list whose rows reveal actions on a horizontal swipe, the way a mail inbox does. Use it for short lists where people triage items quickly.
* Only one row stays open at a time; touching anywhere else or pressing Escape puts it away. Every row also has a More actions menu with the same
* actions, so keyboard and screen reader users never need the gesture.
*/
export interface SwipeActionsProps { label: string; children: ReactNode; className?: string }
/** One row. `leading` actions sit under the left edge and `trailing` actions under the right; the outermost action on each side commits on a full swipe. */
export interface SwipeActionsRowProps {
/** Names the row in its menu button, for example the message subject. */
label: string;
leading?: SwipeAction[];
trailing?: SwipeAction[];
/** Lets a long swipe commit the outermost action without a tap. On by default. */
fullSwipe?: boolean;
children: ReactNode;
className?: string;
}
type Side = "leading" | "trailing";
type Group = { openId: string | null; setOpenId: Dispatch<SetStateAction<string | null>>; rows: Map<string, HTMLElement> };
type Drag = { pointer: number; type: string; startX: number; startY: number; origin: number; locked: "x" | "y" | null; samples: [number, number][] };
/** Width of one action while a row rests open. */
const ACTION = 76;
/** Movement before a press is read as a swipe or handed to the page as a scroll. */
const SLOP = 8;
/** Seconds of travel projected from the release velocity, roughly a 0.99 deceleration rate. */
const PROJECTION = .1;
/** Release speed in px/s that counts as a flick. */
const FLICK = 900;
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
/** Past its last stop the row still follows, but every pixel costs more, like pulling against elastic. */
const rubberBand = (overshoot: number, dimension: number) => (1 - 1 / (overshoot * .55 / dimension + 1)) * dimension;
const sideOf = (value: number): Side | null => value > 0 ? "leading" : value < 0 ? "trailing" : null;
const GroupContext = createContext<Group>({ openId: null, setOpenId: () => {}, rows: new Map() });
export function SwipeActions({ label, children, className }: SwipeActionsProps) {
const [openId, setOpenId] = useState<string | null>(null);
const [rows] = useState(() => new Map<string, HTMLElement>());
useEffect(() => {
if (!openId) return;
const onPointerDown = (event: PointerEvent) => { const row = rows.get(openId); if (row && event.target instanceof Node && row.contains(event.target)) return; setOpenId(null); };
const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpenId(null); };
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeyDown); };
}, [openId, rows]);
const group = useMemo(() => ({ openId, setOpenId, rows }), [openId, rows]);
return <GroupContext.Provider value={group}>
<div className={[styles.surface, className].filter(Boolean).join(" ")}>
<ul role="list" aria-label={label} tabIndex={-1} className={styles.list}>
<AnimatePresence initial={false}>{children}</AnimatePresence>
</ul>
</div>
</GroupContext.Provider>;
}
function velocityOf(samples: [number, number][]) {
const last = samples[samples.length - 1];
if (!last) return 0;
const first = samples.find(sample => last[0] - sample[0] <= 80) ?? last;
const elapsed = (last[0] - first[0]) / 1000;
return elapsed > 0 ? (last[1] - first[1]) / elapsed : 0;
}
export function SwipeActionsRow({ label, leading = [], trailing = [], fullSwipe = true, children, className }: SwipeActionsRowProps) {
const { openId, setOpenId, rows } = useContext(GroupContext);
const id = useId();
const reduced = useReducedMotion() ?? false;
const present = useIsPresent();
const rowRef = useRef<HTMLLIElement>(null);
const x = useMotionValue(0);
const cover = useMotionValue(0);
const [covering, setCovering] = useState<{ side: Side; index: number } | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const width = useRef(0);
const drag = useRef<Drag | null>(null);
const travel = useRef<AnimationPlaybackControls | null>(null);
const stretch = useRef<AnimationPlaybackControls | null>(null);
const armed = useRef(false);
const leaving = useRef(false);
const swallowClick = useRef(false);
const focusNeighbour = useRef(false);
const restoreTimer = useRef(0);
const actionsOf = (side: Side) => side === "leading" ? leading : trailing;
const openWidth = (side: Side) => actionsOf(side).length * ACTION;
const outermost = (side: Side) => side === "leading" ? 0 : trailing.length - 1;
const canCommit = (side: Side) => fullSwipe && actionsOf(side).length > 0;
const threshold = (side: Side) => Math.max(openWidth(side) + 48, width.current * .56);
function stretchTo(value: number) {
stretch.current?.stop();
if (reduced) { cover.jump(value); return; }
stretch.current = animate(cover, value, motionTokens.spring.morph);
}
/** Springs the row to a stop, carrying the release velocity. Travel snaps instantly with reduced motion. */
function settle(target: number, velocity = 0) {
if (armed.current || (target !== 0 && cover.get() !== 0)) stretchTo(0);
armed.current = false;
const from = x.get();
const home = () => { if (target === 0) { stretch.current?.stop(); cover.jump(0); } };
if (reduced) { x.jump(target); home(); }
else {
// Heading home, the spring may not carry the row past zero, or the opposite actions would flash.
const toward = target === 0 && Math.sign(velocity) === -Math.sign(from);
const launch = toward ? Math.sign(velocity) * Math.min(Math.abs(velocity), Math.abs(from) * 12) : velocity;
travel.current = animate(x, target, { ...(target === 0 ? motionTokens.spring.smooth : motionTokens.spring.snappy), velocity: launch, onComplete: home });
}
if (target === 0) setOpenId(current => current === id ? null : current);
else setOpenId(id);
}
/** The chosen action stretches over the whole row while the row leaves in its direction; the list then closes the gap. */
function commit(side: Side, index: number, velocity = 0) {
const action = actionsOf(side)[index];
if (!action || leaving.current) return;
playUiSound("click");
const direction = side === "leading" ? 1 : -1;
armed.current = false;
setCovering({ side, index });
stretchTo(1);
if (action.keepRow) { action.onSelect(); settle(0, velocity); return; }
leaving.current = true;
const row = rowRef.current;
if (row) row.dataset.removing = "";
travel.current?.stop();
const end = direction * (width.current + 2);
if (reduced) x.jump(end);
else travel.current = animate(x, end, { ...motionTokens.spring.smooth, velocity: Math.sign(velocity) === direction ? velocity : 0 });
setOpenId(current => current === id ? null : current);
action.onSelect();
// A list that keeps the item gets its row back instead of an empty band of colour.
window.clearTimeout(restoreTimer.current);
restoreTimer.current = window.setTimeout(() => { if (!leaving.current) return; leaving.current = false; if (row) delete row.dataset.removing; settle(0); }, 1400);
}
function arm(value: number, pointerType: string) {
const side = sideOf(value);
const limit = side ? threshold(side) : Infinity;
const next = !!side && canCommit(side) && Math.abs(value) > (armed.current ? limit - 20 : limit);
if (next === armed.current || !side) { if (!side && armed.current) { armed.current = false; stretchTo(0); } return; }
armed.current = next;
if (next) {
setCovering({ side, index: outermost(side) });
if (pointerType === "touch") navigator.vibrate?.(8);
}
stretchTo(next ? 1 : 0);
}
function constrain(raw: number) {
const side = sideOf(raw);
if (!side) return 0;
const dimension = width.current || 320;
const limit = actionsOf(side).length === 0 ? 0 : canCommit(side) ? dimension : openWidth(side);
const distance = Math.abs(raw);
return Math.sign(raw) * (distance <= limit ? distance : limit + rubberBand(distance - limit, dimension));
}
function release(velocity: number) {
const value = x.get(), side = sideOf(value);
if (!side || actionsOf(side).length === 0) { settle(0, velocity); return; }
const direction = side === "leading" ? 1 : -1;
const distance = Math.abs(value), outward = velocity * direction, open = openWidth(side);
const projected = distance + outward * PROJECTION;
const flung = distance > open && outward > FLICK && projected > threshold(side);
// A flick back toward home cancels an armed full swipe.
if (canCommit(side) && ((armed.current && outward > -FLICK) || flung)) { commit(side, outermost(side), velocity); return; }
if (projected > open / 2 && openId !== id) playUiSound("click");
settle(projected > open / 2 ? direction * open : 0, velocity);
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
swallowClick.current = false;
if (leaving.current || !event.isPrimary || (event.pointerType === "mouse" && event.button !== 0)) return;
// Catch the row mid-flight: the next move retargets from wherever it is now.
travel.current?.stop();
drag.current = { pointer: event.pointerId, type: event.pointerType, startX: event.clientX, startY: event.clientY, origin: x.get(), locked: null, samples: [[event.timeStamp, event.clientX]] };
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
const current = drag.current;
if (!current || current.pointer !== event.pointerId || current.locked === "y") return;
if (!current.locked) {
const dx = event.clientX - current.startX, dy = event.clientY - current.startY;
if (Math.hypot(dx, dy) < SLOP) return;
if (Math.abs(dy) > Math.abs(dx)) { current.locked = "y"; return; }
// Measure from here, so crossing the slop never jumps the row.
current.locked = "x";
current.startX = event.clientX;
event.currentTarget.setPointerCapture(event.pointerId);
if (rowRef.current) rowRef.current.dataset.dragging = "";
setOpenId(id);
}
const value = constrain(current.origin + event.clientX - current.startX);
x.set(value);
current.samples.push([event.timeStamp, event.clientX]);
if (current.samples.length > 12) current.samples.shift();
arm(value, current.type);
}
function onPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
const current = drag.current;
if (!current || current.pointer !== event.pointerId) return;
drag.current = null;
if (current.locked === "x") {
swallowClick.current = true;
if (rowRef.current) delete rowRef.current.dataset.dragging;
current.samples.push([event.timeStamp, event.clientX]);
release(event.type === "pointercancel" ? 0 : velocityOf(current.samples));
return;
}
// A tap on an open row closes it instead of acting on what is underneath.
if (current.locked === null && Math.abs(x.get()) > .5) { swallowClick.current = true; settle(0); }
}
function onClickCapture(event: ReactMouseEvent<HTMLDivElement>) {
if (!swallowClick.current) return;
swallowClick.current = false;
event.preventDefault();
event.stopPropagation();
}
function moveFocusToNeighbour() {
const row = rowRef.current;
if (!row) return;
const staying = (step: "nextElementSibling" | "previousElementSibling") => {
let node = row[step];
while (node instanceof HTMLElement && "removing" in node.dataset) node = node[step];
return node;
};
const neighbour = staying("nextElementSibling") ?? staying("previousElementSibling");
(neighbour?.querySelector<HTMLElement>("[data-swipe-more]") ?? row.parentElement)?.focus({ preventScroll: true });
}
useEffect(() => {
const row = rowRef.current;
if (!row) return;
rows.set(id, row);
width.current = row.offsetWidth;
const observer = new ResizeObserver(([entry]) => { if (entry) width.current = entry.contentRect.width; });
observer.observe(row);
return () => { observer.disconnect(); rows.delete(id); window.clearTimeout(restoreTimer.current); setOpenId(current => current === id ? null : current); };
}, [id, rows, setOpenId]);
// Another row opened, or someone touched outside: put this one away.
const closeForOthers = useEffectEvent(() => { if (openId !== id && !leaving.current && drag.current?.locked !== "x" && x.get() !== 0) settle(0); });
useEffect(() => { closeForOthers(); }, [openId]);
// Undo brought the item back while it was still leaving: slide it home as the height reopens.
const returnHome = useEffectEvent(() => {
if (!leaving.current) return;
leaving.current = false;
window.clearTimeout(restoreTimer.current);
if (rowRef.current) delete rowRef.current.dataset.removing;
settle(0);
});
useEffect(() => { if (present) returnHome(); }, [present]);
const menuItems = [...leading.map((action, index) => ({ action, side: "leading" as const, index })), ...trailing.map((action, index) => ({ action, side: "trailing" as const, index }))];
const collapse = reduced
? { opacity: 0, transition: { duration: motionTokens.duration.fast } }
: { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: motionTokens.duration.instant }, opacity: { duration: motionTokens.duration.instant, delay: motionTokens.duration.moderate } } };
return <motion.li ref={rowRef} className={[styles.row, className].filter(Boolean).join(" ")} style={{ "--swipe-action-width": `${ACTION}px` } as CSSProperties}
initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={collapse}
transition={reduced ? { duration: motionTokens.duration.fast } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }}>
{(["leading", "trailing"] as const).map(side => actionsOf(side).map((action, index) => {
const count = actionsOf(side).length;
const rank = side === "leading" ? count - 1 - index : index;
const coverRank = covering?.side === side ? (side === "leading" ? count - 1 - covering.index : covering.index) : null;
return <ActionLayer key={`${side}-${index}`} action={action} side={side} rank={rank} count={count} coverRank={coverRank} x={x} cover={cover} onPress={() => commit(side, index)} />;
}))}
<motion.div className={styles.content} style={{ x }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onClickCapture={onClickCapture}>
<div className={styles.body}>{children}</div>
{menuItems.length > 0 && <Menu.Root open={menuOpen} onOpenChange={setMenuOpen}>
{/* Opens on click rather than on press, so a swipe that starts on the button still moves the row. */}
<Menu.Trigger className={styles.more} data-swipe-more="" aria-label={`${label} 더 보기`} onPointerDown={event => event.preventDefault()} onClick={event => { if (event.detail > 0) setMenuOpen(open => !open); }}>
<UiIcon iconRole="more" size={18} strokeWidth={1.75} aria-hidden="true" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content className={styles.menu} align="end" sideOffset={6} collisionPadding={12} loop onCloseAutoFocus={event => { if (!focusNeighbour.current) return; focusNeighbour.current = false; event.preventDefault(); moveFocusToNeighbour(); }}>
{menuItems.map(({ action, side, index }, order) => <Menu.Item key={`${side}-${index}`} className={styles.item} data-tone={action.tone} style={{ "--i": order } as CSSProperties} onSelect={() => { focusNeighbour.current = !action.keepRow; commit(side, index); }}>
<span className={styles.itemIcon} aria-hidden="true">{action.icon}</span>{action.label}
</Menu.Item>)}
</Menu.Content>
</Menu.Portal>
</Menu.Root>}
</motion.div>
</motion.li>;
}
type LayerProps = { action: SwipeAction; side: Side; rank: number; count: number; coverRank: number | null; x: MotionValue<number>; cover: MotionValue<number>; onPress: () => void };
/**
* One revealed action. Rank 0 sits against the content and the highest rank at the row's outer edge. Every layer spans the full row and slides in by
* transform only, so the actions share the revealed space evenly, widen together past the resting width, and the covering action can stretch across the row.
*/
function ActionLayer({ action, side, rank, count, coverRank, x, cover, onPress }: LayerProps) {
const direction = side === "leading" ? 1 : -1;
const segment = useTransform(x, value => Math.max(0, value * direction) / count);
// Distance from the row's outer edge to this layer's inner edge.
const inset = useTransform([x, cover], ([value = 0, progress = 0]: number[]) => {
const revealed = Math.max(0, value * direction), base = (count - rank) * revealed / count;
if (coverRank === rank) return base + progress * (revealed - base);
if (coverRank !== null && rank > coverRank) return base * (1 - progress);
return base;
});
const shift = useTransform(inset, value => direction * value);
// The glyph rides the centre of its share, then hugs the content edge while this action covers the row.
const glyphX = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => -direction * (coverRank === rank ? share / 2 + progress * (ACTION / 2 - share / 2) : share / 2));
const iconReveal = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => {
const own = clamp01((share - ACTION * .25) / (ACTION * .55));
if (coverRank === rank) return Math.max(own, progress);
return coverRank === null ? own : own * (1 - progress);
});
const labelReveal = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => {
const own = clamp01((share - ACTION * .72) / (ACTION * .24));
if (coverRank === rank) return Math.max(own, progress);
return coverRank === null ? own : own * (1 - progress);
});
const iconScale = useTransform(iconReveal, value => .6 + .4 * value);
// The menu button is the accessible path, so the revealed copy stays out of the tab order and the accessibility tree.
return <motion.button type="button" tabIndex={-1} aria-hidden="true" className={styles.layer} data-side={side} data-tone={action.tone ?? "neutral"} style={{ x: shift, zIndex: rank + 1 }} onClick={onPress}>
<motion.span className={styles.anchor} style={{ x: glyphX }}>
<span className={styles.glyph}>
<motion.span className={styles.icon} style={{ opacity: iconReveal, scale: iconScale }}>{action.icon}</motion.span>
<motion.span className={styles.label} style={{ opacity: labelReveal }}>{action.label}</motion.span>
</span>
</motion.span>
</motion.button>;
}
export default SwipeActions;