Magnetic FAQ
Installation
npx shadcn@latest add https://uibrik.com/r/magnetic-faq.jsonInstall the packages
npm install @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/magnetic-faq/magnetic-faq.tsx"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "motion/react"; import { IconX } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useIsDark } from "../_shared/use-is-dark"; // Magnetic FAQ. Questions float as a loose cloud of pills that lean toward the // pointer as it passes. Picking one grows it into the answer card in the // middle, while the rest drift outward and fade so the card has the stage. // // Only an empty background shape morphs between pill and card. The text sits // beside it, never inside a scaled box, so nothing stretches: the question // glides by position alone and the answer fades in once the card has room. // Closing runs the other way: the answer fades out first, then the card shrinks. // // While a card is open, its related questions are listed under the answer and // stay sharp in the cloud, so it doubles as "read next"; picking one swaps the // card straight across. The // pill a card came from is left as a dashed outline, showing where it returns. // // Each pill's pull is worked out from its resting position (offsetLeft/Top, // which ignore transforms), measured once per layout rather than every frame, // so the movement never feeds back into itself or forces a reflow. // // The open pill, and the one shrinking back after a close, are pinned: no pull // and no scatter. Their pill is where the card's shape lands, and a target that // drifts mid-morph is what makes a shared layout animation jitter. const MORPH = { type: "spring", duration: 0.45, bounce: 0.1 } as const; const SETTLE_MS = 600; // how long a closed pill stays pinned while its card shrinks into it const MAGNET = { stiffness: 170, damping: 18, mass: 0.5 }; const EASE_OUT = [0.23, 1, 0.32, 1] as const; const FAR = 1e6; // pointer position while it's outside the cloud // Set inline rather than as classes so Motion can keep the hairline 1px while // the shape scales. Same number of layers in both themes so they interpolate. const EDGE = { light: "inset 0 0 0 1px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.9), 0 1px 2px rgba(0,0,0,0.04)", dark: "inset 0 0 0 1px rgba(255,255,255,0.07), inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0)", }; const PANEL_EDGE = "shadow-[inset_0_0_0_1px_rgb(0_0_0/0.06),inset_0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.06),inset_0_1px_2px_rgb(0_0_0/0.5)]"; export type MagneticFaqItem = { question: string; answer: string; /** Indexes of questions to keep in view while this one is open. */ related?: readonly number[]; }; export type MagneticFaqProps = { items: readonly MagneticFaqItem[]; /** Furthest a pill moves toward the pointer, px. */ strength?: number; /** Distance from the pointer within which pills are pulled, px. */ radius?: number; /** How far the other pills drift out while a card is open, px. */ scatter?: number; /** Give pills a little depth: further ones are fainter and move less. */ depth?: boolean; /** Width of the open card, px. It never grows past the cloud. */ cardWidth?: number; /** Leave a dashed outline where the open card's pill sits. */ trail?: boolean; className?: string; }; // Stable 0..1 per index, so depth doesn't change between renders. const hash = (i: number) => { const x = Math.sin((i + 1) * 12.9898) * 43758.5453; return x - Math.floor(x); }; export function MagneticFaq({ items, strength = 14, radius = 180, scatter = 48, depth = true, cardWidth = 420, trail = true, className, }: MagneticFaqProps) { const id = useId(); const reduceMotion = useReducedMotion(); const edge = useIsDark() ? EDGE.dark : EDGE.light; const [selected, setSelected] = useState(-1); const [closing, setClosing] = useState(false); const [settling, setSettling] = useState(-1); const [layoutVersion, setLayoutVersion] = useState(0); const root = useRef<HTMLDivElement>(null); const pointerX = useMotionValue(FAR); const pointerY = useMotionValue(FAR); const spread = useMotionValue(0); const pills = useRef<(HTMLButtonElement | null)[]>([]); const close = useRef<HTMLButtonElement>(null); const last = useRef(-1); const depthOf = (i: number) => (depth ? Math.round((0.45 + 0.55 * hash(i)) * 100) / 100 : 1); const finishClose = () => { setSettling(selected); setClosing(false); setSelected(-1); }; useEffect(() => { if (settling < 0) return; const t = setTimeout(() => setSettling(-1), SETTLE_MS); return () => clearTimeout(t); }, [settling]); // Pills re-measure their resting spot whenever the cloud reflows. useEffect(() => { const el = root.current; if (!el) return; const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1)); ro.observe(el); return () => ro.disconnect(); }, []); const select = (i: number) => { if (closing || i === selected) return; // Swapping from one card to another: the old pill is landing too. if (selected >= 0) setSettling(selected); setSelected(i); }; // The cloud starts settling straight away; the card waits for its answer to fade. const requestClose = () => { if (selected < 0 || closing) return; spread.set(0); if (reduceMotion) finishClose(); else setClosing(true); }; useEffect(() => { spread.set(selected >= 0 ? 1 : 0); if (selected >= 0) { last.current = selected; close.current?.focus({ preventScroll: true }); } else if (last.current >= 0) { pills.current[last.current]?.focus({ preventScroll: true }); last.current = -1; } }, [selected, spread]); useEffect(() => { if (selected < 0) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && requestClose(); // A click anywhere but the card, or a pill you can pick, closes it. const onClick = (e: MouseEvent) => { const t = e.target as Element; const dialog = root.current?.querySelector("[role=dialog]"); if (!dialog?.contains(t) && !t.closest?.("[data-pill]")) requestClose(); }; document.addEventListener("keydown", onKey); document.addEventListener("click", onClick); return () => { document.removeEventListener("keydown", onKey); document.removeEventListener("click", onClick); }; }); const item = items[selected]; const related = new Set(item?.related ?? []); const morph = reduceMotion ? { duration: 0 } : MORPH; return ( <div ref={root} className={cn("relative flex min-h-90 w-full items-center justify-center", className)} onPointerMove={(e) => { if (e.pointerType !== "mouse" || reduceMotion) return; const r = e.currentTarget.getBoundingClientRect(); pointerX.set(e.clientX - r.left); pointerY.set(e.clientY - r.top); }} onPointerLeave={() => { pointerX.set(FAR); pointerY.set(FAR); }} > <ul className="flex max-w-2xl flex-wrap items-center justify-center gap-x-2.5 gap-y-3 px-4"> {items.map((it, i) => ( <Pill key={i} shapeId={`${id}-shape-${i}`} titleId={`${id}-title-${i}`} question={it.question} isOpen={i === selected} pinned={i === selected || i === settling} layoutVersion={layoutVersion} dimmed={selected >= 0 && i !== selected && !closing} related={related.has(i)} trail={trail} depth={depthOf(i)} edge={edge} strength={reduceMotion ? 0 : strength} radius={radius} scatter={reduceMotion ? 0 : scatter} pointerX={pointerX} pointerY={pointerY} spread={spread} buttonRef={(el) => { pills.current[i] = el; }} onSelect={() => select(i)} /> ))} </ul> {item && ( // Lets clicks through to the pills around the card. <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-4"> <div key={selected} role="dialog" aria-labelledby={`${id}-heading`} className="pointer-events-auto relative w-full p-1.5" style={{ maxWidth: cardWidth }} > <motion.div aria-hidden layoutId={`${id}-shape-${selected}`} className="absolute inset-0 bg-zinc-100 dark:bg-zinc-900" style={{ borderRadius: 24, boxShadow: edge }} transition={morph} /> <div className="relative flex min-h-11 items-center gap-3 pl-3.5 pr-1"> <h3 id={`${id}-heading`} className="min-w-0 flex-1"> <motion.span layoutId={`${id}-title-${selected}`} layout="position" className="inline-block text-[15px] font-medium text-zinc-900 dark:text-zinc-100" initial={{ opacity: depthOf(selected) }} animate={{ opacity: 1 }} transition={morph} > {item.question} </motion.span> </h3> <motion.button ref={close} type="button" aria-label="Close" onClick={requestClose} initial={{ opacity: 0, scale: 0.8 }} animate={closing ? { opacity: 0, scale: 0.8 } : { opacity: 1, scale: 1 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.2, ease: EASE_OUT, delay: closing ? 0 : 0.2 }} className="grid size-8 shrink-0 place-items-center rounded-full text-zinc-500 outline-none transition-colors hover:bg-white hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:hover:bg-zinc-950 dark:hover:text-zinc-100" > <IconX size={14} stroke={2.5} /> </motion.button> </div> <motion.div initial={{ opacity: 0, y: 8, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }} animate={ closing ? { opacity: 0, y: 4, filter: "blur(2px)" } : { opacity: 1, y: 0, filter: "blur(0px)" } } transition={ reduceMotion ? { duration: 0 } : closing ? { duration: 0.1, ease: "easeIn" } : { duration: 0.3, ease: EASE_OUT, delay: 0.2 } } onAnimationComplete={() => closing && finishClose()} className={cn("relative rounded-[18px] bg-white px-4 py-3.5 dark:bg-zinc-950", PANEL_EDGE)} > <p className="text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-400">{item.answer}</p> </motion.div> {related.size > 0 && ( <motion.div className="relative flex flex-wrap items-center gap-1 px-2 pb-0.5 pt-2" initial={{ opacity: 0, y: 4 }} animate={closing ? { opacity: 0, y: 2 } : { opacity: 1, y: 0 }} transition={ reduceMotion ? { duration: 0 } : closing ? { duration: 0.1, ease: "easeIn" } : { duration: 0.3, ease: EASE_OUT, delay: 0.28 } } > <span className="mr-1 text-[12px] font-medium text-zinc-400 dark:text-zinc-500">Related</span> {[...related].map((j) => items[j] ? ( <button key={j} type="button" // Marked like a pill: the swap unmounts this card, so the click // can't be traced back to it by the time it reaches the document. data-pill="" onClick={() => select(j)} className="h-7 rounded-full px-2.5 text-[13px] font-medium text-zinc-600 outline-none transition-colors hover:bg-white hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-950 dark:hover:text-zinc-100" > {items[j].question} </button> ) : null, )} </motion.div> )} </div> </div> )} </div> ); } type PillProps = { /** Shared with the card's background, which grows out of this pill. */ shapeId: string; /** Shared with the card's title, so the question glides into place. */ titleId: string; question: string; isOpen: boolean; /** Held at rest: the card is morphing to or from this pill. */ pinned: boolean; /** Bumped when the cloud reflows, so the pill re-measures its resting spot. */ layoutVersion: number; dimmed: boolean; /** Related to the open card: stays sharp, close by and clickable. */ related: boolean; trail: boolean; depth: number; edge: string; strength: number; radius: number; scatter: number; pointerX: MotionValue<number>; pointerY: MotionValue<number>; spread: MotionValue<number>; buttonRef: (el: HTMLButtonElement | null) => void; onSelect: () => void; }; function Pill({ shapeId, titleId, question, isOpen, pinned, layoutVersion, dimmed, related, trail, depth, edge, strength, radius, scatter, pointerX, pointerY, spread, buttonRef, onSelect, }: PillProps) { const el = useRef<HTMLLIElement>(null); const reduceMotion = useReducedMotion(); const morph = reduceMotion ? { duration: 0 } : MORPH; const fade = reduceMotion ? { duration: 0 } : { duration: 0.25, ease: EASE_OUT }; // Resting centre relative to the cloud, and the direction out from its middle. const rest = useRef<{ cx: number; cy: number; ux: number; uy: number } | null>(null); useLayoutEffect(() => { const node = el.current; const parent = node?.offsetParent as HTMLElement | null; if (!node || !parent) return; const cx = node.offsetLeft + node.offsetWidth / 2; const cy = node.offsetTop + node.offsetHeight / 2; const ox = cx - parent.clientWidth / 2; const oy = cy - parent.clientHeight / 2; const d = Math.hypot(ox, oy) || 1; rest.current = { cx, cy, ux: ox / d, uy: oy / d }; }, [layoutVersion, question]); // Pull toward the pointer, easing off with distance, plus a push away from // the middle of the cloud while another pill is open. const offset = (axis: "x" | "y") => { // Read every motion value up front: useTransform subscribes to the ones read // on its first run, before the element has mounted. const s = spread.get(); const px = pointerX.get(); const py = pointerY.get(); const r = rest.current; if (!r || pinned) return 0; if (s > 0) return (axis === "x" ? r.ux : r.uy) * scatter * s * (0.6 + 0.4 * depth); const dx = px - r.cx; const dy = py - r.cy; const d = Math.hypot(dx, dy); if (d >= radius || d === 0) return 0; return ((axis === "x" ? dx : dy) / d) * (1 - d / radius) ** 2 * strength * depth; }; const x = useSpring(useTransform(() => offset("x")), MAGNET); const y = useSpring(useTransform(() => offset("y")), MAGNET); const tone = !dimmed ? "on" : related ? "related" : "off"; return ( <motion.li ref={el} style={{ x, y }} className="list-none"> {isOpen ? ( // Holds the pill's place in the cloud while it's the card. <motion.span aria-hidden className={cn( "flex h-10 items-center whitespace-nowrap rounded-full px-4 text-[15px] font-medium", // An outline, not a border, so the cloud doesn't shift by a pixel. trail && "outline-1 -outline-offset-1 outline-dashed outline-zinc-300 dark:outline-zinc-700", )} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={reduceMotion ? { duration: 0 } : { duration: 0.3, ease: EASE_OUT, delay: 0.15 }} > <span className="invisible">{question}</span> </motion.span> ) : ( <motion.button ref={buttonRef} type="button" onClick={onSelect} tabIndex={tone === "off" ? -1 : 0} aria-haspopup="dialog" data-pill={tone === "off" ? undefined : ""} className={cn( "group relative flex h-10 items-center rounded-full px-4 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400", tone === "off" && "pointer-events-none", )} initial={false} animate={{ opacity: tone === "off" ? 0.35 : tone === "related" ? 0.85 : 1, scale: tone === "off" ? 0.94 : tone === "related" ? 0.97 : 1, filter: tone === "off" && !reduceMotion ? "blur(2px)" : "blur(0px)", }} whileTap={tone === "off" ? undefined : { scale: 0.95 }} transition={fade} > <motion.span aria-hidden layoutId={shapeId} className="absolute inset-0 bg-zinc-100 transition-colors duration-200 group-hover:bg-zinc-200/70 dark:bg-zinc-900 dark:group-hover:bg-zinc-800" style={{ borderRadius: 20, boxShadow: edge }} transition={morph} /> <motion.span layoutId={titleId} layout="position" className="relative whitespace-nowrap text-[15px] font-medium text-zinc-900 dark:text-zinc-100" style={{ opacity: 0.55 + 0.45 * depth }} transition={morph} > {question} </motion.span> </motion.button> )} </motion.li> ); }
Adds the files to
components/uibrik/magnetic-faq and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/magnetic-faq.Props
MagneticFaq- strength
- Type number
- 14
- Furthest a pill moves toward the pointer.
- radius
- Type number
- 180
- Distance from the pointer within which pills are pulled.
- scatter
- Type number
- 48
- How far the other pills drift out while a card is open.
- depth
- Type boolean
- true
- Further pills are fainter and move less.
- trail
- Type boolean
- true
- Leave a dashed outline where the open card's pill sits.
- cardWidth
- Type number
- 420
- Width of the open card; it never grows past the cloud.
- itemsrequired
- Type { question: string; answer: string; related?: number[] }[]
- –
- Questions and their answers. Keep questions short; they sit on one line. related lists the indexes kept sharp and clickable while this one is open.
- className
- Type string
- –
- Classes for the wrapper.