Integration Orbit
Installation
npx shadcn@latest add https://uibrik.com/r/integration-orbit.jsonInstall the packages
npm install @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/integration-orbit/integration-orbit.tsx"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, animate, motion, useAnimationFrame, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; import useMeasure from "react-use-measure"; import { IconBrandDiscord, IconBrandDropbox, IconBrandFigma, IconBrandGithub, IconBrandGoogleDrive, IconBrandJira, IconBrandNotion, IconBrandSentry, IconBrandSlack, IconBrandStripe, IconBrandVercel, IconBrandZoom, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; // Integration orbit. Your mark in the middle, the apps it connects to on two // slow rings around it, turning in opposite directions, all in one ink. // Pointing at an app eases both rings to a stop, draws a line from it // to the middle, sends one dot along it and names the app under its tile. // // Each ring is one rotating layer; its icons counter-rotate by the same amount // so they stay upright. Speed is eased toward its target every frame, so // stopping and starting never jumps. export type Integration = { name: string; icon: ReactNode; }; const ICON = 22; const I = (Icon: typeof IconBrandSlack) => <Icon size={ICON} stroke={1.75} />; const INTEGRATIONS: Integration[] = [ { name: "GitHub", icon: I(IconBrandGithub) }, { name: "Slack", icon: I(IconBrandSlack) }, { name: "Figma", icon: I(IconBrandFigma) }, { name: "Stripe", icon: I(IconBrandStripe) }, { name: "Notion", icon: I(IconBrandNotion) }, { name: "Vercel", icon: I(IconBrandVercel) }, { name: "Sentry", icon: I(IconBrandSentry) }, { name: "Zoom", icon: I(IconBrandZoom) }, { name: "Google Drive", icon: I(IconBrandGoogleDrive) }, { name: "Jira", icon: I(IconBrandJira) }, { name: "Discord", icon: I(IconBrandDiscord) }, { name: "Dropbox", icon: I(IconBrandDropbox) }, ]; /** The uibrik mark, drawn in currentColor. Its viewBox is centred on the shape, so it sits true in the middle. */ export function UibrikMark({ className }: { className?: string }) { return ( <svg viewBox="28 29 136 134" fill="currentColor" aria-hidden className={className}> <path d="M34.0779 31H58.5813C60.7904 31 62.5813 32.7909 62.5813 35V128.5H103.331C107.75 128.5 111.331 124.918 111.331 120.5V74C111.331 71.7909 113.122 70 115.331 70H139.831C142.04 70 143.831 71.7909 143.831 74V120.5C143.831 124.918 147.413 128.5 151.831 128.5H158.081C160.29 128.5 162.081 130.291 162.081 132.5V157C162.081 159.209 160.29 161 158.081 161H66.5813C64.3721 161 62.5813 159.209 62.5813 157V128.5H34.0033C31.7929 128.5 30.0015 126.707 30.0033 124.497L30.0779 34.9967C30.0798 32.7888 31.8701 31 34.0779 31Z" /> <path d="M38.2063 128.5H62.5813V152.875C62.5813 152.875 62.5446 139.838 56.8938 134.187C51.243 128.537 38.2063 128.5 38.2063 128.5Z" /> <path d="M86.9563 128.5H62.5813V104.125C62.5813 104.125 62.618 117.162 68.2688 122.812C73.9196 128.463 86.9563 128.5 86.9563 128.5Z" /> <path d="M116.867 35.8233C117.428 33.0188 119.89 31 122.75 31H143.012C146.799 31 149.639 34.4639 148.896 38.1767L144.796 58.6767C144.235 61.4812 141.773 63.5 138.912 63.5H118.65C114.864 63.5 112.024 60.0361 112.767 56.3233L116.867 35.8233Z" /> </svg> ); } export type IntegrationOrbitProps = { integrations?: Integration[]; /** Middle of the orbit: your logo. Defaults to the uibrik mark. */ center?: ReactNode; /** Names the orbit for screen readers. */ label?: string; /** Inner ring speed, degrees per second. The outer ring turns the other way, slower. */ speed?: number; /** Apps on the inner ring; the rest go on the outer one. */ inner?: number; /** Height, px. The width fills the container up to the same size. */ size?: number; onSelect?: (integration: Integration) => void; className?: string; }; const TILE = 48; // icon tile, px const CENTER = 84; // centre tile, px const OUTER_RATIO = 0.72; // outer ring speed relative to the inner one export function IntegrationOrbit({ integrations = INTEGRATIONS, center = <UibrikMark className="size-9" />, label = "Integrations", speed = 8, inner = 4, size = 420, onSelect, className, }: IntegrationOrbitProps) { const reduce = useReducedMotion(); const [ref, { width }] = useMeasure(); const [active, setActive] = useState<number | null>(null); const innerCount = Math.min(inner, integrations.length); const outerCount = integrations.length - innerCount; const half = Math.min(width, size) / 2; // Room for the name under a tile at the bottom of the outer ring. const outerR = Math.max(0, half - TILE / 2 - 18); const innerR = outerR * 0.56; const cx = width / 2; const cy = size / 2; // Rotation in degrees; the outer ring reads the same value, scaled and reversed. const angle = useMotionValue(0); const velocity = useRef(0); const target = active !== null || reduce ? 0 : speed; useAnimationFrame((_, delta) => { // Ease toward the target speed over roughly a quarter second. velocity.current += (target - velocity.current) * Math.min(1, delta / 120); if (Math.abs(velocity.current) > 0.001) angle.set(angle.get() + (velocity.current * delta) / 1000); }); const outerRot = useTransform(angle, (a) => -a * OUTER_RATIO); const rings = [ { r: innerR, rot: angle, items: integrations.slice(0, innerCount), offset: 0, start: -90 }, { r: outerR, rot: outerRot, items: integrations.slice(innerCount), offset: innerCount, start: -90 + 180 / Math.max(1, outerCount) }, ]; const where = (index: number) => { const ring = index < innerCount ? rings[0] : rings[1]; return { ring, base: ring.start + (360 / ring.items.length) * (index - ring.offset) }; }; return ( <div ref={ref} role="group" aria-label={label} className={cn("relative w-full", className)} style={{ height: size, maxWidth: size }} onPointerLeave={() => setActive(null)} > {width > 0 && ( <> <svg width={width} height={size} className="pointer-events-none absolute inset-0 overflow-visible" aria-hidden> {[innerR, outerR].map((r) => ( <circle key={r} cx={cx} cy={cy} r={r} fill="none" className="stroke-foreground/[0.09]" strokeWidth={1} /> ))} <AnimatePresence> {active !== null && ( <Tether key={active} cx={cx} cy={cy} r={where(active).ring.r} base={where(active).base} rot={where(active).ring.rot} reduce={!!reduce} /> )} </AnimatePresence> </svg> {rings.map((ring, ri) => ( <motion.div key={ri} className="pointer-events-none absolute" style={{ left: cx - ring.r, top: cy - ring.r, width: ring.r * 2, height: ring.r * 2, rotate: ring.rot }} > {ring.items.map((item, i) => { const index = ring.offset + i; const base = ((ring.start + (360 / ring.items.length) * i) * Math.PI) / 180; return ( <Satellite key={item.name} item={item} x={ring.r + ring.r * Math.cos(base)} y={ring.r + ring.r * Math.sin(base)} rot={ring.rot} active={active === index} dimmed={active !== null && active !== index} reduce={!!reduce} onEnter={() => setActive(index)} onLeave={() => setActive((a) => (a === index ? null : a))} onSelect={onSelect} /> ); })} </motion.div> ))} {/* Your mark, in the middle. */} <div className="absolute grid place-items-center rounded-full bg-foreground text-background shadow-[0_1px_2px_rgb(0_0_0/0.1),0_8px_20px_-10px_rgb(0_0_0/0.35)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]" style={{ left: cx - CENTER / 2, top: cy - CENTER / 2, width: CENTER, height: CENTER }} > {center} </div> </> )} </div> ); } function Satellite({ item, x, y, rot, active, dimmed, reduce, onEnter, onLeave, onSelect, }: { item: Integration; x: number; y: number; rot: MotionValue<number>; active: boolean; dimmed: boolean; reduce: boolean; onEnter: () => void; onLeave: () => void; onSelect?: (integration: Integration) => void; }) { // Undo the ring's turn, so the icon and its name stay upright. const upright = useTransform(rot, (r) => -r); return ( // Placed by a plain wrapper: edge-tile makes its element position: relative. <motion.div className="pointer-events-auto absolute" style={{ left: x - TILE / 2, top: y - TILE / 2, width: TILE, height: TILE, rotate: upright }} animate={{ scale: active ? 1.1 : 1, opacity: dimmed ? 0.4 : 1 }} transition={{ type: "spring", duration: 0.3, bounce: 0 }} > <button type="button" aria-label={item.name} className={cn( "edge-tile grid size-full place-items-center rounded-full bg-background shadow-[0_1px_2px_rgb(0_0_0/0.05),0_6px_16px_-8px_rgb(0_0_0/0.16)] transition-colors duration-200 outline-none dark:bg-muted", "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500", "text-foreground", )} onPointerEnter={onEnter} onPointerLeave={onLeave} onFocus={onEnter} onBlur={onLeave} onClick={() => onSelect?.(item)} > {item.icon} </button> <AnimatePresence> {active && ( <motion.span aria-hidden className="pointer-events-none absolute top-full left-1/2 mt-2 -translate-x-1/2 rounded-full bg-foreground px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-background" initial={{ opacity: 0, y: reduce ? 0 : -3 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : -3 }} transition={{ duration: 0.15 }} > {item.name} </motion.span> )} </AnimatePresence> </motion.div> ); } /** The line from an app to the middle, with one dot sent along it. */ function Tether({ cx, cy, r, base, rot, reduce, }: { cx: number; cy: number; r: number; base: number; rot: MotionValue<number>; reduce: boolean; }) { // Follows the app while its ring eases to a stop. const x2 = useTransform(rot, (a) => cx + r * Math.cos(((base + a) * Math.PI) / 180)); const y2 = useTransform(rot, (a) => cy + r * Math.sin(((base + a) * Math.PI) / 180)); const t = useMotionValue(0); const dotX = useTransform(() => x2.get() + (cx - x2.get()) * t.get()); const dotY = useTransform(() => y2.get() + (cy - y2.get()) * t.get()); useEffect(() => { if (reduce) return; const c = animate(t, 1, { duration: 0.7, delay: 0.2, ease: [0.65, 0, 0.35, 1] }); return () => c.stop(); }, [t, reduce]); const stroke = "var(--foreground)"; return ( <motion.g initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}> <motion.line x1={cx} y1={cy} x2={x2} y2={y2} style={{ stroke }} strokeOpacity={0.55} strokeWidth={1.5} strokeDasharray="3 4" /> {!reduce && ( <motion.circle cx={dotX} cy={dotY} r={3.5} style={{ fill: stroke }} initial={{ opacity: 0 }} animate={{ opacity: [0, 1, 1, 0] }} transition={{ duration: 0.7, delay: 0.2, times: [0, 0.15, 0.8, 1] }} /> )} </motion.g> ); }
Adds the files to
components/uibrik/integration-orbit and installs @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/integration-orbit.Props
IntegrationOrbit- speed
- Type number
- 8
- Inner ring speed. The outer ring turns the other way, slower. 0 holds still.
- inner
- Type number
- 4
- Apps on the inner ring; the rest go on the outer one.
- size
- Type number
- 420
- Height. The width fills the container up to the same size.
- integrations
- Type { name: string; icon: ReactNode }[]
- 12 sample apps
- Apps in order: the first `inner` on the inner ring, the rest on the outer one.
- center
- Type ReactNode
- <UibrikMark />
- Your logo, in the middle.
- label
- Type string
- "Integrations"
- Names the orbit for screen readers.
- onSelect
- Type (integration) => void
- –
- Called when an app is clicked.
- className
- Type string
- –
- Classes for the wrapper.