Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const FINE_POINTER = "(pointer: fine)"
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"

/**
 * Tile budget — `cell={2}` across a hero would otherwise mint 100k nodes. It is
 * spent by coarsening the pitch rather than by dropping tracks, so the count
 * lands near this number rather than exactly on it.
 */
const TILE_BUDGET = 900

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/magnetic-grid.json

Prompt

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 pointermove on the container, zero per-tile listeners and zero per-tile React state; centres come from grid arithmetic, so the frame costs one getBoundingClientRect no 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, strength or direction mid-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.

On This Page