{"slug":"expanding-arrow-button","name":"Animated CTA Buttons","description":"Expressive call-to-action buttons with expanding, hold, and slide interactions.","category":"motion","source_url":"https://beui.dev/r/expanding-arrow-button/raw","detail_url":"https://beui.dev/r/expanding-arrow-button","raw_url":"https://beui.dev/r/expanding-arrow-button/raw","page_url":"https://beui.dev/components/motion/expanding-arrow-button","markdown_url":"https://beui.dev/components/motion/expanding-arrow-button.md","published_at":"2026-07-16","updated_at":"2026-07-16","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/expanding-arrow-button","@/lib/ease","@/lib/hooks/use-hover-capable","@/lib/utils"],"files":[{"path":"components/motion/expanding-arrow-button.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/expanding-arrow-button\n\nimport {\n  motion,\n  useReducedMotion,\n  type HTMLMotionProps,\n} from \"motion/react\";\nimport {\n  forwardRef,\n  useState,\n  type FocusEvent,\n  type MouseEvent,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ExpandingArrowButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  \"children\"\n> {\n  children: ReactNode;\n  accentClassName?: string;\n  labelClassName?: string;\n}\n\nconst ARROW_OPACITY = [1, 0.78, 0.54, 0.32, 0.16] as const;\n\nfunction DottedChevron({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 20 28\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <circle cx=\"4\" cy=\"4\" r=\"2\" fill=\"currentColor\" />\n      <circle cx=\"10\" cy=\"9\" r=\"2\" fill=\"currentColor\" />\n      <circle cx=\"16\" cy=\"14\" r=\"2\" fill=\"currentColor\" />\n      <circle cx=\"10\" cy=\"19\" r=\"2\" fill=\"currentColor\" />\n      <circle cx=\"4\" cy=\"24\" r=\"2\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nexport const ExpandingArrowButton = forwardRef<\n  HTMLButtonElement,\n  ExpandingArrowButtonProps\n>(function ExpandingArrowButton(\n  {\n    children,\n    className,\n    accentClassName,\n    labelClassName,\n    disabled,\n    onMouseEnter,\n    onMouseLeave,\n    onFocus,\n    onBlur,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const canHover = useHoverCapable();\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const active = !disabled && ((canHover && hovered) || focused);\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  const handleMouseEnter = (event: MouseEvent<HTMLButtonElement>) => {\n    setHovered(true);\n    onMouseEnter?.(event);\n  };\n\n  const handleMouseLeave = (event: MouseEvent<HTMLButtonElement>) => {\n    setHovered(false);\n    onMouseLeave?.(event);\n  };\n\n  const handleFocus = (event: FocusEvent<HTMLButtonElement>) => {\n    setFocused(true);\n    onFocus?.(event);\n  };\n\n  const handleBlur = (event: FocusEvent<HTMLButtonElement>) => {\n    setFocused(false);\n    onBlur?.(event);\n  };\n\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      onMouseEnter={handleMouseEnter}\n      onMouseLeave={handleMouseLeave}\n      onFocus={handleFocus}\n      onBlur={handleBlur}\n      whileTap={reduce || disabled ? undefined : { scale: 0.97 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"relative inline-flex h-16 min-w-72 items-center overflow-hidden rounded-[22px] bg-neutral-950 p-1.5 text-white select-none\",\n        \"outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        layout=\"size\"\n        aria-hidden=\"true\"\n        transition={layoutTransition}\n        style={{\n          width: active ? \"calc(100% - 12px)\" : 52,\n          borderRadius: 16,\n        }}\n        className={cn(\n          \"absolute inset-y-1.5 left-1.5 z-10 overflow-hidden bg-lime-300 text-neutral-950\",\n          accentClassName,\n        )}\n      >\n        <motion.span\n          animate={{ opacity: active ? 0 : 1 }}\n          transition={{ duration: reduce ? 0 : 0.1, ease: EASE_OUT }}\n          className=\"absolute inset-0 grid place-items-center\"\n        >\n          <DottedChevron className=\"h-7 w-5\" />\n        </motion.span>\n\n        <span className=\"absolute inset-0 flex items-center justify-around px-3\">\n          {ARROW_OPACITY.map((opacity, index) => (\n            <motion.span\n              key={opacity}\n              animate={{\n                opacity: active ? 1 : 0,\n                transform:\n                  active && !reduce ? \"translateX(0px)\" : \"translateX(-6px)\",\n              }}\n              transition={{\n                duration: reduce ? 0 : 0.18,\n                delay: active && !reduce ? 0.04 + index * 0.025 : 0,\n                ease: EASE_OUT,\n              }}\n              style={{ color: `rgb(10 10 10 / ${opacity})` }}\n              className=\"inline-grid place-items-center\"\n            >\n              <DottedChevron className=\"h-7 w-5\" />\n            </motion.span>\n          ))}\n        </span>\n      </motion.span>\n\n      <motion.span\n        animate={{\n          opacity: active ? 0 : 1,\n          transform:\n            active && !reduce ? \"translateX(6px)\" : \"translateX(0px)\",\n        }}\n        transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}\n        className={cn(\n          \"relative z-0 ml-[76px] mr-5 whitespace-nowrap text-lg font-medium tracking-[-0.02em]\",\n          labelClassName,\n        )}\n      >\n        {children}\n      </motion.span>\n    </motion.button>\n  );\n});\n"},{"path":"components/motion/hold-action-button.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/expanding-arrow-button\n\nimport { motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\nimport {\n  forwardRef,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface HoldActionButtonProps extends Omit<\n  HTMLMotionProps<\"button\">,\n  | \"children\"\n  | \"type\"\n  | \"onClick\"\n  | \"onPointerDown\"\n  | \"onPointerUp\"\n  | \"onPointerCancel\"\n  | \"onPointerLeave\"\n  | \"onKeyDown\"\n  | \"onKeyUp\"\n> {\n  children: ReactNode;\n  type?: \"vertical\" | \"horizontal\";\n  holdingLabel?: ReactNode;\n  completeLabel?: ReactNode;\n  holdDuration?: number;\n  onHoldComplete?: () => void;\n  fillClassName?: string;\n  labelClassName?: string;\n}\n\nexport const HoldActionButton = forwardRef<\n  HTMLButtonElement,\n  HoldActionButtonProps\n>(function HoldActionButton(\n  {\n    children,\n    type = \"vertical\",\n    holdingLabel = \"Keep holding\",\n    completeLabel = \"Done\",\n    holdDuration = 1600,\n    onHoldComplete,\n    fillClassName,\n    labelClassName,\n    className,\n    disabled,\n    ...rest\n  },\n  ref,\n) {\n  const reduce = useReducedMotion();\n  const completedRef = useRef(false);\n  const [holding, setHolding] = useState(false);\n  const [completed, setCompleted] = useState(false);\n\n  const startHold = () => {\n    if (disabled || holding) return;\n    completedRef.current = false;\n    setCompleted(false);\n    setHolding(true);\n  };\n\n  const cancelHold = () => {\n    setHolding(false);\n    setCompleted(false);\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {\n    if (event.button !== 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    startHold();\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLButtonElement>) => {\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    cancelHold();\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if ((event.key === \" \" || event.key === \"Enter\") && !event.repeat) {\n      event.preventDefault();\n      startHold();\n    }\n  };\n\n  const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \" \" || event.key === \"Enter\") {\n      event.preventDefault();\n      cancelHold();\n    }\n  };\n\n  const handleFillComplete = () => {\n    if (!holding || completedRef.current) return;\n    completedRef.current = true;\n    setCompleted(true);\n    onHoldComplete?.();\n  };\n\n  const active = holding || completed;\n  const activeTransform =\n    type === \"horizontal\" ? \"translateX(0%)\" : \"translateY(0%)\";\n  const idleTransform =\n    type === \"horizontal\" ? \"translateX(-100%)\" : \"translateY(115%)\";\n\n  return (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      disabled={disabled}\n      aria-label={typeof children === \"string\" ? children : undefined}\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={cancelHold}\n      onPointerLeave={cancelHold}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      onContextMenu={(event) => event.preventDefault()}\n      whileTap={reduce || disabled ? undefined : { scale: 0.98 }}\n      transition={SPRING_PRESS}\n      className={cn(\n        \"relative inline-grid h-16 min-w-72 touch-none select-none place-items-center overflow-hidden rounded-[22px] bg-primary px-8 text-primary-foreground\",\n        \"outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.span\n        aria-hidden=\"true\"\n        initial={false}\n        animate={\n          reduce\n            ? { opacity: active ? 1 : 0, transform: \"none\" }\n            : {\n                opacity: 1,\n                transform: active ? activeTransform : idleTransform,\n              }\n        }\n        transition={\n          active\n            ? { duration: holdDuration / 1000, ease: \"linear\" }\n            : { duration: reduce ? 0.15 : 0.24, ease: EASE_OUT }\n        }\n        onAnimationComplete={handleFillComplete}\n        className={cn(\n          \"absolute inset-0 bg-sky-400 will-change-[opacity,transform]\",\n          fillClassName,\n        )}\n      >\n        {!reduce ? (\n          type === \"horizontal\" ? (\n            <motion.svg\n              viewBox=\"0 0 24 240\"\n              preserveAspectRatio=\"none\"\n              aria-hidden=\"true\"\n              animate={{\n                transform: active ? \"translateY(-50%)\" : \"translateY(0%)\",\n              }}\n              transition={{\n                duration: 1.1,\n                ease: \"linear\",\n                repeat: active ? Number.POSITIVE_INFINITY : 0,\n              }}\n              className=\"absolute -right-5 top-0 h-[200%] w-6 text-sky-400\"\n            >\n              <path\n                d=\"M0 0h12C2 20 2 40 12 60s10 40 0 60-10 40 0 60 10 40 0 60H0Z\"\n                fill=\"currentColor\"\n              />\n            </motion.svg>\n          ) : (\n            <motion.svg\n              viewBox=\"0 0 240 24\"\n              preserveAspectRatio=\"none\"\n              aria-hidden=\"true\"\n              animate={{\n                transform: active ? \"translateX(-50%)\" : \"translateX(0%)\",\n              }}\n              transition={{\n                duration: 1.1,\n                ease: \"linear\",\n                repeat: active ? Number.POSITIVE_INFINITY : 0,\n              }}\n              className=\"absolute -top-5 left-0 h-6 w-[200%] text-sky-400\"\n            >\n              <path\n                d=\"M0 12C20 2 40 2 60 12s40 10 60 0 40-10 60 0 40 10 60 0v12H0Z\"\n                fill=\"currentColor\"\n              />\n            </motion.svg>\n          )\n        ) : null}\n      </motion.span>\n\n      <span\n        className={cn(\n          \"relative z-10 grid place-items-center text-base font-medium tracking-[-0.01em]\",\n          labelClassName,\n        )}\n      >\n        <motion.span\n          animate={{ opacity: active ? 0 : 1 }}\n          transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}\n          className=\"col-start-1 row-start-1\"\n        >\n          {children}\n        </motion.span>\n        <motion.span\n          aria-hidden={!holding || completed}\n          animate={{ opacity: holding && !completed ? 1 : 0 }}\n          transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}\n          className=\"col-start-1 row-start-1\"\n        >\n          {holdingLabel}\n        </motion.span>\n        <motion.span\n          aria-hidden={!completed}\n          animate={{ opacity: completed ? 1 : 0 }}\n          transition={{ duration: reduce ? 0 : 0.12, ease: EASE_OUT }}\n          className=\"col-start-1 row-start-1\"\n        >\n          {completeLabel}\n        </motion.span>\n      </span>\n    </motion.button>\n  );\n});\n"},{"path":"components/motion/slide-action-button.tsx","type":"component","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 { 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-primary/10 p-1\",\n        \"ring-1 ring-primary/10\",\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          \"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":"util","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"},{"path":"lib/hooks/use-hover-capable.ts","type":"util","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"util","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"},{"path":"components/previews/motion/expanding-arrow-button.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { ExpandingArrowButton } from \"@/components/motion/expanding-arrow-button\";\n\nexport function ExpandingArrowButtonPreview() {\n  return (\n    <div className=\"flex items-center justify-center\">\n      <ExpandingArrowButton>Book a demo</ExpandingArrowButton>\n    </div>\n  );\n}\n"}]}