{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/swipe-actions/swipe-actions.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, useEffect, useEffectEvent, useId, useMemo, useRef, useState } from \"react\";\nimport type {\n  CSSProperties,\n  Dispatch,\n  MouseEvent as ReactMouseEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n  SetStateAction,\n} from \"react\";\nimport * as Menu from \"@radix-ui/react-dropdown-menu\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue } from \"motion/react\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\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 {\n  label: string;\n  children: ReactNode;\n  className?: string;\n}\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 = {\n  openId: string | null;\n  setOpenId: Dispatch<SetStateAction<string | null>>;\n  rows: Map<string, HTMLElement>;\n};\ntype Drag = {\n  pointer: number;\n  type: string;\n  startX: number;\n  startY: number;\n  origin: number;\n  locked: \"x\" | \"y\" | null;\n  samples: [number, number][];\n};\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 = 0.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 * 0.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\n/* The surface clips every row, so revealed actions never spill past its rounded corners. When the last row is on its way out,\n   the frame fades with it instead of leaving a hairline behind, and it hides once the last row has left. */\nconst surfaceClass =\n  \"overflow-hidden rounded-panel border border-line bg-surface transition-[border-color,background-color] duration-standard ease-standard not-has-[>ul>li:not([data-removing])]:border-transparent not-has-[>ul>li:not([data-removing])]:bg-transparent has-[>ul:empty]:hidden motion-reduce:transition-none\";\n/* The content follows the finger by transform only; vertical pans stay with the page. Its separator starts at the inset, and the\n   list is pulled down one pixel so the last separator tucks under the frame instead of toggling with :last-child while rows leave. */\nconst contentClass =\n  \"relative z-10 flex min-h-16 touch-pan-y items-center gap-2 bg-surface py-3 pr-2 pl-4 select-none group-data-dragging/row:cursor-grabbing [-webkit-touch-callout:none] after:pointer-events-none after:absolute after:right-0 after:bottom-0 after:left-(--swipe-actions-inset,var(--space-4)) after:h-px after:bg-line after:content-[''] [@media(hover:hover)_and_(pointer:fine)]:cursor-grab [&_img]:[-webkit-user-drag:none]\";\n/* The button anchors a menu, so a press answers with colour, never scale. */\nconst moreClass =\n  \"grid size-8 flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent text-ink-3 [-webkit-tap-highlight-color:transparent] transition-[color,background-color] duration-fast ease-standard hover:bg-hover hover:text-ink focus-visible:outline-none active:bg-hover active:text-ink data-[state=open]:bg-hover data-[state=open]:text-ink group-data-dragging/row:bg-transparent group-data-dragging/row:text-ink-3 motion-reduce:transition-none\";\n/* The menu grows from its trigger, matching the library dropdown. */\nconst menuClass =\n  \"group/menu relative z-60 min-w-44 origin-(--radix-dropdown-menu-content-transform-origin) rounded-panel border border-line bg-surface p-[5px] shadow-floating [--menu-x:0px] [--menu-y:-5px] data-[side=top]:[--menu-y:5px] data-[state=closed]:animate-swipe-actions-menu-out data-[state=open]:animate-swipe-actions-menu-in motion-reduce:data-[state=closed]:animate-swipe-actions-menu-fade-out motion-reduce:data-[state=open]:animate-swipe-actions-menu-fade-in\";\nconst itemClass =\n  \"group/item relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[calc(var(--radius-panel)-6px)] px-[11px] text-sm leading-[inherit] text-ink outline-none transition-[background-color] duration-instant ease-standard data-highlighted:bg-hover data-[tone=danger]:text-red data-[tone=danger]:data-highlighted:bg-[color-mix(in_oklab,var(--red)_8%,var(--surface))] group-data-[state=open]/menu:animate-swipe-actions-menu-item-in motion-reduce:transition-none motion-reduce:group-data-[state=open]/menu:animate-none\";\n/* Each action spans the full row from the edge it hides behind and slides in by transform. Outer actions stack above inner ones. */\nconst layerClass =\n  \"group/layer absolute top-0 bottom-0 w-full cursor-pointer border-0 bg-[color-mix(in_oklab,var(--ink)_20%,var(--surface))] p-0 text-ink [-webkit-tap-highlight-color:transparent] focus:outline-none data-[side=leading]:right-full data-[side=trailing]:left-full data-[tone=accent]:bg-accent data-[tone=accent]:text-on-accent data-[tone=danger]:bg-red data-[tone=danger]:text-page\";\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) => {\n      const row = rows.get(openId);\n      if (row && event.target instanceof Node && row.contains(event.target)) return;\n      setOpenId(null);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") setOpenId(null);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown, true);\n      document.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [openId, rows]);\n  const group = useMemo(() => ({ openId, setOpenId, rows }), [openId, rows]);\n  return (\n    <GroupContext.Provider value={group}>\n      <div className={cn(surfaceClass, className)}>\n        <ul\n          // oxlint-disable-next-line jsx-a11y/no-redundant-roles -- Safari drops list semantics from a ul with list-style: none; the explicit role keeps them\n          role=\"list\"\n          aria-label={label}\n          tabIndex={-1}\n          className=\"m-0 -mb-px list-none p-0 focus-visible:outline-none\"\n        >\n          <AnimatePresence initial={false}>{children}</AnimatePresence>\n        </ul>\n      </div>\n    </GroupContext.Provider>\n  );\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({\n  label,\n  leading = [],\n  trailing = [],\n  fullSwipe = true,\n  children,\n  className,\n}: 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 * 0.56);\n\n  function stretchTo(value: number) {\n    stretch.current?.stop();\n    if (reduced) {\n      cover.jump(value);\n      return;\n    }\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 = () => {\n      if (target === 0) {\n        stretch.current?.stop();\n        cover.jump(0);\n      }\n    };\n    if (reduced) {\n      x.jump(target);\n      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, {\n        ...(target === 0 ? motionTokens.spring.smooth : motionTokens.spring.snappy),\n        velocity: launch,\n        onComplete: home,\n      });\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) {\n      action.onSelect();\n      settle(0, velocity);\n      return;\n    }\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\n      travel.current = animate(x, end, {\n        ...motionTokens.spring.smooth,\n        velocity: Math.sign(velocity) === direction ? velocity : 0,\n      });\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(() => {\n      if (!leaving.current) return;\n      leaving.current = false;\n      if (row) delete row.dataset.removing;\n      settle(0);\n    }, 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) {\n      if (!side && armed.current) {\n        armed.current = false;\n        stretchTo(0);\n      }\n      return;\n    }\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(),\n      side = sideOf(value);\n    if (!side || actionsOf(side).length === 0) {\n      settle(0, velocity);\n      return;\n    }\n    const direction = side === \"leading\" ? 1 : -1;\n    const distance = Math.abs(value),\n      outward = velocity * direction,\n      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)) {\n      commit(side, outermost(side), velocity);\n      return;\n    }\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 = {\n      pointer: event.pointerId,\n      type: event.pointerType,\n      startX: event.clientX,\n      startY: event.clientY,\n      origin: x.get(),\n      locked: null,\n      samples: [[event.timeStamp, event.clientX]],\n    };\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,\n        dy = event.clientY - current.startY;\n      if (Math.hypot(dx, dy) < SLOP) return;\n      if (Math.abs(dy) > Math.abs(dx)) {\n        current.locked = \"y\";\n        return;\n      }\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()) > 0.5) {\n      swallowClick.current = true;\n      settle(0);\n    }\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]) => {\n      if (entry) width.current = entry.contentRect.width;\n    });\n    observer.observe(row);\n    return () => {\n      observer.disconnect();\n      rows.delete(id);\n      window.clearTimeout(restoreTimer.current);\n      setOpenId((current) => (current === id ? null : current));\n    };\n  }, [id, rows, setOpenId]);\n\n  // Another row opened, or someone touched outside: put this one away.\n  const closeForOthers = useEffectEvent(() => {\n    if (openId !== id && !leaving.current && drag.current?.locked !== \"x\" && x.get() !== 0) settle(0);\n  });\n  useEffect(() => {\n    closeForOthers();\n  }, [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(() => {\n    if (present) returnHome();\n  }, [present]);\n\n  const menuItems = [\n    ...leading.map((action, index) => ({ action, side: \"leading\" as const, index })),\n    ...trailing.map((action, index) => ({ action, side: \"trailing\" as const, index })),\n  ];\n  const collapse = reduced\n    ? { opacity: 0, transition: { duration: 0.15 } }\n    : {\n        height: 0,\n        opacity: 0,\n        transition: {\n          height: { ...motionTokens.spring.smooth, delay: 0.12 },\n          opacity: { duration: motionTokens.duration.instant, delay: 0.34 },\n        },\n      };\n\n  return (\n    <motion.li\n      ref={rowRef}\n      className={cn(\"group/row relative isolate overflow-hidden\", className)}\n      style={{ \"--swipe-action-width\": `${ACTION}px` } as CSSProperties}\n      initial={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={collapse}\n      transition={\n        reduced\n          ? { duration: 0.15 }\n          : {\n              height: motionTokens.spring.smooth,\n              opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n            }\n      }\n    >\n      {([\"leading\", \"trailing\"] as const).map((side) =>\n        actionsOf(side).map((action, index) => {\n          const count = actionsOf(side).length;\n          const rank = side === \"leading\" ? count - 1 - index : index;\n          const coverRank =\n            covering?.side === side ? (side === \"leading\" ? count - 1 - covering.index : covering.index) : null;\n          return (\n            <ActionLayer\n              key={`${side}-${index}`}\n              action={action}\n              side={side}\n              rank={rank}\n              count={count}\n              coverRank={coverRank}\n              x={x}\n              cover={cover}\n              onPress={() => commit(side, index)}\n            />\n          );\n        }),\n      )}\n      <motion.div\n        className={contentClass}\n        style={{ x }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerEnd}\n        onPointerCancel={onPointerEnd}\n        onClickCapture={onClickCapture}\n      >\n        <div className=\"min-w-0 flex-[1_1_auto]\">{children}</div>\n        {menuItems.length > 0 && (\n          <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\n              className={moreClass}\n              data-swipe-more=\"\"\n              aria-label={`More actions for ${label}`}\n              onPointerDown={(event) => event.preventDefault()}\n              onClick={(event) => {\n                if (event.detail > 0) setMenuOpen((open) => !open);\n              }}\n            >\n              <MoreHorizontal size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n            </Menu.Trigger>\n            <Menu.Portal>\n              <Menu.Content\n                className={menuClass}\n                align=\"end\"\n                sideOffset={6}\n                collisionPadding={12}\n                loop\n                onCloseAutoFocus={(event) => {\n                  if (!focusNeighbour.current) return;\n                  focusNeighbour.current = false;\n                  event.preventDefault();\n                  moveFocusToNeighbour();\n                }}\n              >\n                {menuItems.map(({ action, side, index }, order) => (\n                  <Menu.Item\n                    key={`${side}-${index}`}\n                    className={itemClass}\n                    data-tone={action.tone}\n                    style={{ \"--i\": order } as CSSProperties}\n                    onSelect={() => {\n                      focusNeighbour.current = !action.keepRow;\n                      commit(side, index);\n                    }}\n                  >\n                    <span\n                      className=\"inline-flex w-[17px] text-ink-2 group-data-[tone=danger]/item:text-red [&_svg]:size-4\"\n                      aria-hidden=\"true\"\n                    >\n                      {action.icon}\n                    </span>\n                    {action.label}\n                  </Menu.Item>\n                ))}\n              </Menu.Content>\n            </Menu.Portal>\n          </Menu.Root>\n        )}\n      </motion.div>\n    </motion.li>\n  );\n}\n\ntype LayerProps = {\n  action: SwipeAction;\n  side: Side;\n  rank: number;\n  count: number;\n  coverRank: number | null;\n  x: MotionValue<number>;\n  cover: MotionValue<number>;\n  onPress: () => void;\n};\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),\n      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(\n    [segment, cover],\n    ([share = 0, progress = 0]: number[]) =>\n      -direction * (coverRank === rank ? share / 2 + progress * (ACTION / 2 - share / 2) : share / 2),\n  );\n  const iconReveal = useTransform([segment, cover], ([share = 0, progress = 0]: number[]) => {\n    const own = clamp01((share - ACTION * 0.25) / (ACTION * 0.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 * 0.72) / (ACTION * 0.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) => 0.6 + 0.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 (\n    <motion.button\n      type=\"button\"\n      tabIndex={-1}\n      aria-hidden=\"true\"\n      className={layerClass}\n      data-side={side}\n      data-tone={action.tone ?? \"neutral\"}\n      style={{ x: shift, zIndex: rank + 1 }}\n      onClick={onPress}\n    >\n      <motion.span\n        className=\"absolute top-0 bottom-0 w-0 group-data-[side=leading]/layer:right-0 group-data-[side=trailing]/layer:left-0\"\n        style={{ x: glyphX }}\n      >\n        <span className=\"absolute top-0 bottom-0 left-[calc(var(--swipe-action-width)/-2)] flex w-(--swipe-action-width) flex-col items-center justify-center gap-[3px]\">\n          <motion.span\n            className=\"grid place-items-center [&_svg]:size-5\"\n            style={{ opacity: iconReveal, scale: iconScale }}\n          >\n            {action.icon}\n          </motion.span>\n          <motion.span\n            className=\"text-xs leading-body font-medium tracking-body whitespace-nowrap\"\n            style={{ opacity: labelReveal }}\n          >\n            {action.label}\n          </motion.span>\n        </span>\n      </motion.span>\n    </motion.button>\n  );\n}\n\nexport default SwipeActions;\n",
      "type": "registry:component",
      "target": "@components/iris/swipe-actions/swipe-actions.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/swipe-actions",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "SwipeActions",
    "tags": [
      "swipe",
      "gesture",
      "list"
    ],
    "docs": "https://irisui.dev/components/swipe-actions"
  }
}
