{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "share-button",
  "title": "Share Button",
  "description": "A share trigger that fans out into copy-link and custom share actions with staggered springs, tooltips and full keyboard support",
  "dependencies": [
    "framer-motion",
    "lucide-react"
  ],
  "files": [
    {
      "path": "app/registry/share-button/share-button.tsx",
      "content": "/**\n * Spectrum UI — ShareButton\n *\n * A share trigger that fans out into a row of action buttons. Opening springs\n * each action out from behind the trigger with a 30ms stagger while the share\n * icon morphs into an X; closing reverses the stagger. Ships an optional\n * copy-link action with emerald success feedback, tooltip labels on hover,\n * keyboard support (focus moves to the first action, Tab and arrow keys\n * cycle, Escape closes and restores focus) and honors prefers-reduced-motion\n * with a fade-only expansion.\n *\n * Dependencies: framer-motion, lucide-react, @/lib/utils\n *\n * @example\n * <ShareButton\n *   copyValue=\"https://ui.spectrumhq.in\"\n *   actions={[\n *     { icon: <Twitter size={15} />, label: \"Share on X\", onSelect: shareOnX },\n *   ]}\n * />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"framer-motion\"\nimport { Check, Link, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface ShareAction {\n  /** Icon rendered inside the action button, e.g. a lucide icon element */\n  icon: React.ReactNode\n  /** Tooltip text and accessible name of the action */\n  label: string\n  /** Fires when the action is clicked */\n  onSelect: () => void\n}\n\nexport interface ShareButtonProps {\n  /** Custom actions fanned out after the built-in copy action. Default [] */\n  actions?: ShareAction[]\n  /** URL or text for the built-in copy-link first action; omit to hide it */\n  copyValue?: string\n  /** Side the actions fan out toward. Default \"right\" */\n  direction?: \"left\" | \"right\"\n  /** Visual size of the trigger; actions are one step smaller. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Accessible name of the trigger. Default \"Share\" */\n  label?: string\n  /** Collapse the row shortly after a custom action is selected. Default true */\n  closeOnSelect?: boolean\n  /** Additional classes merged onto the root element */\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Snappy micro spring for icon morphs and tooltip pops */\nconst MICRO_SPRING: Transition = { type: \"spring\", stiffness: 500, damping: 30 }\n/** Softer spring for the actions traveling out of / back behind the trigger */\nconst MOVE_SPRING: Transition = { type: \"spring\", stiffness: 260, damping: 22 }\n/** Fade used for everything when prefers-reduced-motion is set */\nconst REDUCED_FADE: Transition = { duration: 0.15, ease: \"easeOut\" }\n/** Delay between neighboring actions while fanning out (seconds) */\nconst STAGGER_SECONDS = 0.03\n/** How long the copy action shows the emerald check (ms) */\nconst COPY_FEEDBACK_MS = 1500\n/** Beat between selecting an action and the row collapsing (ms) */\nconst CLOSE_ON_SELECT_MS = 150\n\nconst SIZES = {\n  sm: { trigger: \"h-8 w-8\", triggerIcon: 15, action: \"h-7 w-7\", actionIcon: 13, gap: \"gap-1.5\", inset: \"ml-1.5 mr-1.5\", step: 34 },\n  md: { trigger: \"h-10 w-10\", triggerIcon: 18, action: \"h-9 w-9\", actionIcon: 15, gap: \"gap-2\", inset: \"ml-2 mr-2\", step: 44 },\n  lg: { trigger: \"h-12 w-12\", triggerIcon: 21, action: \"h-10 w-10\", actionIcon: 17, gap: \"gap-2\", inset: \"ml-2 mr-2\", step: 48 },\n} as const\n\n/** Shared neutral round icon-button look for the trigger and every action */\nconst BUTTON_BASE_CLASSES = cn(\n  \"relative inline-flex touch-manipulation select-none items-center justify-center rounded-full border transition-colors\",\n  \"border-neutral-200 bg-white text-neutral-600 hover:bg-neutral-100\",\n  \"dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800\",\n  \"shadow-[0px_1px_2px_0px_rgba(0,0,0,0.04),0px_2px_4px_0px_rgba(0,0,0,0.04)] dark:shadow-none\",\n  \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n)\n\n/** Inline share-nodes glyph so the collapsed trigger has zero icon deps */\nfunction ShareIcon({ size }: { size: number }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <circle cx=\"18\" cy=\"5\" r=\"3\" />\n      <circle cx=\"6\" cy=\"12\" r=\"3\" />\n      <circle cx=\"18\" cy=\"19\" r=\"3\" />\n      <path d=\"m8.59 13.51 6.83 3.98\" />\n      <path d=\"m15.41 6.51-6.82 3.98\" />\n    </svg>\n  )\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function ShareButton({\n  actions = [],\n  copyValue,\n  direction = \"right\",\n  size = \"md\",\n  label = \"Share\",\n  closeOnSelect = true,\n  className,\n}: ShareButtonProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [open, setOpen] = useState(false)\n  const [copied, setCopied] = useState(false)\n  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)\n  const [focusedIndex, setFocusedIndex] = useState<number | null>(null)\n\n  const rootRef = useRef<HTMLSpanElement>(null)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const actionRefs = useRef<Array<HTMLButtonElement | null>>([])\n  const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n  const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  const sizes = SIZES[size]\n  // +1 slot for the built-in copy action when copyValue is provided\n  const itemCount = actions.length + (copyValue !== undefined ? 1 : 0)\n  // Fanning right means actions travel +x away from the trigger (and back -x)\n  const directionSign = direction === \"right\" ? 1 : -1\n\n  const closeMenu = useCallback((restoreFocus: boolean) => {\n    setOpen(false)\n    setHoveredIndex(null)\n    setFocusedIndex(null)\n    if (restoreFocus) triggerRef.current?.focus()\n  }, [])\n\n  const handleTriggerClick = useCallback(() => {\n    if (open) {\n      closeMenu(false)\n      return\n    }\n    // Fresh copy state on every open so reopening shows the link icon again\n    if (copyTimerRef.current) clearTimeout(copyTimerRef.current)\n    setCopied(false)\n    setOpen(true)\n  }, [open, closeMenu])\n\n  const handleCopy = useCallback(async () => {\n    if (copyValue === undefined) return\n    try {\n      await navigator.clipboard.writeText(copyValue)\n      setCopied(true)\n      if (copyTimerRef.current) clearTimeout(copyTimerRef.current)\n      copyTimerRef.current = setTimeout(() => setCopied(false), COPY_FEEDBACK_MS)\n    } catch {\n      // Clipboard unavailable (permissions, insecure context): fail silently\n    }\n  }, [copyValue])\n\n  const handleActionSelect = useCallback(\n    (action: ShareAction) => {\n      action.onSelect()\n      if (!closeOnSelect) return\n      if (closeTimerRef.current) clearTimeout(closeTimerRef.current)\n      closeTimerRef.current = setTimeout(\n        () => closeMenu(true),\n        CLOSE_ON_SELECT_MS,\n      )\n    },\n    [closeOnSelect, closeMenu],\n  )\n\n  // Escape closes from anywhere inside the component and returns focus\n  const handleRootKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      if (event.key !== \"Escape\" || !open) return\n      event.stopPropagation()\n      closeMenu(true)\n    },\n    [open, closeMenu],\n  )\n\n  // Tab / Shift+Tab cycle the actions; arrow keys follow the visual order\n  const handleMenuKeyDown = useCallback(\n    (event: React.KeyboardEvent) => {\n      const nodes = actionRefs.current.filter(\n        (node): node is HTMLButtonElement => node !== null,\n      )\n      if (nodes.length === 0) return\n      const currentIndex = nodes.indexOf(\n        document.activeElement as HTMLButtonElement,\n      )\n      let nextIndex: number | null = null\n      if (event.key === \"Tab\") {\n        nextIndex = currentIndex + (event.shiftKey ? -1 : 1)\n      } else if (event.key === \"ArrowRight\" || event.key === \"ArrowLeft\") {\n        const forward = (event.key === \"ArrowRight\") === (direction === \"right\")\n        nextIndex = currentIndex + (forward ? 1 : -1)\n      }\n      if (nextIndex === null) return\n      event.preventDefault()\n      nodes[((nextIndex % nodes.length) + nodes.length) % nodes.length].focus()\n    },\n    [direction],\n  )\n\n  // Move focus to the first action once the row has mounted\n  useEffect(() => {\n    if (!open) return\n    const frame = requestAnimationFrame(() => {\n      actionRefs.current.find((node) => node !== null)?.focus()\n    })\n    return () => cancelAnimationFrame(frame)\n  }, [open])\n\n  // Outside click closes; listener only exists while open, so nothing leaks\n  useEffect(() => {\n    if (!open) return\n    const handlePointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) closeMenu(false)\n    }\n    document.addEventListener(\"pointerdown\", handlePointerDown)\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown)\n  }, [open, closeMenu])\n\n  // Never leave feedback / collapse timers running after unmount\n  useEffect(() => {\n    return () => {\n      if (copyTimerRef.current) clearTimeout(copyTimerRef.current)\n      if (closeTimerRef.current) clearTimeout(closeTimerRef.current)\n    }\n  }, [])\n\n  const renderAction = (\n    index: number,\n    ariaLabel: string,\n    tooltip: string,\n    onClick: () => void,\n    children: React.ReactNode,\n  ) => {\n    const showTooltip = hoveredIndex === index || focusedIndex === index\n    // Each action starts stacked behind the trigger and springs to its slot;\n    // the exit runs the same path in reverse, farthest action leaving first\n    const stackedOffset = -directionSign * sizes.step * (index + 1)\n    const enterDelay = index * STAGGER_SECONDS\n    const exitDelay = (itemCount - 1 - index) * STAGGER_SECONDS\n    return (\n      <motion.button\n        key={index}\n        ref={(node: HTMLButtonElement | null) => {\n          actionRefs.current[index] = node\n        }}\n        type=\"button\"\n        role=\"menuitem\"\n        aria-label={ariaLabel}\n        onClick={onClick}\n        onMouseEnter={() => setHoveredIndex(index)}\n        onMouseLeave={() =>\n          setHoveredIndex((current) => (current === index ? null : current))\n        }\n        onFocus={() => setFocusedIndex(index)}\n        onBlur={() =>\n          setFocusedIndex((current) => (current === index ? null : current))\n        }\n        style={{\n          transformOrigin: direction === \"right\" ? \"left center\" : \"right center\",\n        }}\n        initial={\n          shouldReduceMotion\n            ? { opacity: 0 }\n            : { x: stackedOffset, scale: 0.5, opacity: 0 }\n        }\n        animate={\n          shouldReduceMotion ? { opacity: 1 } : { x: 0, scale: 1, opacity: 1 }\n        }\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: REDUCED_FADE }\n            : {\n                x: stackedOffset,\n                scale: 0.5,\n                opacity: 0,\n                transition: { ...MOVE_SPRING, delay: exitDelay },\n              }\n        }\n        transition={\n          shouldReduceMotion\n            ? REDUCED_FADE\n            : { ...MOVE_SPRING, delay: enterDelay }\n        }\n        whileTap={shouldReduceMotion ? undefined : { scale: 0.92 }}\n        className={cn(BUTTON_BASE_CLASSES, sizes.action)}\n      >\n        {children}\n        <AnimatePresence>\n          {showTooltip && (\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute bottom-full left-1/2 mb-2 whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 text-xs font-medium text-white dark:bg-neutral-100 dark:text-neutral-900\"\n              initial={\n                shouldReduceMotion\n                  ? { opacity: 0, x: \"-50%\" }\n                  : { opacity: 0, y: 4, scale: 0.9, x: \"-50%\" }\n              }\n              animate={\n                shouldReduceMotion\n                  ? { opacity: 1, x: \"-50%\" }\n                  : { opacity: 1, y: 0, scale: 1, x: \"-50%\" }\n              }\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, x: \"-50%\" }\n                  : { opacity: 0, y: 4, scale: 0.9, x: \"-50%\" }\n              }\n              transition={shouldReduceMotion ? REDUCED_FADE : MICRO_SPRING}\n            >\n              {tooltip}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </motion.button>\n    )\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      onKeyDown={handleRootKeyDown}\n      className={cn(\"relative inline-flex items-center\", className)}\n    >\n      <motion.button\n        ref={triggerRef}\n        type=\"button\"\n        onClick={handleTriggerClick}\n        aria-expanded={open}\n        aria-haspopup=\"menu\"\n        aria-label={label}\n        whileTap={shouldReduceMotion ? undefined : { scale: 0.94 }}\n        className={cn(\"z-20\", BUTTON_BASE_CLASSES, sizes.trigger)}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"relative inline-flex\"\n          style={{ width: sizes.triggerIcon, height: sizes.triggerIcon }}\n        >\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={open ? \"close\" : \"share\"}\n              className=\"absolute inset-0 flex items-center justify-center\"\n              initial={\n                shouldReduceMotion ? { opacity: 0 } : { opacity: 0, rotate: -90 }\n              }\n              animate={\n                shouldReduceMotion ? { opacity: 1 } : { opacity: 1, rotate: 0 }\n              }\n              exit={\n                shouldReduceMotion ? { opacity: 0 } : { opacity: 0, rotate: 90 }\n              }\n              transition={shouldReduceMotion ? REDUCED_FADE : MICRO_SPRING}\n            >\n              {open ? (\n                <X size={sizes.triggerIcon} />\n              ) : (\n                <ShareIcon size={sizes.triggerIcon} />\n              )}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.button>\n\n      <AnimatePresence initial={false}>\n        {open && itemCount > 0 && (\n          <motion.div\n            key=\"actions\"\n            role=\"menu\"\n            aria-label={label}\n            onKeyDown={handleMenuKeyDown}\n            className={cn(\n              \"absolute inset-y-0 z-10 flex items-center\",\n              direction === \"right\"\n                ? \"left-full flex-row\"\n                : \"right-full flex-row-reverse\",\n              sizes.gap,\n              sizes.inset,\n            )}\n          >\n            {copyValue !== undefined &&\n              renderAction(\n                0,\n                \"Copy link\",\n                copied ? \"Copied!\" : \"Copy link\",\n                handleCopy,\n                <span\n                  className=\"relative inline-flex\"\n                  style={{ width: sizes.actionIcon, height: sizes.actionIcon }}\n                >\n                  <AnimatePresence initial={false}>\n                    <motion.span\n                      key={copied ? \"check\" : \"link\"}\n                      className={cn(\n                        \"absolute inset-0 flex items-center justify-center\",\n                        copied && \"text-emerald-500\",\n                      )}\n                      initial={\n                        shouldReduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.5 }\n                      }\n                      animate={\n                        shouldReduceMotion\n                          ? { opacity: 1 }\n                          : { opacity: 1, scale: 1 }\n                      }\n                      exit={\n                        shouldReduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.5 }\n                      }\n                      transition={\n                        shouldReduceMotion ? REDUCED_FADE : MICRO_SPRING\n                      }\n                    >\n                      {copied ? (\n                        <Check size={sizes.actionIcon} />\n                      ) : (\n                        <Link size={sizes.actionIcon} />\n                      )}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>,\n              )}\n            {actions.map((action, actionIndex) => {\n              const index =\n                actionIndex + (copyValue !== undefined ? 1 : 0)\n              return renderAction(\n                index,\n                action.label,\n                action.label,\n                () => handleActionSelect(action),\n                action.icon,\n              )\n            })}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/share-button.tsx"
    }
  ],
  "type": "registry:component"
}
