{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "swipe-actions",
  "type": "registry:ui",
  "title": "Swipe actions",
  "description": "Reveal row actions with a swipe, or from the same actions in a menu.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@radix-ui/react-dropdown-menu",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/swipe-actions/swipe-actions.tsx",
      "type": "registry:component",
      "target": "@components/xzero/swipe-actions/swipe-actions.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from \"react\";\nimport type { CSSProperties, Dispatch, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, SetStateAction } from \"react\";\nimport * as Menu from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue } from \"motion/react\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./swipe-actions.module.css\";\n\nexport interface SwipeAction {\n  label: string;\n  icon: ReactNode;\n  /** Fill of the revealed action. Pair `danger` with a label that names the destructive result. */\n  tone?: \"neutral\" | \"accent\" | \"danger\";\n  /** Runs for a full swipe, a tap on the revealed action, or the More actions menu. Remove the item here unless `keepRow` is set. */\n  onSelect: () => void;\n  /** The row springs home after the action instead of sliding away and collapsing, for actions such as Mark as unread. */\n  keepRow?: boolean;\n}\n\n/**\n * 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.\n * 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\n * actions, so keyboard and screen reader users never need the gesture.\n */\nexport interface SwipeActionsProps { label: string; children: ReactNode; className?: string }\n\n/** 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. */\nexport interface SwipeActionsRowProps {\n  /** Names the row in its menu button, for example the message subject. */\n  label: string;\n  leading?: SwipeAction[];\n  trailing?: SwipeAction[];\n  /** Lets a long swipe commit the outermost action without a tap. On by default. */\n  fullSwipe?: boolean;\n  children: ReactNode;\n  className?: string;\n}\n\ntype Side = \"leading\" | \"trailing\";\ntype Group = { openId: string | null; setOpenId: Dispatch<SetStateAction<string | null>>; rows: Map<string, HTMLElement> };\ntype Drag = { pointer: number; type: string; startX: number; startY: number; origin: number; locked: \"x\" | \"y\" | null; samples: [number, number][] };\n\n/** Width of one action while a row rests open. */\nconst ACTION = 76;\n/** Movement before a press is read as a swipe or handed to the page as a scroll. */\nconst SLOP = 8;\n/** Seconds of travel projected from the release velocity, roughly a 0.99 deceleration rate. */\nconst PROJECTION = .1;\n/** Release speed in px/s that counts as a flick. */\nconst FLICK = 900;\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n/** Past its last stop the row still follows, but every pixel costs more, like pulling against elastic. */\nconst rubberBand = (overshoot: number, dimension: number) => (1 - 1 / (overshoot * .55 / dimension + 1)) * dimension;\nconst sideOf = (value: number): Side | null => value > 0 ? \"leading\" : value < 0 ? \"trailing\" : null;\n\nconst GroupContext = createContext<Group>({ openId: null, setOpenId: () => {}, rows: new Map() });\n\nexport function SwipeActions({ label, children, className }: SwipeActionsProps) {\n  const [openId, setOpenId] = useState<string | null>(null);\n  const [rows] = useState(() => new Map<string, HTMLElement>());\n  useEffect(() => {\n    if (!openId) return;\n    const onPointerDown = (event: PointerEvent) => { const row = rows.get(openId); if (row && event.target instanceof Node && row.contains(event.target)) return; setOpenId(null); };\n    const onKeyDown = (event: KeyboardEvent) => { if (event.key === \"Escape\") setOpenId(null); };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => { document.removeEventListener(\"pointerdown\", onPointerDown, true); document.removeEventListener(\"keydown\", onKeyDown); };\n  }, [openId, rows]);\n  const group = useMemo(() => ({ openId, setOpenId, rows }), [openId, rows]);\n  return <GroupContext.Provider value={group}>\n    <div className={[styles.surface, className].filter(Boolean).join(\" \")}>\n      <ul role=\"list\" aria-label={label} tabIndex={-1} className={styles.list}>\n        <AnimatePresence initial={false}>{children}</AnimatePresence>\n      </ul>\n    </div>\n  </GroupContext.Provider>;\n}\n\nfunction velocityOf(samples: [number, number][]) {\n  const last = samples[samples.length - 1];\n  if (!last) return 0;\n  const first = samples.find(sample => last[0] - sample[0] <= 80) ?? last;\n  const elapsed = (last[0] - first[0]) / 1000;\n  return elapsed > 0 ? (last[1] - first[1]) / elapsed : 0;\n}\n\nexport function SwipeActionsRow({ label, leading = [], trailing = [], fullSwipe = true, children, className }: SwipeActionsRowProps) {\n  const { openId, setOpenId, rows } = useContext(GroupContext);\n  const id = useId();\n  const reduced = useReducedMotion() ?? false;\n  const present = useIsPresent();\n  const rowRef = useRef<HTMLLIElement>(null);\n  const x = useMotionValue(0);\n  const cover = useMotionValue(0);\n  const [covering, setCovering] = useState<{ side: Side; index: number } | null>(null);\n  const [menuOpen, setMenuOpen] = useState(false);\n  const width = useRef(0);\n  const drag = useRef<Drag | null>(null);\n  const travel = useRef<AnimationPlaybackControls | null>(null);\n  const stretch = useRef<AnimationPlaybackControls | null>(null);\n  const armed = useRef(false);\n  const leaving = useRef(false);\n  const swallowClick = useRef(false);\n  const focusNeighbour = useRef(false);\n  const restoreTimer = useRef(0);\n\n  const actionsOf = (side: Side) => side === \"leading\" ? leading : trailing;\n  const openWidth = (side: Side) => actionsOf(side).length * ACTION;\n  const outermost = (side: Side) => side === \"leading\" ? 0 : trailing.length - 1;\n  const canCommit = (side: Side) => fullSwipe && actionsOf(side).length > 0;\n  const threshold = (side: Side) => Math.max(openWidth(side) + 48, width.current * .56);\n\n  function stretchTo(value: number) {\n    stretch.current?.stop();\n    if (reduced) { cover.jump(value); return; }\n    stretch.current = animate(cover, value, motionTokens.spring.morph);\n  }\n\n  /** Springs the row to a stop, carrying the release velocity. Travel snaps instantly with reduced motion. */\n  function settle(target: number, velocity = 0) {\n    if (armed.current || (target !== 0 && cover.get() !== 0)) stretchTo(0);\n    armed.current = false;\n    const from = x.get();\n    const home = () => { if (target === 0) { stretch.current?.stop(); cover.jump(0); } };\n    if (reduced) { x.jump(target); home(); }\n    else {\n      // Heading home, the spring may not carry the row past zero, or the opposite actions would flash.\n      const toward = target === 0 && Math.sign(velocity) === -Math.sign(from);\n      const launch = toward ? Math.sign(velocity) * Math.min(Math.abs(velocity), Math.abs(from) * 12) : velocity;\n      travel.current = animate(x, target, { ...(target === 0 ? motionTokens.spring.smooth : motionTokens.spring.snappy), velocity: launch, onComplete: home });\n    }\n    if (target === 0) setOpenId(current => current === id ? null : current);\n    else setOpenId(id);\n  }\n\n  /** The chosen action stretches over the whole row while the row leaves in its direction; the list then closes the gap. */\n  function commit(side: Side, index: number, velocity = 0) {\n    const action = actionsOf(side)[index];\n    if (!action || leaving.current) return;\n    const direction = side === \"leading\" ? 1 : -1;\n    armed.current = false;\n    setCovering({ side, index });\n    stretchTo(1);\n    if (action.keepRow) { action.onSelect(); settle(0, velocity); return; }\n    leaving.current = true;\n    const row = rowRef.current;\n    if (row) row.dataset.removing = \"\";\n    travel.current?.stop();\n    const end = direction * (width.current + 2);\n    if (reduced) x.jump(end);\n    else travel.current = animate(x, end, { ...motionTokens.spring.smooth, velocity: Math.sign(velocity) === direction ? velocity : 0 });\n    setOpenId(current => current === id ? null : current);\n    action.onSelect();\n    // A list that keeps the item gets its row back instead of an empty band of colour.\n    window.clearTimeout(restoreTimer.current);\n    restoreTimer.current = window.setTimeout(() => { if (!leaving.current) return; leaving.current = false; if (row) delete row.dataset.removing; settle(0); }, 1400);\n  }\n\n  function arm(value: number, pointerType: string) {\n    const side = sideOf(value);\n    const limit = side ? threshold(side) : Infinity;\n    const next = !!side && canCommit(side) && Math.abs(value) > (armed.current ? limit - 20 : limit);\n    if (next === armed.current || !side) { if (!side && armed.current) { armed.current = false; stretchTo(0); } return; }\n    armed.current = next;\n    if (next) {\n      setCovering({ side, index: outermost(side) });\n      if (pointerType === \"touch\") navigator.vibrate?.(8);\n    }\n    stretchTo(next ? 1 : 0);\n  }\n\n  function constrain(raw: number) {\n    const side = sideOf(raw);\n    if (!side) return 0;\n    const dimension = width.current || 320;\n    const limit = actionsOf(side).length === 0 ? 0 : canCommit(side) ? dimension : openWidth(side);\n    const distance = Math.abs(raw);\n    return Math.sign(raw) * (distance <= limit ? distance : limit + rubberBand(distance - limit, dimension));\n  }\n\n  function release(velocity: number) {\n    const value = x.get(), side = sideOf(value);\n    if (!side || actionsOf(side).length === 0) { settle(0, velocity); return; }\n    const direction = side === \"leading\" ? 1 : -1;\n    const distance = Math.abs(value), outward = velocity * direction, open = openWidth(side);\n    const projected = distance + outward * PROJECTION;\n    const flung = distance > open && outward > FLICK && projected > threshold(side);\n    // A flick back toward home cancels an armed full swipe.\n    if (canCommit(side) && ((armed.current && outward > -FLICK) || flung)) { commit(side, outermost(side), velocity); return; }\n    settle(projected > open / 2 ? direction * open : 0, velocity);\n  }\n\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    swallowClick.current = false;\n    if (leaving.current || !event.isPrimary || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    // Catch the row mid-flight: the next move retargets from wherever it is now.\n    travel.current?.stop();\n    drag.current = { pointer: event.pointerId, type: event.pointerType, startX: event.clientX, startY: event.clientY, origin: x.get(), locked: null, samples: [[event.timeStamp, event.clientX]] };\n  }\n\n  function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = drag.current;\n    if (!current || current.pointer !== event.pointerId || current.locked === \"y\") return;\n    if (!current.locked) {\n      const dx = event.clientX - current.startX, dy = event.clientY - current.startY;\n      if (Math.hypot(dx, dy) < SLOP) return;\n      if (Math.abs(dy) > Math.abs(dx)) { current.locked = \"y\"; return; }\n      // Measure from here, so crossing the slop never jumps the row.\n      current.locked = \"x\";\n      current.startX = event.clientX;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      if (rowRef.current) rowRef.current.dataset.dragging = \"\";\n      setOpenId(id);\n    }\n    const value = constrain(current.origin + event.clientX - current.startX);\n    x.set(value);\n    current.samples.push([event.timeStamp, event.clientX]);\n    if (current.samples.length > 12) current.samples.shift();\n    arm(value, current.type);\n  }\n\n  function onPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = drag.current;\n    if (!current || current.pointer !== event.pointerId) return;\n    drag.current = null;\n    if (current.locked === \"x\") {\n      swallowClick.current = true;\n      if (rowRef.current) delete rowRef.current.dataset.dragging;\n      current.samples.push([event.timeStamp, event.clientX]);\n      release(event.type === \"pointercancel\" ? 0 : velocityOf(current.samples));\n      return;\n    }\n    // A tap on an open row closes it instead of acting on what is underneath.\n    if (current.locked === null && Math.abs(x.get()) > .5) { swallowClick.current = true; settle(0); }\n  }\n\n  function onClickCapture(event: ReactMouseEvent<HTMLDivElement>) {\n    if (!swallowClick.current) return;\n    swallowClick.current = false;\n    event.preventDefault();\n    event.stopPropagation();\n  }\n\n  function moveFocusToNeighbour() {\n    const row = rowRef.current;\n    if (!row) return;\n    const staying = (step: \"nextElementSibling\" | \"previousElementSibling\") => {\n      let node = row[step];\n      while (node instanceof HTMLElement && \"removing\" in node.dataset) node = node[step];\n      return node;\n    };\n    const neighbour = staying(\"nextElementSibling\") ?? staying(\"previousElementSibling\");\n    (neighbour?.querySelector<HTMLElement>(\"[data-swipe-more]\") ?? row.parentElement)?.focus({ preventScroll: true });\n  }\n\n  useEffect(() => {\n    const row = rowRef.current;\n    if (!row) return;\n    rows.set(id, row);\n    width.current = row.offsetWidth;\n    const observer = new ResizeObserver(([entry]) => { if (entry) width.current = entry.contentRect.width; });\n    observer.observe(row);\n    return () => { observer.disconnect(); rows.delete(id); window.clearTimeout(restoreTimer.current); setOpenId(current => current === id ? null : current); };\n  }, [id, rows, setOpenId]);\n\n  // Another row opened, or someone touched outside: put this one away.\n  const closeForOthers = useEffectEvent(() => { if (openId !== id && !leaving.current && drag.current?.locked !== \"x\" && x.get() !== 0) settle(0); });\n  useEffect(() => { closeForOthers(); }, [openId]);\n\n  // Undo brought the item back while it was still leaving: slide it home as the height reopens.\n  const returnHome = useEffectEvent(() => {\n    if (!leaving.current) return;\n    leaving.current = false;\n    window.clearTimeout(restoreTimer.current);\n    if (rowRef.current) delete rowRef.current.dataset.removing;\n    settle(0);\n  });\n  useEffect(() => { if (present) returnHome(); }, [present]);\n\n  const menuItems = [...leading.map((action, index) => ({ action, side: \"leading\" as const, index })), ...trailing.map((action, index) => ({ action, side: \"trailing\" as const, index }))];\n  const collapse = reduced\n    ? { opacity: 0, transition: { duration: .15 } }\n    : { height: 0, opacity: 0, transition: { height: { ...motionTokens.spring.smooth, delay: .12 }, opacity: { duration: motionTokens.duration.instant, delay: .34 } } };\n\n  return <motion.li ref={rowRef} className={[styles.row, className].filter(Boolean).join(\" \")} style={{ \"--swipe-action-width\": `${ACTION}px` } as CSSProperties}\n    initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }} exit={collapse}\n    transition={reduced ? { duration: .15 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }}>\n    {([\"leading\", \"trailing\"] as const).map(side => actionsOf(side).map((action, index) => {\n      const count = actionsOf(side).length;\n      const rank = side === \"leading\" ? count - 1 - index : index;\n      const coverRank = covering?.side === side ? (side === \"leading\" ? count - 1 - covering.index : covering.index) : null;\n      return <ActionLayer key={`${side}-${index}`} action={action} side={side} rank={rank} count={count} coverRank={coverRank} x={x} cover={cover} onPress={() => commit(side, index)} />;\n    }))}\n    <motion.div className={styles.content} style={{ x }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onClickCapture={onClickCapture}>\n      <div className={styles.body}>{children}</div>\n      {menuItems.length > 0 && <Menu.Root open={menuOpen} onOpenChange={setMenuOpen}>\n        {/* Opens on click rather than on press, so a swipe that starts on the button still moves the row. */}\n        <Menu.Trigger className={styles.more} data-swipe-more=\"\" aria-label={`More actions for ${label}`} onPointerDown={event => event.preventDefault()} onClick={event => { if (event.detail > 0) setMenuOpen(open => !open); }}>\n          <MoreHorizontal size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n        </Menu.Trigger>\n        <Menu.Portal>\n          <Menu.Content className={styles.menu} align=\"end\" sideOffset={6} collisionPadding={12} loop onCloseAutoFocus={event => { if (!focusNeighbour.current) return; focusNeighbour.current = false; event.preventDefault(); moveFocusToNeighbour(); }}>\n            {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); }}>\n              <span className={styles.itemIcon} aria-hidden=\"true\">{action.icon}</span>{action.label}\n            </Menu.Item>)}\n          </Menu.Content>\n        </Menu.Portal>\n      </Menu.Root>}\n    </motion.div>\n  </motion.li>;\n}\n\ntype LayerProps = { action: SwipeAction; side: Side; rank: number; count: number; coverRank: number | null; x: MotionValue<number>; cover: MotionValue<number>; onPress: () => void };\n\n/**\n * 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\n * 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.\n */\nfunction ActionLayer({ action, side, rank, count, coverRank, x, cover, onPress }: LayerProps) {\n  const direction = side === \"leading\" ? 1 : -1;\n  const segment = useTransform(x, value => Math.max(0, value * direction) / count);\n  // Distance from the row's outer edge to this layer's inner edge.\n  const inset = useTransform([x, cover], ([value = 0, progress = 0]: number[]) => {\n    const revealed = Math.max(0, value * direction), base = (count - rank) * revealed / count;\n    if (coverRank === rank) return base + progress * (revealed - base);\n    if (coverRank !== null && rank > coverRank) return base * (1 - progress);\n    return base;\n  });\n  const shift = useTransform(inset, value => direction * value);\n  // The glyph rides the centre of its share, then hugs the content edge while this action covers the row.\n  const glyphX = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => -direction * (coverRank === rank ? share / 2 + progress * (ACTION / 2 - share / 2) : share / 2));\n  const iconReveal = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => {\n    const own = clamp01((share - ACTION * .25) / (ACTION * .55));\n    if (coverRank === rank) return Math.max(own, progress);\n    return coverRank === null ? own : own * (1 - progress);\n  });\n  const labelReveal = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => {\n    const own = clamp01((share - ACTION * .72) / (ACTION * .24));\n    if (coverRank === rank) return Math.max(own, progress);\n    return coverRank === null ? own : own * (1 - progress);\n  });\n  const iconScale = useTransform(iconReveal, value => .6 + .4 * value);\n  // The menu button is the accessible path, so the revealed copy stays out of the tab order and the accessibility tree.\n  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}>\n    <motion.span className={styles.anchor} style={{ x: glyphX }}>\n      <span className={styles.glyph}>\n        <motion.span className={styles.icon} style={{ opacity: iconReveal, scale: iconScale }}>{action.icon}</motion.span>\n        <motion.span className={styles.label} style={{ opacity: labelReveal }}>{action.label}</motion.span>\n      </span>\n    </motion.span>\n  </motion.button>;\n}\n\nexport default SwipeActions;\n"
    },
    {
      "path": "registry/components/swipe-actions/swipe-actions.module.css",
      "type": "registry:component",
      "target": "@components/xzero/swipe-actions/swipe-actions.module.css",
      "content": "/* The surface clips every row, so revealed actions never spill past its rounded corners. It hides once the last row has left. */\n.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); }\n/* When the last row is on its way out, the frame fades with it instead of leaving a hairline behind. */\n.surface:not(:has(> .list > .row:not([data-removing]))) { border-color: transparent; background-color: transparent; }\n.surface:has(> .list:empty) { display: none; }\n/* Pulled down one pixel so the last separator tucks under the frame, instead of toggling with :last-child while rows leave. */\n.list { margin: 0 0 -1px; padding: 0; list-style: none; }\n.list:focus-visible { outline: none; }\n.row { position: relative; isolation: isolate; overflow: hidden; }\n/* The content follows the finger by transform only; vertical pans stay with the page. */\n.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; }\n.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; }\n.content img { -webkit-user-drag: none; }\n.body { min-width: 0; flex: 1 1 auto; }\n.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); }\n/* The button anchors a menu, so a press answers with colour, never scale. */\n.more:active, .more[data-state=\"open\"] { background: var(--surface-muted); color: var(--foreground); }\n.more:focus-visible { outline: none; }\n@media (hover: hover) and (pointer: fine) {\n  .content { cursor: grab; }\n  .more:hover { background: var(--surface-muted); color: var(--foreground); }\n}\n.row[data-dragging] .content { cursor: grabbing; }\n.row[data-dragging] .more { background: transparent; color: var(--text-muted); }\n\n/* Each action spans the full row from the edge it hides behind and slides in by transform. Outer actions stack above inner ones. */\n.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; }\n.layer[data-side=\"trailing\"] { left: 100%; }\n.layer[data-side=\"leading\"] { right: 100%; }\n.layer[data-tone=\"accent\"] { background: var(--accent); color: var(--accent-foreground); }\n.layer[data-tone=\"danger\"] { background: var(--danger); color: var(--background); }\n.layer:focus { outline: none; }\n.anchor { position: absolute; top: 0; bottom: 0; width: 0; }\n.layer[data-side=\"trailing\"] .anchor { left: 0; }\n.layer[data-side=\"leading\"] .anchor { right: 0; }\n.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; }\n.icon { display: grid; place-items: center; }\n.icon svg { width: 20px; height: 20px; }\n.label { font-size: var(--text-xs); font-weight: 500; letter-spacing: var(--tracking-body); line-height: var(--leading-body); white-space: nowrap; }\n\n/* The menu grows from its trigger, matching the library dropdown. */\n.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: 60; min-width: 11rem; padding: 5px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); }\n.menu[data-side=\"top\"] { --menu-y: 5px; }\n.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; }\n.menu[data-state=\"closed\"] { animation: menu-out var(--duration-instant) var(--ease-standard) both; }\n.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); }\n.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); }\n.item[data-highlighted] { background: var(--surface-muted); }\n.item[data-tone=\"danger\"] { color: var(--danger); }\n.item[data-tone=\"danger\"][data-highlighted] { background: color-mix(in oklab, var(--danger) 8%, var(--surface-raised)); }\n.itemIcon { display: inline-flex; width: 17px; color: var(--text-secondary); }\n.itemIcon svg { width: 16px; height: 16px; }\n.item[data-tone=\"danger\"] .itemIcon { color: var(--danger); }\n@keyframes menu-fade-in { from { opacity: 0; } }\n@keyframes menu-rise-in { from { transform: translate(var(--menu-x), var(--menu-y)) scale(.97); } }\n@keyframes menu-out { to { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); } }\n@keyframes menu-item-in { from { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); } }\n@keyframes menu-fade-out { to { opacity: 0; } }\n@media (prefers-reduced-motion: reduce) {\n  .surface, .more, .item { transition: none; }\n  .menu[data-state=\"open\"] { animation: menu-fade-in var(--duration-instant) linear both; }\n  .menu[data-state=\"closed\"] { animation: menu-fade-out var(--duration-instant) linear both; }\n  .menu[data-state=\"open\"] .item { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/swipe-actions",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "swipe",
      "gesture",
      "list"
    ],
    "docs": "https://ui.xzero.co.uk/components/swipe-actions",
    "markdown": "https://ui.xzero.co.uk/components/swipe-actions/markdown"
  }
}