{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "social-preview-dock", "type": "registry:ui", "title": "Social Preview Dock", "description": "A row of social links sharing one preview card. One panel owns the card surface and resizes itself between profiles while the contents cross-fade inside, so moving between icons reads as a single card growing and travelling. The GitHub card pulls a live contribution graph.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/social-preview-dock.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Social Preview Dock — a row of social links sharing one preview card.\n *\n * One panel owns the card surface — border, background, rounding, shadow — and\n * resizes itself between profiles, the way a navigation menu viewport does.\n * Hovering an icon measures that card's natural box and springs the panel's\n * width, height and x onto it while the contents cross-fade inside. So moving\n * between icons reads as a single card growing and travelling, never as two\n * cards sliding past each other.\n *\n * Only the card on screen is mounted. The panel is `overflow-hidden`, so during\n * a resize the incoming card is simply revealed by a box that is still growing —\n * no layout animation, nothing to keep in sync, and no offscreen work.\n *\n * The card is reachable by cursor: the panel's wrapper carries the gap down to\n * the row as padding, so the pointer never crosses dead space on the way up, and\n * `mouseleave` sits on the outer wrapper, which counts the panel as inside.\n *\n * The shadow is a `drop-shadow` filter on the wrapper rather than a box-shadow\n * on the panel, so it tracks the panel's animating size without a second\n * transition of its own.\n *\n * All content arrives through `profile` / `items`. Nothing here is anyone's\n * real account — see the demo for populated cards.\n */\n\n/** One day of the GitHub contribution graph. `level` is GitHub's own 0–4 ramp. */\nexport interface ContributionDay {\n date: string;\n count: number;\n level: number;\n}\n\nexport interface SocialProfile {\n /** GitHub login: shown on the GitHub card, and the key live data is fetched with. */\n username?: string;\n /** X handle, without the @. */\n xHandle?: string;\n /** Display name on the LinkedIn card. */\n name?: string;\n /** Square image URL. The live GitHub avatar replaces it when it arrives. */\n avatar?: string;\n /** One-liner under the @handle on the X card. */\n bio?: string;\n /** Job title on the LinkedIn card. */\n headline?: string;\n /** City line on the LinkedIn card. */\n location?: string;\n /**\n * Contribution total to show when live data is off or unavailable. Left out,\n * the card says \"Contributions in the last year\" rather than inventing a number.\n */\n contributions?: number;\n /**\n * Offline heatmap: one digit (0–4) per day, oldest first, rendered\n * column-major into 7 rows. Live data replaces it. Left out, the grid renders\n * a full year of empty tiles rather than a fabricated streak.\n */\n levels?: string;\n /** Banner image for the X card. Takes precedence over the gradient. */\n cover?: string;\n links?: { github?: string; linkedin?: string; x?: string };\n}\n\nexport interface SocialPreviewItem {\n /** Stable identifier. */\n id: string;\n /** Accessible name for the link. */\n label: string;\n href: string;\n /** Brand mark — sized by the button, colored by `currentColor`. */\n icon: React.ReactNode;\n /** Card revealed on hover. Give it a fixed width so the rail can't reflow. */\n card: React.ReactNode;\n}\n\nexport interface SocialPreviewDockProps {\n /** Card content. Every field is optional; cards drop what they aren't given. */\n profile?: SocialProfile;\n /** Replace the links and their cards outright. Live data is skipped. */\n items?: SocialPreviewItem[];\n /** Address for the copy pill. Omit it and the pill isn't rendered. */\n email?: string;\n /** Fetch the live GitHub card. Needs `profile.username`. */\n live?: boolean;\n /**\n * Banner blob colors per card, as background utility classes. Theme tokens by\n * default, so the banners follow a palette swap and light/dark on their own.\n */\n bannerColors?: { linkedin: string[]; x: string[] };\n className?: string;\n}\n\n/* Travel is a touch livelier than the resize: the panel arrives at the new icon\n just before it finishes growing, which reads as one object moving rather than\n a box being redrawn. Both are near-critically damped — a card that overshoots\n its own size looks like a bug. */\nconst SPRING_MOVE = {\n type: \"spring\" as const,\n stiffness: 620,\n damping: 46,\n mass: 0.8,\n};\nconst SPRING_SIZE = {\n type: \"spring\" as const,\n stiffness: 700,\n damping: 54,\n mass: 0.8,\n};\n/** Content swaps faster than the box moves, so the card is never mid-fade at rest. */\nconst FADE = { duration: 0.16, ease: [0.22, 1, 0.36, 1] as const };\n\n/** Gap kept between the revealed card and the viewport edge. */\nconst MARGIN = 12;\n/** 52 weeks. What the grid falls back to with no data to draw. */\nconst EMPTY_YEAR = 364;\n\nconst DEFAULT_BANNER_COLORS = {\n linkedin: [\"bg-chart-2\", \"bg-chart-3\", \"bg-primary\"],\n x: [\"bg-chart-5\", \"bg-chart-1\", \"bg-chart-4\"],\n};\n\n/* ── Live GitHub data ──\n Both endpoints are public, unauthenticated and send `access-control-allow-\n origin: *`, so this works from the browser with no token and no server route.\n api.github.com allows 60 requests/hour per visitor IP, which one profile\n lookup per page view stays well under. */\n\ninterface GithubUserResponse {\n avatar_url?: string;\n}\n\ninterface ContributionsResponse {\n total?: { lastYear?: number };\n contributions?: ContributionDay[];\n}\n\ninterface GithubLive {\n avatar?: string;\n total?: number;\n days?: ContributionDay[];\n}\n\nconst GITHUB_API = \"https://api.github.com/users\";\nconst CONTRIBUTIONS_API = \"https://github-contributions-api.jogruber.de/v4\";\n\nfunction useGithubLive(username: string, enabled: boolean): GithubLive {\n const [live, setLive] = React.useState({});\n\n React.useEffect(() => {\n if (!enabled || !username) return;\n let alive = true;\n\n const json = (url: string): Promise =>\n fetch(url).then((r) => (r.ok ? r.json() : Promise.reject(r.status)));\n\n /* allSettled, not all: a rate-limited profile lookup should not cost us the\n heatmap, and vice versa. Whatever fails keeps its offline value. */\n Promise.allSettled([\n json(`${GITHUB_API}/${username}`),\n json(`${CONTRIBUTIONS_API}/${username}?y=last`),\n ]).then(([user, graph]) => {\n if (!alive) return;\n const next: GithubLive = {};\n if (user.status === \"fulfilled\") next.avatar = user.value?.avatar_url;\n if (graph.status === \"fulfilled\") {\n next.total = graph.value?.total?.lastYear;\n next.days = graph.value?.contributions;\n }\n setLive(next);\n });\n\n return () => {\n alive = false;\n };\n }, [username, enabled]);\n\n return live;\n}\n\n/* ── Brand marks ──\n Paths only, `currentColor`, no brand palette: they inherit the theme's\n foreground like any other icon in the row. */\n\nconst GithubMark = () => (\n \n \n \n);\n\nconst LinkedinMark = () => (\n \n \n \n);\n\nconst XMark = () => (\n \n \n \n);\n\n/* ── Cards ── */\n\n/** Card contents carry no surface of their own — the panel is the card, and it\n resizes between them. They only cap at the viewport so a 420px card can't\n overflow a 375px phone. */\nconst CARD = \"max-w-[calc(100vw-1.5rem)]\";\n\n/** Level 0 is a neutral tile; 1–4 are one chart token at four strengths, so the\n ramp follows the theme instead of a hardcoded green. */\nconst LEVEL_TINT = [\n \"bg-muted\",\n \"bg-chart-2/25\",\n \"bg-chart-2/45\",\n \"bg-chart-2/70\",\n \"bg-chart-2\",\n];\n\n/** \"2025-07-27\" → \"Jul 27, 2025\", parsed as a local date so a timezone behind\n UTC doesn't shift every tile back a day. */\nfunction formatDay(iso: string): string {\n const [y, m, d] = iso.split(\"-\").map(Number);\n return new Date(y, m - 1, d).toLocaleDateString(undefined, {\n month: \"short\",\n day: \"numeric\",\n year: \"numeric\",\n });\n}\n\ninterface HeatmapTip {\n x: number;\n y: number;\n text: string;\n}\n\n/** Half the widest tooltip line, near enough. See the note at the call site. */\nconst TIP_HALF = 100;\n\nfunction ContributionHeatmap({\n days,\n label,\n}: {\n days: ContributionDay[];\n label: string;\n}) {\n /* One tooltip node driven by delegation, not 364 hover handlers and 364\n mounted chips. The tile's own offsets are enough to place it. */\n const [tip, setTip] = React.useState(null);\n const gridRef = React.useRef(null);\n\n const trackTip = (e: React.MouseEvent) => {\n const cell = (e.target as HTMLElement).closest(\"[data-day]\");\n const day = cell && days[Number(cell.dataset.day)];\n if (!cell || !day?.date) return setTip(null);\n setTip({\n x: cell.offsetLeft + cell.offsetWidth / 2,\n y: cell.offsetTop,\n text: `${day.count} contribution${day.count === 1 ? \"\" : \"s\"} on ${formatDay(day.date)}`,\n });\n };\n\n return (\n
\n {/* One image to assistive tech: 364 tiles announced one by one is noise,\n and the total above already carries the information. */}\n setTip(null)}\n >\n {days.map((day, i) => (\n \n ))}\n
\n {tip && (\n \n {tip.text}\n \n )}\n \n );\n}\n\nfunction GithubCard({\n profile,\n live,\n}: {\n profile: SocialProfile;\n live: GithubLive;\n}) {\n /* Live days carry a real date and count. Without them the grid draws an empty\n year rather than a made-up streak — a blank graph is honest, fake data isn't. */\n const days = React.useMemo(() => {\n if (live.days?.length) return live.days;\n const levels = profile.levels ?? \"\";\n return Array.from({ length: levels.length || EMPTY_YEAR }, (_, i) => ({\n date: \"\",\n count: 0,\n level: Number(levels[i] ?? 0),\n }));\n }, [live.days, profile.levels]);\n\n const total = live.total ?? profile.contributions;\n const caption =\n total === undefined\n ? \"Contributions in the last year\"\n : `${total.toLocaleString()} contributions in the last year`;\n\n return (\n
\n
\n {profile.avatar || live.avatar ? (\n /* eslint-disable-next-line @next/next/no-img-element */\n \n ) : null}\n
\n

{profile.username}

\n

{caption}

\n
\n
\n \n
\n );\n}\n\n/* ── Banner ──\n A mesh gradient, drawn here rather than pulled from a package: four colors\n blended by inverse distance to four anchors drifting on their own orbits,\n over a domain-warped field. The warp is what turns four blobs into a mesh —\n without it you get four circles.\n\n About 60 lines of WebGL and no dependency, which also means no build step to\n add, nothing to keep in version sync, and no second copy of a renderer if you\n already ship one. */\n\nconst VERTEX_SHADER = `\nattribute vec2 a_pos;\nvoid main() { gl_Position = vec4(a_pos, 0.0, 1.0); }\n`;\n\nconst FRAGMENT_SHADER = `\nprecision mediump float;\nuniform vec2 u_res;\nuniform float u_t;\nuniform vec3 u_colors[4];\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / u_res;\n\n /* Deliberately not aspect-corrected: letting the field stretch with the box\n gives a banner soft horizontal sweeps instead of a row of circles. */\n vec2 p = uv;\n p += 0.18 * vec2(sin(uv.y * 4.0 + u_t * 0.6), cos(uv.x * 4.0 + u_t * 0.5));\n p += 0.10 * vec2(sin(uv.y * 8.0 - u_t * 0.4), cos(uv.x * 7.0 + u_t * 0.45));\n\n vec3 sum = vec3(0.0);\n float weight = 0.0;\n for (int i = 0; i < 4; i++) {\n float fi = float(i);\n vec2 anchor = vec2(\n 0.5 + 0.42 * sin(u_t * (0.23 + 0.05 * fi) + fi * 1.7),\n 0.5 + 0.42 * cos(u_t * (0.19 + 0.06 * fi) + fi * 2.3)\n );\n /* Inverse distance, softened: the epsilon keeps an anchor from burning a\n hard dot into the surface as the field passes over it. */\n float w = 1.0 / (pow(distance(p, anchor), 2.6) + 0.015);\n sum += u_colors[i] * w;\n weight += w;\n }\n gl_FragColor = vec4(sum / weight, 1.0);\n}\n`;\n\n/**\n * Resolve CSS custom properties — or any CSS color — to [r, g, b] floats.\n *\n * A shader needs numbers, but hardcoding them would put the banners outside the\n * theme. A canvas 2D context normalises whatever the browser can parse\n * (including the `oklch()` shadcn emits under Tailwind v4) down to hex, which\n * gives us numbers while the source of truth stays a theme token. Re-resolves\n * when the theme flips.\n */\nfunction useShaderColors(colors: string[]): number[][] {\n const [rgb, setRgb] = React.useState([]);\n const key = colors.join(\"|\");\n\n React.useEffect(() => {\n const ctx = document.createElement(\"canvas\").getContext(\"2d\");\n if (!ctx) return;\n\n const read = () => {\n const styles = getComputedStyle(document.documentElement);\n setRgb(\n key.split(\"|\").map((color) => {\n const raw = color.startsWith(\"--\")\n ? styles.getPropertyValue(color).trim()\n : color;\n ctx.fillStyle = \"#888888\";\n ctx.fillStyle = raw || \"#888888\";\n const hex = String(ctx.fillStyle);\n // Opaque colors come back as #rrggbb; anything else keeps mid grey.\n if (hex[0] !== \"#\" || hex.length !== 7) return [0.53, 0.53, 0.53];\n return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);\n }),\n );\n };\n\n read();\n const observer = new MutationObserver(read);\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"style\", \"data-theme\"],\n });\n return () => observer.disconnect();\n }, [key]);\n\n return rgb;\n}\n\nfunction compile(\n gl: WebGLRenderingContext,\n type: number,\n source: string,\n): WebGLShader | null {\n const shader = gl.createShader(type);\n if (!shader) return null;\n gl.shaderSource(shader, source);\n gl.compileShader(shader);\n return gl.getShaderParameter(shader, gl.COMPILE_STATUS) ? shader : null;\n}\n\nfunction MeshGradient({\n colors,\n speed = 0.35,\n className,\n}: {\n colors: string[];\n speed?: number;\n className?: string;\n}) {\n const canvasRef = React.useRef(null);\n const rgb = useShaderColors(colors);\n const key = rgb.map((c) => c.join(\",\")).join(\";\");\n\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas || !rgb.length) return;\n\n /* WebGL 1, not 2: it is the wider net, and nothing here needs 2. A machine\n without either just keeps the CSS gradient painted behind the canvas. */\n const gl = canvas.getContext(\"webgl\", { antialias: false, depth: false });\n if (!gl) return;\n\n const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);\n const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);\n const program = vs && fs ? gl.createProgram() : null;\n if (!vs || !fs || !program) return;\n gl.attachShader(program, vs);\n gl.attachShader(program, fs);\n gl.linkProgram(program);\n if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;\n gl.useProgram(program);\n\n const buffer = gl.createBuffer();\n gl.bindBuffer(gl.ARRAY_BUFFER, buffer);\n gl.bufferData(\n gl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n gl.STATIC_DRAW,\n );\n const attribute = gl.getAttribLocation(program, \"a_pos\");\n gl.enableVertexAttribArray(attribute);\n gl.vertexAttribPointer(attribute, 2, gl.FLOAT, false, 0, 0);\n\n // Fewer than four colors just repeat around the anchors.\n const palette = Array.from({ length: 4 }, (_, i) => rgb[i % rgb.length]);\n gl.uniform3fv(\n gl.getUniformLocation(program, \"u_colors\"),\n new Float32Array(palette.flat()),\n );\n\n const uRes = gl.getUniformLocation(program, \"u_res\");\n const uTime = gl.getUniformLocation(program, \"u_t\");\n const still = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n const start = performance.now();\n let frame = 0;\n\n const resize = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const w = Math.max(1, Math.round(canvas.clientWidth * dpr));\n const h = Math.max(1, Math.round(canvas.clientHeight * dpr));\n if (canvas.width !== w || canvas.height !== h) {\n canvas.width = w;\n canvas.height = h;\n }\n gl.viewport(0, 0, canvas.width, canvas.height);\n gl.uniform2f(uRes, canvas.width, canvas.height);\n };\n\n const draw = (now: number) => {\n resize();\n // Reduced motion keeps the gradient, drops the drift.\n gl.uniform1f(uTime, still ? 0 : ((now - start) / 1000) * speed);\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n if (!still) frame = requestAnimationFrame(draw);\n };\n frame = requestAnimationFrame(draw);\n\n const observer = new ResizeObserver(resize);\n observer.observe(canvas);\n\n return () => {\n cancelAnimationFrame(frame);\n observer.disconnect();\n gl.deleteProgram(program);\n gl.deleteShader(vs);\n gl.deleteShader(fs);\n gl.deleteBuffer(buffer);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [key, speed]);\n\n return (\n /* `rounded-[inherit]` is not cosmetic. A WebGL canvas is a composited\n layer, and a composited layer ignores an ancestor's rounded\n `overflow: hidden` clip — the card body below it clips fine, so the\n banner alone would sit there with square top corners. Carrying the\n radius down to the canvas itself is what rounds it. */\n \n );\n}\n\nfunction Banner({\n colors,\n className,\n}: {\n colors: string[];\n className?: string;\n}) {\n return (\n /* The gradient is the fallback, not decoration: without WebGL the canvas\n paints nothing, and this is what stays. Only the card on screen is\n mounted, so no banner renders behind a closed panel. */\n \n \n \n );\n}\n\n/** `relative` on the avatars is load-bearing: the shader banner is a positioned\n element, so a static image would paint under it and lose its overlap. */\nconst OVERLAP_AVATAR =\n \"relative z-10 -mt-7 size-14 rounded-full border-4 border-card object-cover\";\n\nfunction LinkedinCard({\n profile,\n live,\n colors,\n}: {\n profile: SocialProfile;\n live: GithubLive;\n colors: string[];\n}) {\n return (\n
\n \n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n

{profile.name}

\n
\n

\n {profile.headline}\n {profile.headline && profile.location &&
}\n {profile.location}\n

\n {profile.links?.linkedin && (\n \n Connect\n \n )}\n
\n
\n
\n );\n}\n\nfunction XCard({\n profile,\n live,\n colors,\n}: {\n profile: SocialProfile;\n live: GithubLive;\n colors: string[];\n}) {\n return (\n
\n {profile.cover ? (\n \n ) : (\n \n )}\n
\n
\n {/* eslint-disable-next-line @next/next/no-img-element */}\n \n {profile.links?.x && (\n \n Follow\n \n )}\n
\n {profile.xHandle && (\n

@{profile.xHandle}

\n )}\n

{profile.bio}

\n
\n
\n );\n}\n\nfunction defaultItems(\n profile: SocialProfile,\n live: GithubLive,\n bannerColors: { linkedin: string[]; x: string[] },\n): SocialPreviewItem[] {\n const links = profile.links ?? {};\n return [\n links.github && {\n id: \"github\",\n label: \"GitHub\",\n href: links.github,\n icon: ,\n card: ,\n },\n links.linkedin && {\n id: \"linkedin\",\n label: \"LinkedIn\",\n href: links.linkedin,\n icon: ,\n card: (\n \n ),\n },\n links.x && {\n id: \"x\",\n label: \"X\",\n href: links.x,\n icon: ,\n card: ,\n },\n ].filter(Boolean) as SocialPreviewItem[];\n}\n\n/* ── Copy pill ── */\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = React.useState(false);\n const timer = React.useRef | undefined>(\n undefined,\n );\n\n React.useEffect(() => () => clearTimeout(timer.current), []);\n\n const copy = async () => {\n try {\n await navigator.clipboard.writeText(email);\n setCopied(true);\n clearTimeout(timer.current);\n timer.current = setTimeout(() => setCopied(false), 3000);\n } catch {\n // No clipboard permission (or no secure context) — hand it to the mail client.\n window.location.assign(`mailto:${email}`);\n }\n };\n\n return (\n \n {/* Both labels stack so the pill can't change width mid-transition. */}\n \n Copy my email\n \n \n E-mail copied!\n \n \n {copied ? `${email} copied to clipboard` : \"\"}\n \n \n );\n}\n\n/* ── Dock ── */\n\nconst useIsoLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\n/** Sum offsetLeft up the offsetParent chain until `ancestor`. Transform-independent. */\nfunction offsetLeftWithin(\n el: HTMLElement | null,\n ancestor: HTMLElement | null,\n): number {\n let x = 0;\n let node: HTMLElement | null = el;\n while (node && node !== ancestor) {\n x += node.offsetLeft;\n node = node.offsetParent as HTMLElement | null;\n }\n return x;\n}\n\nexport function SocialPreviewDock({\n profile = {},\n items,\n email,\n live = true,\n bannerColors = DEFAULT_BANNER_COLORS,\n className,\n}: SocialPreviewDockProps) {\n const github = useGithubLive(\n profile.username ?? \"\",\n live && !items && !!profile.username,\n );\n\n const links = React.useMemo(\n () => items ?? defaultItems(profile, github, bannerColors),\n [items, profile, github, bannerColors],\n );\n\n const wrapperRef = React.useRef(null);\n const panelRef = React.useRef(null);\n const btnRefs = React.useRef<(HTMLAnchorElement | null)[]>([]);\n\n const [active, setActive] = React.useState<{\n index: number;\n id: string;\n } | null>(null);\n const [box, setBox] = React.useState({ x: 0, width: 0, height: 0 });\n /* First open jumps into place. Springing from the previous card's box would\n otherwise fly the panel in from wherever it last was. */\n const openRef = React.useRef(false);\n const appearing = !openRef.current;\n\n /* Measure before paint, so the panel never shows a frame at the wrong size.\n The card is absolutely positioned inside an overflow-hidden panel, so its\n own box is its natural one no matter what the panel currently measures. */\n useIsoLayoutEffect(() => {\n const wrapper = wrapperRef.current;\n if (!active || !wrapper) return;\n const card = panelRef.current?.querySelector(\n `[data-card=\"${active.id}\"]`,\n );\n const btn = btnRefs.current[active.index];\n if (!card || !btn) return;\n\n const width = card.offsetWidth;\n const height = card.offsetHeight;\n\n /* Centre on the icon, then keep the card on screen: the row can sit anywhere\n on the page and every card is wider than it is. When a card is wider than\n the viewport the left edge wins — a cut right edge is cheaper to read than\n a cut avatar. */\n const originLeft = wrapper.getBoundingClientRect().left;\n const centered =\n offsetLeftWithin(btn, wrapper) + btn.offsetWidth / 2 - width / 2;\n const x = Math.max(\n MARGIN - originLeft,\n Math.min(centered, window.innerWidth - MARGIN - width - originLeft),\n );\n\n setBox({ x, width, height });\n openRef.current = true;\n }, [active, github]);\n\n const hide = React.useCallback(() => {\n openRef.current = false;\n setActive(null);\n }, []);\n\n const visible = active !== null && box.width > 0;\n\n return (\n /* The panel is a DOM child of this wrapper, so moving the pointer from an\n icon up onto the card never fires this `mouseleave` — mouseleave counts\n descendants as inside, wherever they are painted. */\n {\n if (!e.currentTarget.contains(e.relatedTarget as Node)) hide();\n }}\n onKeyDown={(e) => {\n if (e.key === \"Escape\") hide();\n }}\n className={cn(\"relative inline-flex items-center\", className)}\n >\n {/* ── Panel ──\n `pb-3` is the gap to the row, and it belongs to this wrapper rather\n than being a margin, so it stays hoverable: the pointer crosses it\n on the way to the card instead of leaving the dock. The shadow is a\n filter here so it follows the panel's animating shape. */}\n \n \n \n {active && (\n \n {links[active.index]?.card}\n \n )}\n \n \n \n\n {/* ── The row itself ── */}\n
\n {email && }\n {links.map((item, i) => (\n {\n btnRefs.current[i] = el;\n }}\n href={item.href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-label={item.label}\n onMouseEnter={() => setActive({ index: i, id: item.id })}\n onFocus={() => setActive({ index: i, id: item.id })}\n className=\"rounded-md p-2 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&>svg]:size-6\"\n >\n {item.icon}\n \n ))}\n
\n \n );\n}\n\nexport default SocialPreviewDock;\n", "type": "registry:ui", "target": "components/ruixen/social-preview-dock.tsx" } ] }