Interactive Grid
component / background
A tiled grid of real cells for the space behind a hero — square or honeycomb at a fixed cell size, flat or receding to a horizon and slanted across it on two degree props, inert, breathing on a deterministic hash, or lighting up under the pointer and taking a full second to let go, in pure CSS.
- type
- component
- files
- 2
- registry deps
- @automindz/backdrop
- npm deps
- none
installs
- components/automindz/interactive-grid.tsx
- styles/automindz-interactive-grid.css
- size is a length in px and there is no viewBox, so a square is square in any container
- Lines are a tiled pattern and run to the container's edge — fade is opt-in, not the default
- A lit cell is a flat, pale fill at 0.145 — no gradient, no shrink — lighting in 100ms and fading over 1000ms
- Two angles in degrees — tilt recedes, rotate slants — sharing one origin and one turn
- pulse is a hash of position, not Math.random(): same field on the server and in the browser
**Two props tilt this grid, and both are plain degrees.** tilt is the recession — how far the plane leans away from the viewer: 0 is a flat wall, 35–60 a floor running to a horizon, clamped at 60 and positive only, because leaning it the other way puts the horizon at the bottom of the box. rotate is the slant — how far the lattice turns *inside* that plane, signed and clamped to ±30, so the rows run diagonally across the box and converge toward a vanishing point off to one side instead of straight up the middle. They compose and neither is a preset: <InteractiveGrid tilt={50} rotate={-14} /> is the receding floor seen from off its axis, which is the picture people mean when they ask for a tilted grid and get a merely foreshortened one. tilt is a real rotateX under a perspective, so cells foreshorten and columns converge — a skewY is the cheap version and does neither — and hit-testing follows the transformed shape, so the cell that lights is still the one under the pointer at any pair of angles. rotate is applied *inside* the SVG, on the one group that holds the line layer and the cells together: that is why the two lattices can never come out of phase, why the horizon stays level and the near edge stays pinned flat along the container's floor, and why a turn costs a little reach at the far corners and never costs coverage. Both are orthogonal to shape and behaviour — a slanted, tilted hexagon pulse is a thing you can ask for — and neither ever changes the tiling, only the field it is drawn on. A tilt always dissolves along its recession axis whatever fade says, because a horizon is not optional. The stylesheet is not optional either: add import "@/styles/automindz-interactive-grid.css" to your root layout once, or nothing responds. Skipping it is safe, never blank — the lines are there at the intensity you asked for, tiled edge to edge, tilted and turned if you asked for those, because every paint is a presentation attribute rather than a rule. **The caller wires one thing: the parent.** Like Backdrop and GridBeams this renders absolute inset-0 -z-10, so its container needs relative isolate — without a stacking context the negative z-index escapes upward and the grid paints behind an ancestor's background. <section className="relative isolate overflow-hidden py-24"><InteractiveGrid behaviour="hover" tilt={45} /><h1>…</h1></section> is the whole integration; no prop is required and there is no config file. **size is a length in CSS pixels, not a ratio** — the cell's width, default 40, clamped to 8–240 — and the SVG carries no viewBox, so one user unit is one CSS pixel: a square is square and a hexagon regular in every container, at every aspect ratio, at every angle, with nothing measured. A hexagon of width size has circumradius size / √3. **The grid always covers its container and runs to its edge**: the lines are an SVG <pattern> filling the field at that fixed pitch, and fade defaults to "none" so the container clips the field rather than a mask dissolving it — pass fade="edges" only for a grid that bleeds into other content instead of into a boundary. columns and rows (48 × 13 by default) bound how far a pointer gets a response, never how far the grid reaches. **A lit cell is the cell, and it is flat, pale and slow.** Every behaviour fills with flat currentColor, so the lit shape is the cell's own path — there is no gradient, no scale and no rim, and the cell lattice and the line lattice are displaced by one shared origin and turned by one shared rotation, so they cannot drift out of phase. That fill stops **half a hairline short of the outline**, which is not a shrink but the only way to keep a lit cell flat: an SVG <pattern> clips its content to its tile, so half of every line is painted *inside* the cell it borders, and a fill reaching the boundary composited over it — measured at 214 of 255 along a lit cell's left and top edges where every other line in the grid renders at 223, which reads as a darker border around a pale block. The inset is asymmetric on a square (the right and bottom lines belong to the next tile and already sit outside the cell, so pulling the fill back there would leave bare background instead) and one shared width on a hexagon. It is geometry, not a transform or a clip-path, so the hover target is unchanged: pointer-events: all hit-tests the cells' hairline stroke as well as their fill, and two neighbours still meet on the line between them. The fill is 0.145 of the layer's own alpha, which is measured rather than picked: it reproduces the rendered lightness the published implementations reach (about luminance 242 of 255 over white), in both themes, from currentColor alone. A cell lights in **100ms** and goes out over **1000ms**, so sweeping the pointer leaves a wake of cells still fading behind it — the falloff is in time, not in space, because CSS cannot name the cell below the one you are on. **press is kept for compatibility and renders exactly what hover does**; it used to shrink the cell and shade it like a well, which read as dark and busy next to the flat fill, so prefer hover in new code. **Clicks are handled, not hoped for**: the layer is pointer-events: none and only the cell group turns it back on, and only for hover and press, so a CTA in the section is in front of the grid rather than clicked through it, at any angle. **pulse is deterministic, not random** — each cell's phase and whether it breathes at all come from a hash of its position, so there is no Math.random(), no ResizeObserver, no state and no hydration mismatch; and **the cadence is capped, not documented**: interactiveGridDuration() floors the lap at 2s (0.5Hz per cell) whatever you pass, so set it with the duration prop and never by overriding --automindz-igrid-duration, which the component writes and the per-cell delays are measured against. The cell cap is arithmetic — 640 squares / 480 hexagons, matched in markup bytes — and trims rows, never columns. behaviour="static" renders the pattern and no cells at all. **intensity defaults to "strong", one step above Backdrop's scale**, because that scale is tuned for a texture you are not meant to notice and this is a grid you are invited to point at: strong puts a line at a 0.127 luminance delta against white, where normal is 0.074 and disappears. The derivation is unchanged, so one app still has one idea of a hairline, and intensity="normal" gets Backdrop's level back. The contrast caveat is Backdrop's: text-foreground clears 4.5:1 over every intensity, but text-muted-foreground drops to 3.6:1 at strong — put muted metadata on a bg-background card, or use fade="edges" to clear the text column. It is currentColor and alpha throughout — give it text-primary and lines and cells follow. Dials: --automindz-igrid-line (0.375), --automindz-igrid-line-width (1px), --automindz-igrid-cell (the lit fill, 0.145), --automindz-igrid-in / --automindz-igrid-out (attack and release, 100ms / 1000ms — the long release is the trail), --automindz-igrid-pulse-opacity (defaults through to --automindz-igrid-cell, so a breathing cell peaks where a hovered one holds), --automindz-igrid-depth (perspective distance behind a tilt, 900px — shorter is a wider-angle lens and a harder recession) and --automindz-igrid-fade (the whole mask layer list — only applies when there is a mask, so pair it with fade="edges" or a tilt).
Variations
one state per section
- 01Hover — a flat, pale fill that takes a second to let go
- 02Tilt — a plane, not a wall
- 03Slant — tilt plus rotate
- 04Tilted honeycomb, breathing
- 052200px wide — scroll it
- 06A square is square, in any box
- 07Edge to edge, or dissolved
Hover — a flat, pale fill that takes a second to let go
Sweep the pointer across it rather than parking on one cell. A cell lights in 100ms and goes out over 1000ms, so you leave a wake of cells still fading behind you — the falloff is in time, not in space, because CSS cannot name the cell below the one you are on. The fill is flat currentColor at 0.145 of the layer's alpha, which renders at the same lightness the published implementations reach; there is no gradient, no scale and no rim, because all three read as busy next to theirs. Zoom in on one lit cell: the block is flat all the way to its edge and every line around it is the same weight. It stops half a hairline short of the outline to stay that way — a pattern clips half of every line into the cell it borders, and a fill that reached it composited darker there than in the middle of the same cell.
Real cells, no client boundary
Per-cell :hover needs no pointer coordinates, so there is no listener and no state — this renders in a server component, like every other backdrop here.
Tilt — a plane, not a wall
A real rotateX under a perspective, so the cells foreshorten and the columns converge on a vanishing point; a skew would slant them without either. Sweep near the bottom — hit-testing follows the transformed shape, so the cell that lights is the one you are on, lying in the plane. Cells are still square in their own plane: the tilt is a transform on the field, never a change to the tiling.
The far edge has no line to end on
The mask is applied in the plane's own box, before the transform, so its ramp comes out as a horizon rather than as a fade at the top of the section.
Slant — tilt plus rotate
Two props, both plain degrees: tilt={50} is how far the plane leans away, rotate={-14} is how far the lattice turns inside it. The rows now run diagonally and converge off to the upper right instead of straight up the box — two vanishing points rather than one — while the horizon stays level and the near edge stays pinned flat along the bottom, because the turn is applied inside the SVG and never to the element. Sweep down near the front: the lit cells lie in the turned plane and fade along the row you just crossed.
Both lattices turn together, or neither does
The lines and the cells sit in one group and share one rotation, so a slant cannot put the lit cells out of phase with the outlines they belong to.
Tilted honeycomb, breathing
shape, behaviour and tilt are three orthogonal props, so this costs nothing extra: a hexagon of width 48 has circumradius 48 ÷ √3 and stays regular at any tilt. Roughly a third of the cells breathe, scattered by a hash of their position rather than by Math.random() — the same field on the server, in the browser and on every reload, with no ResizeObserver and no hydration mismatch.
CSS has no randomness
So the scatter is arithmetic. Each cell's phase and whether it breathes at all are two independent draws from one hash — sharing one would make the quiet cells exactly the late ones.
2200px wide — scroll it
Scroll right to the end: the grid is still there. The lines are an SVG pattern filling 100% × 100% at a fixed pitch, so coverage does not depend on the cell counts or on the cell cap — an earlier build stopped after 1600px and left 799px of bare background on a 2400px hero.
2200px of container, one <rect> of lines
A square is square, in any box
One size={40}, two containers of opposite proportions, and the cells are identical in both — measure them. The SVG carries no viewBox, so a user unit is a CSS pixel and nothing is fitted to anything, and the lines run into all four corners because the default has no mask to take them away again.
wide — 40px cells
tall — the same 40px cells
Edge to edge, or dissolved
Left is the default: the grid runs into the container's corners and the container clips it. Right is fade="edges", for a grid bleeding into other content rather than into a boundary — compare where the first line falls on each. behaviour="static" renders the pattern and nothing else, two nodes and no cells, which is also what you get with no stylesheet, on a touch device, and under reduced motion for pulse.
default — clipped by the container
fade="edges"
