{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"share-button","type":"registry:component","title":"Share button","description":"Opens in place into copy, X and email, and the link turns into a check when it is copied.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion","simple-icons"],"registryDependencies":["utils"],"files":[{"path":"components/share-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { siX } from \"simple-icons\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The pill reshapes around its contents with no bounce, so its clipped\n// edge never swings past the icons it's revealing or tucking away.\nconst RESHAPE = { type: \"spring\", duration: 0.4, bounce: 0 } as const;\n\nconst noop = () => () => {};\n// The Web Share API opens the phone's own sheet; desktops mostly lack it.\nconst canNativeShare = () => typeof navigator.share === \"function\";\n\n// One icon family: a 16px grid, 1.4 strokes, round caps. The X mark is the\n// brand's own shape, filled, sized so its weight sits with the strokes.\nconst stroke = {\n  viewBox: \"0 0 16 16\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.4,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n  \"aria-hidden\": true,\n  className: \"size-4\",\n} as const;\n\nconst Icon = {\n  share: (\n    <svg {...stroke}>\n      <path d=\"M8 9.5V2.5M5.25 5.25 8 2.5l2.75 2.75M3.5 8v4.5a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V8\" />\n    </svg>\n  ),\n  link: (\n    <svg {...stroke}>\n      <path d=\"M6.25 9.75l3.5-3.5M7 4.25l1.05-1.05a3.25 3.25 0 0 1 4.6 4.6L11.6 8.85M9 11.75l-1.05 1.05a3.25 3.25 0 0 1-4.6-4.6L4.4 7.15\" />\n    </svg>\n  ),\n  check: (\n    <svg {...stroke}>\n      <path d=\"M3.5 8.25l3 3 6-6.5\" />\n    </svg>\n  ),\n  mail: (\n    <svg {...stroke}>\n      <rect x=\"2.25\" y=\"3.75\" width=\"11.5\" height=\"8.5\" rx=\"1.75\" />\n      <path d=\"M2.75 5.25 8 9l5.25-3.75\" />\n    </svg>\n  ),\n  more: (\n    <svg {...stroke}>\n      <path d=\"M3.75 8h.01M8 8h.01M12.25 8h.01\" strokeWidth={2.25} />\n    </svg>\n  ),\n  close: (\n    <svg {...stroke}>\n      <path d=\"M4.5 4.5l7 7M11.5 4.5l-7 7\" />\n    </svg>\n  ),\n  x: (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden className=\"size-3.5\">\n      <path d={siX.path} />\n    </svg>\n  ),\n};\n\nexport function ShareButton({\n  url,\n  title,\n  demo,\n  className,\n}: {\n  url: string;\n  title: string;\n  // Drives the pill from outside (the card preview) with no side effects.\n  demo?: { open: boolean; copied: boolean } | null;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const [copied, setCopied] = useState(false);\n  const native = useSyncExternalStore(noop, canNativeShare, () => false);\n  const root = useRef<HTMLDivElement>(null);\n  const trigger = useRef<HTMLButtonElement>(null);\n  const first = useRef<HTMLButtonElement>(null);\n  const usingKeys = useRef(false);\n  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  const isOpen = demo ? demo.open : open;\n  const isCopied = demo ? demo.copied : copied;\n\n  // Focus follows the pill only for keyboard users, so a click never\n  // leaves a ring behind.\n  const toggle = (next: boolean) => {\n    setOpen(next);\n    setCopied(false);\n    clearTimeout(copyTimer.current);\n    if (!usingKeys.current) return;\n    requestAnimationFrame(() =>\n      (next ? first.current : trigger.current)?.focus(),\n    );\n  };\n  const toggleRef = useRef(toggle);\n  useEffect(() => {\n    toggleRef.current = toggle;\n  });\n\n  // Closes on Escape or a press anywhere else.\n  useEffect(() => {\n    if (!open) return;\n    const onDown = (e: PointerEvent) => {\n      if (!root.current?.contains(e.target as Node)) toggleRef.current(false);\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        usingKeys.current = true;\n        toggleRef.current(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open]);\n\n  useEffect(() => () => clearTimeout(copyTimer.current), []);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(url);\n    } catch {\n      return;\n    }\n    setCopied(true);\n    clearTimeout(copyTimer.current);\n    copyTimer.current = setTimeout(() => setCopied(false), 1800);\n  };\n\n  const t = reduceMotion ? { duration: 0 } : RESHAPE;\n  const fade = reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT };\n  const text = encodeURIComponent(title);\n  const link = encodeURIComponent(url);\n\n  return (\n    <motion.div\n      ref={root}\n      layout\n      transition={t}\n      onPointerDownCapture={() => (usingKeys.current = false)}\n      onKeyDownCapture={() => (usingKeys.current = true)}\n      // Radius set here so the reshaping keeps a true pill; the pill clips,\n      // so contents arriving or leaving never show outside it.\n      style={{ borderRadius: 999 }}\n      className={cn(\n        \"relative flex h-11 items-center overflow-hidden bg-background shadow-raised\",\n        className,\n      )}\n    >\n      {/* Both faces sit in normal flow and swap in place, so the pill\n          measures one or the other and never both at once. */}\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {!isOpen ? (\n          <motion.button\n            key=\"share\"\n            ref={trigger}\n            layout=\"position\"\n            type=\"button\"\n            aria-expanded={false}\n            onClick={() => toggle(true)}\n            initial={{ opacity: 0, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, filter: \"blur(0px)\", transition: { ...fade, delay: 0.08 } }}\n            exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.1 } }}\n            className=\"flex h-11 shrink-0 items-center gap-2 rounded-full px-5 text-[14px] font-medium text-foreground outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n          >\n            {Icon.share}\n            Share\n          </motion.button>\n        ) : (\n          <motion.div\n            key=\"targets\"\n            layout=\"position\"\n            role=\"group\"\n            aria-label={`Share ${title}`}\n            className=\"flex shrink-0 items-center gap-0.5 p-1\"\n            initial={{ opacity: 0, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, filter: \"blur(0px)\", transition: { ...fade, delay: 0.06 } }}\n            exit={{ opacity: 0, filter: \"blur(4px)\", transition: { duration: 0.1 } }}\n          >\n            {/* Copy grows a word when it's done, inside the pill. */}\n            <motion.button\n              ref={first}\n              layout=\"position\"\n              type=\"button\"\n              aria-label={isCopied ? \"Link copied\" : \"Copy link\"}\n              onClick={() => (demo ? undefined : void copy())}\n              className={cn(target, \"w-auto gap-1.5 px-2.5\")}\n            >\n              <span className=\"relative grid size-4 place-items-center\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={isCopied ? \"check\" : \"link\"}\n                    initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                    exit={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n                    transition={fade}\n                    className=\"grid place-items-center\"\n                  >\n                    {isCopied ? Icon.check : Icon.link}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n              <AnimatePresence initial={false}>\n                {isCopied && (\n                  <motion.span\n                    initial={{ opacity: 0, width: 0, filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, width: \"auto\", filter: \"blur(0px)\" }}\n                    exit={{ opacity: 0, width: 0, filter: \"blur(4px)\" }}\n                    transition={reduceMotion ? { duration: 0 } : RESHAPE}\n                    className=\"overflow-hidden text-[13px] font-medium whitespace-nowrap\"\n                  >\n                    Copied\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </motion.button>\n            <a\n              href={`https://x.com/intent/post?text=${text}&url=${link}`}\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              aria-label=\"Post on X\"\n              className={target}\n            >\n              {Icon.x}\n            </a>\n            <a\n              href={`mailto:?subject=${text}&body=${link}`}\n              aria-label=\"Email\"\n              className={target}\n            >\n              {Icon.mail}\n            </a>\n            {native && (\n              <button\n                type=\"button\"\n                aria-label=\"More ways to share\"\n                onClick={() =>\n                  void navigator.share({ title, url }).catch(() => {})\n                }\n                className={target}\n              >\n                {Icon.more}\n              </button>\n            )}\n            <span aria-hidden className=\"mx-1 h-5 w-px bg-border\" />\n            <button\n              type=\"button\"\n              aria-label=\"Close\"\n              onClick={() => toggle(false)}\n              className={cn(target, \"text-muted-foreground hover:text-foreground\")}\n            >\n              {Icon.close}\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {isCopied ? \"Link copied\" : \"\"}\n      </span>\n    </motion.div>\n  );\n}\n\nconst target =\n  \"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-foreground outline-hidden transition-[background-color,color,scale] duration-150 ease-out hover:bg-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\";\n\nexport default function ShareButtonDemo() {\n  const play = usePreviewPlay();\n  const [demo, setDemo] = useState<{ open: boolean; copied: boolean } | null>(\n    null,\n  );\n\n  // The card's hover show: open, copy the link, close again. Nothing is\n  // written to the clipboard.\n  useEffect(() => {\n    if (play !== true) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const at = (ms: number, fn: () => void) => timers.push(setTimeout(fn, ms));\n    const run = () => {\n      at(400, () => setDemo({ open: true, copied: false }));\n      at(1300, () => setDemo({ open: true, copied: true }));\n      at(3000, () => setDemo({ open: false, copied: false }));\n      at(4400, run);\n    };\n    run();\n    return () => {\n      timers.forEach(clearTimeout);\n      setDemo(null);\n    };\n  }, [play]);\n\n  return (\n    <ShareButton\n      url=\"https://lab.xevrion.dev/lab/share-button\"\n      title=\"Share button, from ui lab\"\n      demo={play === true ? demo : null}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/share-button"}