{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"swipe-actions","type":"registry:component","title":"Swipe actions","description":"A row that makes room for its next step. Swipe to reveal, then choose what happens.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/status-button.json"],"files":[{"path":"registry/swipe-actions.tsx","type":"registry:component","content":"// Hairline UI · swipe-actions · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { StatusButton, type ActionStatus } from './status-button';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useSwipeReveal } from './use-swipe-reveal';\n\nexport type SwipeAction = { id: string; label: string; icon?: React.ReactNode; pendingLabel?: string; successLabel?: string; onAction: () => void | Promise<void>; disabled?: boolean };\nexport interface SwipeActionsProps extends React.HTMLAttributes<HTMLDivElement> { actions: SwipeAction[]; label: string; disabled?: boolean }\n\n/** A foreground row parts to reveal explicit actions. Crossing a swipe threshold never performs an action. */\nexport function SwipeActions({ actions, label, disabled = false, children, className = '', ...props }: SwipeActionsProps) {\n  const reduced = useInteractionReducedMotion();\n  const [result, setResult] = React.useState<{ id: string; status: ActionStatus } | null>(null);\n  const busy = React.useRef(false), alive = React.useRef(false);\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const swipe = useSwipeReveal(reduced, disabled || result?.status === 'pending');\n  const id = React.useId();\n  React.useEffect(() => { alive.current = true; return () => { alive.current = false; if (timer.current) clearTimeout(timer.current); }; }, []);\n  const execute = async (action: SwipeAction) => {\n    if (busy.current || result?.status === 'success' || disabled || action.disabled) return;\n    busy.current = true; if (timer.current) clearTimeout(timer.current); setResult({ id: action.id, status: 'pending' });\n    try {\n      await action.onAction();\n      if (alive.current) {\n        setResult({ id: action.id, status: 'success' });\n        timer.current = setTimeout(() => {\n          const ownsFocus = swipe.content.current?.parentElement?.contains(document.activeElement) ?? false;\n          setResult(null); swipe.settle(false, ownsFocus);\n        }, 1300);\n      }\n    } catch { if (alive.current) setResult({ id: action.id, status: 'error' }); }\n    finally { busy.current = false; }\n  };\n  return <div {...props} {...swipe.handlers} role=\"group\" aria-label={label} className={`relative isolate overflow-hidden rounded-[18px] border border-border bg-muted/35 text-foreground ${className}`} style={{ touchAction: 'pan-y', ...props.style }}>\n    <div ref={swipe.actions} id={id} inert={!swipe.open} aria-hidden={!swipe.open} className=\"absolute inset-y-0 right-0 flex max-w-[80%] items-stretch\">\n      {actions.map((action) => <div key={action.id} className=\"flex w-[84px] min-w-0 items-center justify-center border-l border-border px-1\">\n        <StatusButton status={result?.id === action.id ? result.status : 'idle'} labels={{ idle: action.label, pending: action.pendingLabel ?? 'Working', success: action.successLabel ?? 'Done', error: 'Retry' }}\n          icon={action.icon} variant=\"ghost\" size=\"sm\" disabled={disabled || action.disabled} aria-disabled={result?.status === 'pending' || result?.status === 'success' || disabled || action.disabled || undefined} onClick={() => void execute(action)} className=\"!h-auto flex-col gap-1.5 !px-1 !py-2 !text-[11px]\" />\n      </div>)}\n    </div>\n    <div ref={swipe.content} className=\"relative flex min-h-[88px] items-center gap-3 rounded-[17px] bg-background px-4 py-4 will-change-transform\">\n      <div className=\"min-w-0 flex-1\">{children}</div>\n      <button ref={swipe.trigger} type=\"button\" aria-label={`${swipe.open ? 'Hide' : 'Show'} actions for ${label}`} aria-expanded={swipe.open} aria-controls={id} disabled={disabled || result?.status === 'pending'}\n        onClick={() => swipe.settle(!swipe.open)} className={`flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-default disabled:opacity-40 ${INTERACTION_FOCUS}`}>\n        <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" className=\"size-4\"><path d=\"M4 10h.01M10 10h.01M16 10h.01\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" /></svg>\n      </button>\n    </div>\n    <span role=\"status\" className=\"sr-only\">{result ? `${actions.find((action) => action.id === result.id)?.label}: ${result.status === 'error' ? 'Failed. Try again.' : result.status}` : ''}</span>\n  </div>;\n}\n"},{"path":"registry/use-swipe-reveal.ts","type":"registry:component","content":"// Hairline UI · swipe-actions · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { springFrames } from './ui-interaction-motion';\n\ntype Gesture = { id: number; x: number; y: number; origin: number; last: number; started: boolean; width: number };\n\n/** Measure once at pointer-down; every move writes only the foreground transform. */\nexport function useSwipeReveal(reduced: boolean, disabled: boolean) {\n  const content = React.useRef<HTMLDivElement>(null);\n  const actions = React.useRef<HTMLDivElement>(null);\n  const trigger = React.useRef<HTMLButtonElement>(null);\n  const position = React.useRef(0);\n  const gesture = React.useRef<Gesture | null>(null);\n  const animation = React.useRef<Animation | null>(null);\n  const suppressed = React.useRef(false);\n  const [open, setOpen] = React.useState(false);\n  const paint = (x: number) => { position.current = x; if (content.current) content.current.style.transform = `translate3d(${x}px,0,0)`; };\n  const settle = (show: boolean, restoreFocus = false) => {\n    const from = animation.current?.playState === 'running' && content.current ? new DOMMatrixReadOnly(getComputedStyle(content.current).transform).m41 : position.current;\n    const target = show ? -(actions.current?.offsetWidth ?? 0) : 0;\n    animation.current?.cancel(); paint(target); setOpen(show);\n    if (!reduced && content.current && Math.abs(from - target) > 0.5) {\n      animation.current = content.current.animate(springFrames(from, target, 0, 420).map(({ value, offset }) => ({ transform: `translate3d(${value}px,0,0)`, offset })), { duration: 420, easing: 'linear' });\n    }\n    if (restoreFocus) trigger.current?.focus({ preventScroll: true });\n  };\n  const finish = (cancelled: boolean) => {\n    const g = gesture.current;\n    if (!g) return;\n    gesture.current = null;\n    if (!g.started) return;\n    suppressed.current = true;\n    settle(cancelled ? g.origin < 0 : -g.last > g.width * 0.45);\n  };\n  const finishRef = React.useRef(finish); finishRef.current = finish;\n  React.useEffect(() => {\n    const cancel = () => finishRef.current(true);\n    const hidden = () => { if (document.hidden) cancel(); };\n    window.addEventListener('blur', cancel); document.addEventListener('visibilitychange', hidden);\n    const observer = new ResizeObserver(() => { if (position.current < 0 && !gesture.current) paint(-(actions.current?.offsetWidth ?? 0)); });\n    if (actions.current) observer.observe(actions.current);\n    return () => { animation.current?.cancel(); observer.disconnect(); window.removeEventListener('blur', cancel); document.removeEventListener('visibilitychange', hidden); };\n  }, []);\n  React.useEffect(() => { if (disabled) finishRef.current(true); }, [disabled]);\n  return {\n    content, actions, trigger, open, settle,\n    handlers: {\n      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n        suppressed.current = false;\n        if (disabled || event.button !== 0 || !event.isPrimary || (event.target as Element).closest('button, a, input, textarea, select, [role=\"slider\"]')) return;\n        const current = animation.current?.playState === 'running' && content.current ? new DOMMatrixReadOnly(getComputedStyle(content.current).transform).m41 : position.current;\n        animation.current?.cancel(); paint(current);\n        gesture.current = { id: event.pointerId, x: event.clientX, y: event.clientY, origin: position.current, last: position.current, started: false, width: actions.current?.offsetWidth ?? 0 };\n      },\n      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n        const g = gesture.current;\n        if (!g || event.pointerId !== g.id) return;\n        const dx = event.clientX - g.x, dy = event.clientY - g.y;\n        if (!g.started) {\n          if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;\n          if (Math.abs(dy) > Math.abs(dx)) { gesture.current = null; return; }\n          g.started = true; event.currentTarget.setPointerCapture(event.pointerId);\n        }\n        const raw = g.origin + dx;\n        g.last = raw > 0 ? Math.min(18, raw * 0.15) : raw < -g.width ? -g.width - Math.min(18, (-raw - g.width) * 0.15) : raw;\n        paint(g.last);\n      },\n      onPointerUp(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(false); },\n      onPointerCancel(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(true); },\n      onLostPointerCapture(event: React.PointerEvent<HTMLDivElement>) { if (event.pointerId === gesture.current?.id) finish(true); },\n      onClickCapture(event: React.MouseEvent) { if (suppressed.current) { event.preventDefault(); event.stopPropagation(); suppressed.current = false; } },\n      onKeyDown(event: React.KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault(); settle(false, true); } },\n      onBlur(event: React.FocusEvent) { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { gesture.current = null; settle(false); } },\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · swipe-actions · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/swipe-actions. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}