{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"slide-action-button","type":"registry:component","title":"Animated CTA Buttons Slide Action Button","description":"Drag the thumb to the end to confirm an action; release early to spring back.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/slide-action-button.tsx","type":"registry:component","target":"@components/motion/slide-action-button.tsx","content":"\"use client\";\n// beui.dev/components/motion/expanding-arrow-button\n\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CLASS, TOUCH_GESTURE_CONTENT_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SlideActionButtonProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\"\n> {\n  children: ReactNode;\n  completeLabel?: ReactNode;\n  threshold?: number;\n  resetDelay?: number;\n  onComplete?: () => void;\n  thumbClassName?: string;\n  fillClassName?: string;\n}\n\nexport function SlideActionButton({\n  children,\n  completeLabel = \"Complete\",\n  threshold = 0.82,\n  resetDelay = 1200,\n  onComplete,\n  thumbClassName,\n  fillClassName,\n  className,\n  ...rest\n}: SlideActionButtonProps) {\n  const reduce = useReducedMotion();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const thumbRef = useRef<HTMLButtonElement>(null);\n  const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const completedRef = useRef(false);\n  const x = useMotionValue(0);\n  const [maxDistance, setMaxDistance] = useState(0);\n  const [completed, setCompleted] = useState(false);\n  const safeDistance = Math.max(maxDistance, 1);\n  const dragProgress = useTransform(x, [0, safeDistance], [0, 1]);\n  const fillProgress = useTransform(x, [0, safeDistance], [0, 1]);\n  const labelOpacity = useTransform(\n    x,\n    [0, safeDistance * 0.35, safeDistance * 0.65],\n    [1, 0.75, 0],\n  );\n  const iconPath = useTransform(\n    dragProgress,\n    [0, 0.5, 1],\n    [\n      \"M 8 5 L 15 12 L 8 19\",\n      \"M 7 8 L 12 14 L 17 10\",\n      \"M 5 12 L 10 17 L 19 7\",\n    ],\n  );\n\n  useLayoutEffect(() => {\n    const track = trackRef.current;\n    const thumb = thumbRef.current;\n    if (!track || !thumb) return;\n\n    const measure = () => {\n      setMaxDistance(Math.max(track.clientWidth - thumb.clientWidth - 8, 0));\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(track);\n    observer.observe(thumb);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (resetTimerRef.current) clearTimeout(resetTimerRef.current);\n    },\n    [],\n  );\n\n  const moveTo = (target: number) => {\n    if (reduce) {\n      x.set(target);\n      return;\n    }\n    animate(x, target, SPRING_LAYOUT);\n  };\n\n  const reset = () => {\n    completedRef.current = false;\n    setCompleted(false);\n    moveTo(0);\n  };\n\n  const complete = () => {\n    if (completedRef.current || maxDistance === 0) return;\n    completedRef.current = true;\n    setCompleted(true);\n    moveTo(maxDistance);\n    onComplete?.();\n\n    if (resetTimerRef.current) clearTimeout(resetTimerRef.current);\n    resetTimerRef.current = setTimeout(reset, resetDelay);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key !== \"Enter\" && event.key !== \" \") return;\n    event.preventDefault();\n    complete();\n  };\n\n  return (\n    <div\n      ref={trackRef}\n      className={cn(\n        \"relative h-16 w-72 overflow-hidden rounded-[22px] bg-muted p-1\",\n        \"ring-1 ring-primary/10\",\n        // The track only carries the label — the slide starts on the thumb,\n        // which suppresses selection for the whole gesture on its own.\n        TOUCH_GESTURE_CONTENT_CLASS,\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        style={{ scaleX: fillProgress }}\n        className={cn(\n          \"absolute inset-0 origin-left bg-primary will-change-transform\",\n          fillClassName,\n        )}\n      />\n\n      <motion.span\n        aria-hidden=\"true\"\n        style={{ opacity: labelOpacity }}\n        className=\"pointer-events-none absolute inset-0 grid place-items-center pl-10 text-sm font-medium text-foreground\"\n      >\n        {children}\n      </motion.span>\n\n      <motion.span\n        aria-live=\"polite\"\n        animate={{ opacity: completed ? 1 : 0 }}\n        transition={{ duration: reduce ? 0 : 0.15, ease: EASE_OUT }}\n        className=\"pointer-events-none absolute inset-0 grid place-items-center text-sm font-medium text-primary-foreground\"\n      >\n        {completed ? completeLabel : null}\n      </motion.span>\n\n      <motion.button\n        ref={thumbRef}\n        type=\"button\"\n        aria-label={typeof children === \"string\" ? children : \"Slide action\"}\n        drag={completed ? false : \"x\"}\n        dragConstraints={{ left: 0, right: maxDistance }}\n        dragElastic={0}\n        dragMomentum={false}\n        style={{ x }}\n        onDragEnd={() => {\n          if (x.get() >= maxDistance * threshold) complete();\n          else moveTo(0);\n        }}\n        onKeyDown={handleKeyDown}\n        whileTap={reduce || completed ? undefined : { scale: 0.94 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          \"relative z-10 grid size-14 touch-none cursor-grab place-items-center rounded-[18px] bg-primary text-primary-foreground shadow-sm\",\n          TOUCH_GESTURE_CLASS,\n          \"outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          completed && \"cursor-default bg-background text-foreground\",\n          thumbClassName,\n        )}\n      >\n        <motion.svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" className=\"size-5\">\n          <motion.path\n            d={iconPath}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"2\"\n          />\n        </motion.svg>\n      </motion.button>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}