{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "share-button", "type": "registry:ui", "title": "Share Button", "description": "A morphing share button with four-phase choreography, staggered cascade transitions, and cursor glow.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/share-button.tsx", "content": "\"use client\";\n\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { useState, useEffect, useCallback, useRef } from \"react\";\n\n/**\n * Share Button — Rauno Freiberg craft.\n *\n * One surface, four phases, choreographed transitions.\n * Idle → Open (staggered cascade) → Closing (reverse cascade) → Done.\n * Icons exit BEFORE state swaps. Container holds shape during exit.\n * Cursor glow under glass. Per-role springs. Blur materialization.\n */\n\n/* ── Springs — tuned per visual weight ── */\n\nconst spring = {\n morph: { type: \"spring\" as const, stiffness: 400, damping: 30 },\n content: { type: \"spring\" as const, stiffness: 340, damping: 28 },\n icon: { type: \"spring\" as const, stiffness: 440, damping: 24 },\n press: { type: \"spring\" as const, stiffness: 600, damping: 32 },\n};\n\n/* ── Types ── */\n\ntype Phase = \"idle\" | \"open\" | \"closing\" | \"done\";\n\ninterface ShareButtonProps {\n onShare?: (target: string) => void;\n}\n\n/* ── Share targets ── */\n\nconst TARGETS = [\n { id: \"link\", label: \"Copy link\" },\n { id: \"email\", label: \"Email\" },\n { id: \"x\", label: \"Share on X\" },\n];\n\n/* ── Component ── */\n\nexport function ShareButton({ onShare }: ShareButtonProps) {\n const [phase, setPhase] = useState(\"idle\");\n const [locked, setLocked] = useState(false);\n const [glowX, setGlowX] = useState(0);\n const [glowOn, setGlowOn] = useState(false);\n const closeTimer = useRef>();\n\n const lock = useCallback(() => {\n setLocked(true);\n setTimeout(() => setLocked(false), 450);\n }, []);\n\n /* Clean up timers */\n useEffect(() => {\n return () => {\n if (closeTimer.current) clearTimeout(closeTimer.current);\n };\n }, []);\n\n /* Auto-revert from done */\n useEffect(() => {\n if (phase !== \"done\") return;\n const t = setTimeout(() => {\n setPhase(\"idle\");\n lock();\n }, 2200);\n return () => clearTimeout(t);\n }, [phase, lock]);\n\n /**\n * Choreographed close:\n * 1. Set \"closing\" — icons reverse-stagger out, container holds shape\n * 2. After 300ms (icons invisible) — swap to target phase\n */\n const startClose = (nextPhase: \"idle\" | \"done\") => {\n setPhase(\"closing\");\n closeTimer.current = setTimeout(() => setPhase(nextPhase), 300);\n };\n\n const toggle = () => {\n if (locked || phase === \"closing\") return;\n lock();\n if (phase === \"idle\") {\n setPhase(\"open\");\n } else {\n startClose(\"idle\");\n }\n };\n\n const share = (target: string) => {\n if (locked || phase !== \"open\") return;\n lock();\n onShare?.(target);\n startClose(\"done\");\n };\n\n const closing = phase === \"closing\";\n\n return (\n
\n {/* One surface — layout-animated shell */}\n {\n const r = e.currentTarget.getBoundingClientRect();\n setGlowX(e.clientX - r.left);\n if (!glowOn) setGlowOn(true);\n }}\n onMouseLeave={() => setGlowOn(false)}\n >\n {/* ── Cursor glow — light-under-glass ── */}\n \n\n \n {/* ── IDLE ── */}\n {phase === \"idle\" && (\n \n Share\n \n \n )}\n\n {/* ── OPEN + CLOSING (same key — icons animate within, no AnimatePresence swap) ── */}\n {(phase === \"open\" || closing) && (\n \n {/* Share anchor — dimmed, acts as close */}\n \n Share\n \n\n {/* Divider */}\n \n\n {/* Target icons — forward stagger in, REVERSE stagger out */}\n {TARGETS.map((target, i) => {\n const reverseI = TARGETS.length - 1 - i;\n return (\n share(target.id)}\n whileHover={!closing ? { scale: 1.12 } : undefined}\n whileTap={!closing ? { scale: 0.88 } : undefined}\n initial={{ opacity: 0, scale: 0.4, filter: \"blur(4px)\" }}\n animate={{\n opacity: closing ? 0 : 1,\n scale: closing ? 0.4 : 1,\n filter: closing ? \"blur(4px)\" : \"blur(0px)\",\n }}\n transition={{\n ...spring.icon,\n delay: closing ? reverseI * 0.04 : 0.04 + i * 0.05,\n filter: {\n duration: 0.2,\n delay: closing ? reverseI * 0.04 : 0.04 + i * 0.05,\n },\n }}\n className=\"flex items-center justify-center w-9 h-9 mx-0.5 rounded-xl text-neutral-400 hover:text-white hover:bg-white/[0.06] transition-colors duration-150 outline-none cursor-pointer\"\n aria-label={target.label}\n style={{ pointerEvents: closing ? \"none\" : \"auto\" }}\n >\n {target.id === \"link\" && }\n {target.id === \"email\" && }\n {target.id === \"x\" && }\n \n );\n })}\n\n
\n \n )}\n\n {/* ── DONE — checkmark draws on ── */}\n {phase === \"done\" && (\n \n \n Copied\n \n \n \n )}\n \n \n
\n );\n}\n\n/* ── Icons ── */\n\nfunction ArrowIcon() {\n return (\n \n \n \n \n );\n}\n\nfunction LinkIcon() {\n return (\n \n \n \n \n );\n}\n\nfunction EmailIcon() {\n return (\n \n \n \n \n );\n}\n\nfunction XIcon() {\n return (\n \n \n \n );\n}\n\nfunction CheckIcon() {\n return (\n \n \n \n );\n}\n\nexport default ShareButton;\n", "type": "registry:ui", "target": "components/ruixen/share-button.tsx" } ] }