Orbit FAQ
NewInstallation
npx shadcn@latest add https://uibrik.com/r/orbit-faq.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/orbit-faq/orbit-faq.tsx"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useIsPresent, useMotionValue, useReducedMotion, type MotionValue } from "motion/react"; import { IconPlus } from "@tabler/icons-react"; import useMeasure from "react-use-measure"; import { cn } from "@/lib/utils"; // Orbit FAQ. Categories sit on a ring around a hub. Picking one turns the ring // until that category faces the questions, and its questions fan out beside the // orbit on an arc that shares its centre, so the list reads as part of it. // // Side by side when there's room, the ring turns its pick to the right; stacked, // it turns it to the bottom, toward the list below. // // Three quiet extras, each optional: a thin line drawn from the category to the // open question, a ring round each category filling as its questions are read, // and the category's icon large and faint behind the hub's label. const TURN_EASE = [0.32, 0.72, 0, 1] as const; const FADE = { duration: 0.3, ease: [0.23, 1, 0.32, 1] } as const; const ROW = 48; // height of a closed question row, px const LIST_W = 380; // width of the question list when side by side, px const GAP = 48; // between orbit and list when side by side, px const ITEM_EDGE = "shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06),inset_0_1px_0_rgb(255_255_255/0.9),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.07),inset_0_1px_0_rgb(255_255_255/0.08)]"; export type OrbitFaqCategory = { label: string; icon: ReactNode; items: readonly { question: string; answer: string }[]; }; export type OrbitFaqProps = { categories: readonly OrbitFaqCategory[]; /** Category picked on mount. */ defaultCategory?: number; /** Radius of the ring the categories sit on, px. */ ringRadius?: number; /** Diameter of a category button, px. */ iconSize?: number; /** How strongly the question list curves around the orbit, 0 for a straight list. */ curve?: number; /** Draw a line from the category to the open question (side by side only). */ connector?: boolean; /** A ring round each category that fills as its questions are opened. */ progress?: boolean; /** The category's icon, large and faint, behind the hub's label. */ watermark?: boolean; className?: string; }; // Shortest turn from `from` to an angle equivalent to `to`, degrees. const nearest = (from: number, to: number) => from + ((((to - from) % 360) + 540) % 360) - 180; export function OrbitFaq({ categories, defaultCategory = 0, ringRadius = 112, iconSize = 44, curve = 1, connector = true, progress = true, watermark = true, className, }: OrbitFaqProps) { const id = useId(); const reduceMotion = useReducedMotion(); const [ref, { width }] = useMeasure(); const n = categories.length; const [active, setActive] = useState(() => Math.min(Math.max(defaultCategory, 0), n - 1)); const [hovered, setHovered] = useState(-1); const [open, setOpen] = useState(-1); const tabs = useRef<(HTMLButtonElement | null)[]>([]); const rows = useRef<(HTMLLIElement | null)[]>([]); const rootEl = useRef<HTMLDivElement | null>(null); // Questions opened so far, as "category:question". const [seen, setSeen] = useState<ReadonlySet<string>>(() => new Set()); const linkD = useMotionValue(""); const linkX = useMotionValue(0); const linkY = useMotionValue(0); const size = ringRadius * 2 + iconSize + 8; const wide = width === 0 || width >= size + GAP + LIST_W; // Where the picked category ends up: facing the list. const anchor = wide ? 0 : 90; const base = (i: number) => (i * 360) / n - 90; const [rotation, setRotation] = useState(() => anchor - base(active)); // Bigger turns take a little longer, so every turn reads at the same pace. const [turnFor, setTurnFor] = useState(0.4); // Keep the pick facing the list, after a pick or when the layout flips. const target = nearest(rotation, anchor - base(active)); if (Math.abs(target - rotation) > 0.01) { setRotation(target); setTurnFor(0.32 + (Math.abs(target - rotation) / 180) * 0.26); } const pick = (i: number) => { if (i === active) return; setActive(i); setOpen(-1); setHovered(-1); }; const onTabKey = (e: KeyboardEvent, i: number) => { const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0; const to = next ? (i + next + n) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1; if (to < 0) return; e.preventDefault(); pick(to); tabs.current[to]?.focus(); }; const toggleQuestion = (j: number) => { if (open === j) return setOpen(-1); setOpen(j); setSeen((prev) => (prev.has(`${active}:${j}`) ? prev : new Set(prev).add(`${active}:${j}`))); }; const readOf = (i: number) => { const total = categories[i]?.items.length ?? 0; let read = 0; for (let j = 0; j < total; j++) if (seen.has(`${i}:${j}`)) read++; return total ? read / total : 0; }; const wantsLink = connector && wide && open >= 0; const linkKey = `${active}-${open}`; // Which link has come to rest and may be drawn. The row moves while its answer // unfolds and the list re-centres; drawing then would trace a moving target // and show the line twice, mid-move and settled. So wait for it to sit still, // then draw once. const [settledLink, setSettledLink] = useState<string | null>(null); const showLink = wantsLink && settledLink === linkKey; useEffect(() => { if (!wantsLink) return; let raf = 0; let last: number[] | null = null; let still = 0; let settled = false; const start = performance.now(); const measure = () => { const root = rootEl.current?.getBoundingClientRect(); const a = tabs.current[active]?.getBoundingClientRect(); const b = rows.current[open]?.getBoundingClientRect(); if (root && a && b) { const x0 = a.right - root.left + 6; const y0 = a.top + a.height / 2 - root.top; const x1 = b.left - root.left - 8; const y1 = b.top - root.top + ROW / 2; const mx = (x0 + x1) / 2; linkD.set(`M ${x0} ${y0} C ${mx} ${y0}, ${mx} ${y1}, ${x1} ${y1}`); linkX.set(x1); linkY.set(y1); const now = [x0, y0, x1, y1]; still = last && now.every((v, k) => Math.abs(v - last![k]) < 0.1) ? still + 1 : 0; last = now; } const elapsed = performance.now() - start; // The unfold (and any fold of the previous answer) runs 350ms with a long, // slow tail; draw once it's done and the row is still, so the line lands // on its final spot instead of creeping into it. if (!settled && ((elapsed > 380 && still >= 2) || elapsed > 700)) { settled = true; setSettledLink(linkKey); } // Keep following briefly after, in case anything nudges it. if (elapsed < 1000) raf = requestAnimationFrame(measure); }; measure(); return () => cancelAnimationFrame(raf); }, [wantsLink, linkKey, active, open, width, linkD, linkX, linkY]); const turn = reduceMotion ? { duration: 0 } : { duration: turnFor, ease: TURN_EASE }; // Hovering another category previews it in the hub, a shade quieter. const previewing = hovered >= 0 && hovered !== active; const shown = categories[previewing ? hovered : active]; const category = categories[active]; if (!category) return null; const m = category.items.length; // Radius of the arc the questions follow, centred on the orbit. const arc = size / 2 + GAP; const bend = (j: number) => { if (!wide || curve <= 0) return 0; const dy = (j - (m - 1) / 2) * ROW; return (Math.sqrt(Math.max(arc * arc - dy * dy, 0)) - arc) * curve; }; // A short highlighted arc on the ring where the pick rests. const c = size / 2; const arcPath = (() => { const a0 = ((anchor - 22) * Math.PI) / 180; const a1 = ((anchor + 22) * Math.PI) / 180; const p = (a: number) => `${c + ringRadius * Math.cos(a)} ${c + ringRadius * Math.sin(a)}`; return `M ${p(a0)} A ${ringRadius} ${ringRadius} 0 0 1 ${p(a1)}`; })(); return ( <div ref={(el) => { ref(el); rootEl.current = el; }} className={cn("relative flex w-full items-center justify-center", wide ? "flex-row" : "flex-col gap-8", className)} style={{ gap: wide ? GAP : undefined }} > {connector && ( <svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible"> <AnimatePresence> {showLink && <Link key={linkKey} d={linkD} x={linkX} y={linkY} reduceMotion={!!reduceMotion} />} </AnimatePresence> </svg> )} <div className="relative shrink-0" style={{ width: size, height: size }}> <svg aria-hidden className="absolute inset-0 overflow-visible" width={size} height={size}> <circle cx={c} cy={c} r={ringRadius} fill="none" strokeWidth={1} strokeDasharray="2 5" className="stroke-zinc-300 dark:stroke-zinc-700" /> <path d={arcPath} fill="none" strokeWidth={2} strokeLinecap="round" className="stroke-zinc-400 dark:stroke-zinc-500" /> </svg> <div aria-hidden className={cn("absolute grid place-items-center overflow-hidden rounded-full bg-zinc-100 text-center dark:bg-zinc-900", ITEM_EDGE)} style={{ inset: iconSize + 20 }} > {watermark && ( <AnimatePresence initial={false}> <motion.span key={shown.label} className="absolute inset-0 grid place-items-center text-zinc-900/[0.035] dark:text-white/[0.04]" initial={{ opacity: 0, scale: 0.85 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 1.1 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.35, ease: [0.23, 1, 0.32, 1] }} > <span className="grid place-items-center [&>svg]:size-full" // Big enough to read as a silhouette, not a block behind the text. style={{ width: ringRadius * 1.05, height: ringRadius * 1.05 }} > {shown.icon} </span> </motion.span> </AnimatePresence> )} <AnimatePresence mode="popLayout" initial={false}> <motion.div key={shown.label} initial={{ opacity: 0, y: 6, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -6, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }} transition={reduceMotion ? { duration: 0 } : { duration: 0.2 }} className="relative px-3" > <div className={cn( "text-[15px] font-medium transition-colors duration-200", previewing ? "text-zinc-500 dark:text-zinc-400" : "text-zinc-900 dark:text-zinc-100", )} > {shown.label} </div> <div className="mt-0.5 text-[13px] tabular-nums text-zinc-500"> {shown.items.length} {shown.items.length === 1 ? "question" : "questions"} </div> </motion.div> </AnimatePresence> </div> <motion.div role="tablist" aria-label="Categories" aria-orientation="vertical" className="absolute inset-0" initial={false} animate={{ rotate: rotation }} transition={turn} onPointerLeave={() => setHovered(-1)} > {categories.map((cat, i) => { const a = (base(i) * Math.PI) / 180; const isActive = i === active; const read = readOf(i); const ring = iconSize + 10; return ( <motion.button key={i} ref={(el) => { tabs.current[i] = el; }} type="button" role="tab" id={`${id}-tab-${i}`} aria-selected={isActive} aria-controls={`${id}-panel`} aria-label={cat.label} tabIndex={isActive ? 0 : -1} onClick={() => pick(i)} onKeyDown={(e) => onTabKey(e, i)} // Move, not enter: icons turning under a still pointer shouldn't count as hovering. onPointerMove={() => hovered !== i && setHovered(i)} className={cn( "absolute grid place-items-center rounded-full outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950", isActive ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" : cn("bg-zinc-100 text-zinc-500 hover:text-zinc-900 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100", ITEM_EDGE), )} style={{ width: iconSize, height: iconSize, left: c + ringRadius * Math.cos(a) - iconSize / 2, top: c + ringRadius * Math.sin(a) - iconSize / 2, }} initial={false} // Counter-turn so icons stay upright while the ring spins. animate={{ rotate: -rotation, scale: isActive ? 1.08 : 1 }} transition={turn} > <span className="grid size-[18px] place-items-center [&>svg]:size-[18px]">{cat.icon}</span> {progress && ( <svg aria-hidden className="pointer-events-none absolute -rotate-90 overflow-visible" style={{ inset: -5 }} width={ring} height={ring} > <motion.circle cx={ring / 2} cy={ring / 2} r={ring / 2 - 0.75} fill="none" strokeWidth={1.5} strokeLinecap="round" className="stroke-zinc-400 dark:stroke-zinc-500" initial={false} animate={{ pathLength: read, opacity: read > 0 ? 1 : 0 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.5, ease: [0.23, 1, 0.32, 1] }} /> </svg> )} </motion.button> ); })} </motion.div> </div> <div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-tab-${active}`} className="w-full shrink-0" style={{ maxWidth: LIST_W }} > <AnimatePresence mode="wait" initial={false}> <motion.ul key={active} className="flex flex-col gap-1" exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.12 } }} > {category.items.map((item, j) => { const isOpen = j === open; const x = bend(j); return ( <motion.li key={j} ref={(el) => { rows.current[j] = el; }} initial={reduceMotion ? false : { opacity: 0, x: wide ? x - 20 : 0, y: wide ? 0 : -8, filter: "blur(4px)" }} animate={{ opacity: 1, x, y: 0, filter: "blur(0px)" }} transition={reduceMotion ? { duration: 0 } : { ...FADE, delay: j * 0.04 }} className={cn( "rounded-2xl transition-colors duration-300", isOpen ? cn("bg-zinc-100 dark:bg-zinc-900", ITEM_EDGE) : "hover:bg-zinc-100/70 dark:hover:bg-zinc-900/70", )} > <h3> <button type="button" id={`${id}-q-${j}`} aria-expanded={isOpen} aria-controls={`${id}-a-${j}`} onClick={() => toggleQuestion(j)} className="flex w-full items-center gap-3 rounded-2xl px-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-zinc-400" style={{ minHeight: ROW }} > <span className="w-5 shrink-0 font-mono text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500"> {String(j + 1).padStart(2, "0")} </span> <span className={cn( "min-w-0 flex-1 py-2 text-[15px] font-medium transition-colors duration-300", isOpen ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-600 dark:text-zinc-400", )} > {item.question} </span> <motion.span className="shrink-0 text-zinc-400 dark:text-zinc-500" initial={false} animate={{ rotate: isOpen ? 45 : 0 }} transition={reduceMotion ? { duration: 0 } : FADE} > <IconPlus size={14} stroke={2.5} /> </motion.span> </button> </h3> <AnimatePresence initial={false}> {isOpen && ( <motion.div id={`${id}-a-${j}`} role="region" aria-labelledby={`${id}-q-${j}`} className="overflow-hidden" initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.35, ease: [0.32, 0.72, 0, 1] }} > <p className="pb-4 pl-12 pr-10 text-[14px] leading-relaxed text-zinc-600 dark:text-zinc-400">{item.answer}</p> </motion.div> )} </AnimatePresence> </motion.li> ); })} </motion.ul> </AnimatePresence> </div> </div> ); } type LinkProps = { d: MotionValue<string>; x: MotionValue<number>; y: MotionValue<number>; reduceMotion: boolean }; // The connector. It follows the shared measurements only while it's the current // one; once replaced it freezes where it was and fades, rather than sliding // along with the next question's line. function Link({ d, x, y, reduceMotion }: LinkProps) { const isPresent = useIsPresent(); const ownD = useMotionValue(d.get()); const ownX = useMotionValue(x.get()); const ownY = useMotionValue(y.get()); useEffect(() => { if (!isPresent) return; const off = [d.on("change", (v) => ownD.set(v)), x.on("change", (v) => ownX.set(v)), y.on("change", (v) => ownY.set(v))]; return () => off.forEach((f) => f()); }, [isPresent, d, x, y, ownD, ownX, ownY]); return ( <motion.g className="text-zinc-300 dark:text-zinc-700" initial={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: reduceMotion ? 0 : 0.15 } }} > <motion.path d={ownD} fill="none" stroke="currentColor" strokeWidth={1} initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.45, ease: [0.23, 1, 0.32, 1] }} /> <motion.circle cx={ownX} cy={ownY} r={2.5} className="fill-zinc-400 dark:fill-zinc-500" initial={{ scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.2, delay: 0.35 }} /> </motion.g> ); }
Adds the files to
components/uibrik/orbit-faq and installs @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/orbit-faq.Props
OrbitFaq- ringRadius
- Type number
- 112
- Radius of the ring the categories sit on.
- iconSize
- Type number
- 44
- Diameter of a category button.
- curve
- Type number
- 1
- How strongly the questions curve around the orbit; 0 is a straight list.
- connector
- Type boolean
- true
- Draw a line from the category to the open question. Side by side only.
- progress
- Type boolean
- true
- A ring round each category that fills as its questions are opened.
- watermark
- Type boolean
- true
- The category's icon, large and faint, behind the hub's label.
- defaultCategory
- Type number
- 0
- Category picked on mount.
- categoriesrequired
- Type { label: string; icon: ReactNode; items: { question: string; answer: string }[] }[]
- –
- Categories clockwise from the top, each with an icon and its questions.
- className
- Type string
- –
- Classes for the wrapper.