Magnetic Grid
A lattice of tiles that leans toward — or away from — the cursor, resolved for every tile from a single pointer listener with distance falloff.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/magnetic-grid.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MagneticGrid" component (no runtime
dependencies beyond React and the shared cn() utility).
Contract
- Export a forwardRef<HTMLDivElement, MagneticGridProps> extending
React.HTMLAttributes<HTMLDivElement>; spread the remaining props onto the
root so it stays a drop-in section container.
- Props: cell = 24 (tile edge in px), gap = 10 (px between tiles),
radius = 170 (influence disc in px), strength = 16 (peak displacement in
px), direction = "attract" | "repel", tint = "primary" | "chart",
disabled = false, plus className / children.
- Column and row counts are NOT props: they are derived from the measured
box, so the tiles stay square at every width.
- children render above the lattice and are the only interactive layer; the
component itself renders no clickable affordance.
Behavior
- One passive "pointermove" listener on the root records clientX/clientY
into a ref and wakes a requestAnimationFrame loop. Never one listener per
tile, never React state per pointer event: a 1000Hz mouse still costs
exactly one pass per frame.
- Per frame, read the lattice's getBoundingClientRect ONCE, then derive each
tile centre arithmetically as origin + index * (cell + gap) — the tracks
are centred in the box, so no tile is ever measured individually and a
scrolled or resized page stays correct.
- Falloff: d = distance from the pointer to the tile centre. Outside radius
the target is rest. Inside, falloff = 1 - d / radius; displacement =
unit vector * strength * falloff, signed by direction; proximity =
falloff² and drives both a scale swell (~1.22 max) and opacity
(rest ~0.16 to ~0.85). Cap attraction at min(strength * falloff, d) so
tiles land ON the cursor instead of shooting through it, and skip the
direction maths when d is ~0 to avoid dividing by zero.
- Easing: each tile eases current -> target with a frame-rate independent
factor 1 - exp(-rate * dt) (rate ~9), dt clamped to 1/30 so a backgrounded
tab never resumes with a teleport. That same easing is the settle: on
pointerleave / pointercancel every target becomes rest and the field sinks
home.
- Demand-driven loop: it starts on a pointer move and stops itself the frame
after everything has settled. A tile that reaches rest is written back to
its resting styles exactly once (track it with a Uint8Array flag) instead
of every frame.
- Writes go straight to element.style.transform (translate3d + scale) and
element.style.opacity — no re-render while the pointer moves. Keep the
eased state in Float32Arrays sized to the tile count.
- Environment gate via useSyncExternalStore over "(pointer: fine)" and
"(prefers-reduced-motion: reduce)", server snapshot false. If either fails,
or disabled is true, the lattice still renders — it simply attaches no
listeners and no rAF runs. Also ignore events whose pointerType is "touch",
because hybrid laptops match (pointer: fine) and a finger dragging the page
must never be read as a hover. Both media listeners are removed in the
subscribe cleanup, so a mid-session change flips the branch live.
- Sizing: a ResizeObserver on the lattice layer derives the effective pitch
and then cols = floor((width + gap) / (cell + gap)) (rows likewise), and
only sets state when the numbers actually change. Hold the tile count near
a budget (~900) by scaling the PITCH up — cell and gap together, so that
cell + gap >= sqrt(width * height / budget) — and re-deriving the counts
from it. Never spend the budget by dropping columns and rows: that leaves a
bare band around a field whose whole job is to reach every edge. cell={2}
on a hero therefore comes back coarser instead of minting 100k DOM nodes.
Clamp cell >= 4 and gap / radius / strength >= 0.
- Live tuning: radius, strength, direction and the effective cell/gap live in
a ref that a tiny effect refreshes, so the listener + rAF effect depends
only on the enabled flag and the column/row counts. A prop change then
retargets the running loop (waking it if it had already settled) and the
field eases into its new shape, instead of tearing the listeners down and
freezing flat until the next pointermove — which is what a settings panel
or an in-panel attract/repel toggle would otherwise hit. Keep the pointer
coordinate in a ref too, so when the counts do change the fresh listeners
resume under a cursor that never moved.
- Cleanup is total: remove all three pointer listeners, cancelAnimationFrame,
disconnect the ResizeObserver, and snap every tile home so the next pass
starts from the same rest state its arrays do.
Rendering & styling
- Root: relative isolate overflow-hidden + the consumer className through
cn(). It has no intrinsic height — give it one (h-96, aspect-video) or let
children define it.
- Lattice layer: aria-hidden, pointer-events-none, absolute inset-0, display
grid with place-content-center and explicit
gridTemplateColumns/Rows: repeat(n, <cell>px). It is purely decorative, so
it is never focusable and never announced.
- Tiles: rounded-sm spans whose background is a token — var(--primary) for
tint="primary", or var(--chart-1..5) picked by (row + col) % 5 for a
diagonal ramp in tint="chart". Semantic tokens only, no hex or oklch
literals anywhere.
- Resting opacity varies per tile from a seeded 32-bit LCG (never
Math.random), so the wall reads as textured and the server markup, the
client and every screenshot agree.
- children go in a "relative z-10 h-full" wrapper so they stack above the
field and can be centred inside a fixed-height panel; against an
auto-height root that 100% resolves to auto, so children still drive the
height when you want them to.
Customization levers
- Density: cell + gap are the only two knobs; cell 12 / gap 6 is a fine mesh,
cell 34 / gap 14 is a panel wall. Counts follow the box automatically.
- Physics: radius is how far the field reaches, strength how far a tile
travels. strength > gap makes tiles overlap and clump near the cursor —
usually what you want for "attract"; keep strength < gap for a restrained
corporate feel.
- direction="repel" reads as a clearing opening under the cursor; "attract"
reads as filings pulled to a magnet.
- Colour: tint="primary" for a monochrome wall, tint="chart" for the 5-hue
ramp; or override the tile background token in the map if you want
--accent / --muted-foreground.
- Feel: RESPONSE (the exponential rate) is the single dial between "sticky"
(5) and "snappy" (14); SWELL and PEAK_ALPHA control how much the near ring
blooms — set SWELL to 0 for pure translation.
- Content: children are yours — a headline, a CTA, a logo row. Pass no
children and it is a standalone decorative panel.Concepts
- Proximity field — the payload is the whole lattice, not one element: every tile is a pure function of its distance to a single pointer coordinate, which is why the crowd reads as a magnetic field instead of dozens of independent hovers.
- Single listener, many tiles — one passive
pointermoveon the container, zero per-tile listeners and zero per-tile React state; centres come from grid arithmetic, so the frame costs onegetBoundingClientRectno matter how many tiles there are. - rAF as the throttle — the event handler only records a coordinate; all work happens in one animation frame, so a high-polling-rate mouse cannot outrun the renderer.
- Demand-driven loop — the loop is born on a pointer move and dies the frame after the field settles, and each tile writes its resting styles exactly once on the way home; an idle panel burns nothing.
- Retarget, never restart — the tunables and the pointer coordinate live in refs, so changing
radius,strengthordirectionmid-hover eases the field into its new shape instead of dropping the listeners and leaving it flat, and a re-measured lattice picks straight back up under a cursor that never moved. - Coarsen, never shrink — the tile budget is spent on the pitch and the counts are re-derived from it, so a dense request on a wide hero comes back as a coarser lattice that still reaches all four edges rather than a smaller one floating inside a bare margin.
- Overshoot cap — attraction is clamped to the remaining distance (
min(strength × falloff, d)), so tiles come to rest on the cursor rather than shooting past it and jittering. - Environment gate —
(pointer: fine)plus(prefers-reduced-motion: reduce)decide whether the field is wired at all,pointerType === "touch"is ignored on hybrids, and either way the lattice still renders: motion off degrades to a static texture, never to a blank box.
Elastic Drag Card
A card you can grab and fling — it chases the pointer on a spring, tilts with its own drag velocity, and springs home or docks into a snap point on release.
Canvas Reveal Hover
A panel that answers the pointer with a seeded dot matrix on a canvas beneath its content — squares ignite outward from where the cursor crossed the border, and dissolve when it leaves.