{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"logo-orbit","type":"registry:component","title":"Logo orbit","description":"Logos orbit a heading on tilted rings, passing behind it, and brake to a stop when you point.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["simple-icons"],"registryDependencies":["utils"],"files":[{"path":"components/logo-orbit.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  siArc,\n  siBun,\n  siCloudflare,\n  siFigma,\n  siFramer,\n  siGithub,\n  siLinear,\n  siNeovim,\n  siNextdotjs,\n  siNotion,\n  siRaycast,\n  siReact,\n  siStripe,\n  siSupabase,\n  siTailwindcss,\n  siTypescript,\n  siVercel,\n} from \"simple-icons\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OrbitLogo = { title: string; path: string; hex: string };\n\ntype Ring = {\n  logos: OrbitLogo[];\n  // Radius across, as a share of the stage's width.\n  radius: number;\n  // Seconds per lap; negative turns the other way.\n  lap: number;\n};\n\n// How far the ring is tipped away from you: the vertical radius is this\n// share of the horizontal one, so the circle reads as a tilted plane. Tipped\n// enough that the rings pass above and below the heading, not through it.\nconst TILT = 0.42;\n// A hovered orbit brakes to a stop rather than freezing, and picks back up\n// as smoothly when you leave.\nconst BRAKE = 4;\n\n// Brand colours only show where they'd read: near-black marks (Vercel,\n// Notion, GitHub) would vanish on a dark page, so those stay in the text\n// colour.\nfunction readable(hex: string) {\n  const n = parseInt(hex, 16);\n  const lum = 0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);\n  return lum > 40 && lum < 225;\n}\n\nexport function LogoOrbit({\n  rings,\n  paused = false,\n  className,\n  children,\n}: {\n  rings: Ring[];\n  // Holds a still frame and runs nothing, for idle previews.\n  paused?: boolean;\n  className?: string;\n  // What the logos circle: a heading, a stat, a line of copy.\n  children: React.ReactNode;\n}) {\n  const reduceMotion = useReducedMotion();\n  const stageRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const hovering = useRef(false);\n  const [active, setActive] = useState<string | null>(null);\n\n  const flat = rings.flatMap((ring, r) =>\n    ring.logos.map((logo, i) => ({ logo, r, i, n: ring.logos.length })),\n  );\n\n  useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const turns = rings.map(() => 0);\n    let speed = 1;\n    let frame = 0;\n    let visible = true;\n    let last = performance.now();\n\n    const place = () => {\n      const w = stage.offsetWidth;\n      const h = stage.offsetHeight;\n      flat.forEach(({ r, i, n }, k) => {\n        const el = itemRefs.current[k];\n        if (!el) return;\n        const ring = rings[r];\n        const angle = turns[r] + (i / n) * Math.PI * 2;\n        const rx = (ring.radius * w) / 2;\n        const x = Math.cos(angle) * rx;\n        const y = Math.sin(angle) * rx * TILT;\n        // -1 at the back of the ring, 1 at the front.\n        const depth = Math.sin(angle);\n        const near = (depth + 1) / 2;\n        el.style.transform = `translate(${(w / 2 + x).toFixed(1)}px, ${(h / 2 + y).toFixed(1)}px) translate(-50%, -50%) scale(${(0.7 + near * 0.35).toFixed(3)})`;\n        el.style.opacity = (0.28 + near * 0.72).toFixed(3);\n        // The front half passes over the text, the back half behind it.\n        el.style.zIndex = depth > 0 ? \"3\" : \"1\";\n      });\n    };\n\n    const step = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      const target = hovering.current ? 0 : 1;\n      speed += (target - speed) * (1 - Math.exp(-BRAKE * dt));\n      rings.forEach((ring, r) => {\n        turns[r] += ((Math.PI * 2) / ring.lap) * dt * speed;\n      });\n      place();\n      frame = visible ? requestAnimationFrame(step) : 0;\n    };\n\n    place();\n    if (paused || reduceMotion) return;\n\n    // Sleeps offscreen, so a long page never pays for it.\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible && !frame) {\n        last = performance.now();\n        frame = requestAnimationFrame(step);\n      }\n    });\n    io.observe(stage);\n    const ro = new ResizeObserver(place);\n    ro.observe(stage);\n    frame = requestAnimationFrame(step);\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n      ro.disconnect();\n    };\n    // `flat` is derived from `rings` on every render; the rings are the\n    // real input.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rings, paused, reduceMotion]);\n\n  return (\n    <div\n      ref={stageRef}\n      className={cn(\"relative isolate aspect-[16/10] w-full\", className)}\n      onPointerEnter={(e) => {\n        if (e.pointerType !== \"touch\") hovering.current = true;\n      }}\n      onPointerLeave={() => {\n        hovering.current = false;\n        setActive(null);\n      }}\n    >\n      {/* Centre layer: sits between the back and front of the orbit. It\n          spans the stage only to centre its content, so the pointer passes\n          through to the logos behind it everywhere but the text itself. */}\n      <div className=\"pointer-events-none absolute inset-0 z-[2] grid place-items-center px-[18%] text-center [&>*]:pointer-events-auto\">\n        {children}\n      </div>\n\n      {flat.map(({ logo }, k) => {\n        const on = active === logo.title;\n        return (\n          <div\n            key={logo.title}\n            ref={(el) => void (itemRefs.current[k] = el)}\n            data-on={on}\n            // The one you point at comes forward at full strength, even from\n            // the far side of the ring. !important beats the per-frame depth.\n            className=\"absolute top-0 left-0 will-change-transform data-[on=true]:!z-[4] data-[on=true]:!opacity-100\"\n          >\n            <button\n              type=\"button\"\n              aria-label={logo.title}\n              onPointerEnter={(e) => e.pointerType !== \"touch\" && setActive(logo.title)}\n              onPointerLeave={() => setActive(null)}\n              onFocus={() => {\n                hovering.current = true;\n                setActive(logo.title);\n              }}\n              onBlur={() => {\n                hovering.current = false;\n                setActive(null);\n              }}\n              className=\"relative grid size-11 touch-manipulation place-items-center rounded-full text-foreground outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n            >\n              <svg\n                viewBox=\"0 0 24 24\"\n                aria-hidden\n                className=\"size-7 transition-[color,scale] duration-200 ease-out\"\n                style={{\n                  fill: on && readable(logo.hex) ? `#${logo.hex}` : \"currentColor\",\n                  scale: on ? \"1.12\" : \"1\",\n                }}\n              >\n                <path d={logo.path} />\n              </svg>\n              {/* The name surfaces under the mark you're pointing at. */}\n              <span\n                aria-hidden\n                className={cn(\n                  \"pointer-events-none absolute top-full left-1/2 mt-1 -translate-x-1/2 rounded-full bg-foreground px-2 py-0.5 text-xs font-medium whitespace-nowrap text-background transition-[opacity,translate] duration-150 ease-out\",\n                  on ? \"translate-y-0 opacity-100\" : \"-translate-y-1 opacity-0\",\n                )}\n              >\n                {logo.title}\n              </span>\n            </button>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\nconst pick = (i: { title: string; path: string; hex: string }) => ({\n  title: i.title,\n  path: i.path,\n  hex: i.hex,\n});\n\n// The lab's own stack, so the section says something true.\nconst RINGS: Ring[] = [\n  {\n    logos: [siReact, siNextdotjs, siTypescript, siTailwindcss, siBun, siVercel, siFramer].map(pick),\n    radius: 0.72,\n    lap: 42,\n  },\n  {\n    logos: [siFigma, siLinear, siGithub, siCloudflare, siSupabase, siStripe, siNotion, siRaycast, siArc, siNeovim].map(pick),\n    radius: 0.98,\n    lap: -64,\n  },\n];\n\nexport default function LogoOrbitDemo() {\n  const play = usePreviewPlay();\n  return (\n    <LogoOrbit rings={RINGS} paused={play === false} className=\"w-[min(600px,100%)]\">\n      <div className=\"flex flex-col items-center gap-2\">\n        <p className=\"text-[13px] font-medium text-muted-foreground\">The stack</p>\n        <h2 className=\"text-2xl leading-tight font-semibold tracking-tight text-balance sm:text-[28px]\">\n          The tools behind the lab\n        </h2>\n      </div>\n    </LogoOrbit>\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"}],"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/logo-orbit"}