MaskedGrid
A fine blueprint grid whose lines and intersection dots dissolve around measured content, clearing a fade-edged space around every headline instead of ruling straight through it.
Install
$ npx shadcn@latest add wisp.pouriah.com/r/masked-grid.jsonBackdrop · canvasobstacle-aware
The grid clears around your content.
rebuilt to the standard
"use client";
import * as React from "react";
import {
AVOID_SELECTOR,
cx,
DEFAULT_WISP,
hsl,
useCanvasScene,
useObstacles,
useTokenColor,
useWispGain,
type Box,
type Hsl,
} from "@pouriahlabs/wisp-ui";
/**
* MaskedGrid — a blueprint grid, rebuilt to the standard.
*
* A fine ruled grid behind content is the oldest backdrop there is, and the
* oldest way to fail this library: the lines run straight under the text and the
* page just dims them and hopes. This one measures the content boxes and lets
* the grid *dissolve* into them — a cell fully inside a box is never drawn, and
* the ruling near an edge fades with its distance to the nearest box, so the
* eye reads a clearing around every headline rather than lines crossing it.
*
* The fade is computed per grid segment and per intersection from the true
* rectangle distance, not a rectangular stencil: a diagonal falloff around a
* corner is what stops the clearing from looking like a second box stamped over
* the first.
*/
export interface MaskedGridProps extends React.HTMLAttributes<HTMLDivElement> {
/** Token for the intersection dots — the grid's brighter nodes. */
fromToken?: string;
/** Token for the ruled lines — structural, so it reads muted against the dots. */
lineToken?: string;
/** Selector for content the grid must clear around. */
avoid?: string;
/** Cell pitch, CSS px. */
gridSize?: number;
/** Horizontal breathing room around measured content, CSS px. */
padX?: number;
/** Vertical breathing room around measured content, CSS px. */
padY?: number;
/** Draw the measured boxes as wireframes. Debugging aid — and a good demo. */
debugBoxes?: boolean;
}
export function MaskedGrid({
fromToken = "--wisp",
lineToken = "--wisp-line",
avoid = AVOID_SELECTOR,
gridSize = 46,
padX = 8,
padY = 6,
debugBoxes = false,
className,
...props
}: MaskedGridProps) {
const ref = React.useRef<HTMLCanvasElement>(null);
const boxes = useObstacles(ref, avoid, { padX, padY });
const dot = useTokenColor(fromToken, DEFAULT_WISP);
const line = useTokenColor(lineToken, DEFAULT_LINE);
const gain = useWispGain();
// Line coordinates are pure geometry of the canvas size, so they belong to
// `setup` (re-seeded on a resize past the threshold) rather than being rebuilt
// every frame. The draw loop only walks them and samples the mask.
const xs = React.useRef<number[]>([]);
const ys = React.useRef<number[]>([]);
useCanvasScene(ref, {
setup: ({ width, height }) => {
xs.current = buildAxis(width, gridSize);
ys.current = buildAxis(height, gridSize);
},
draw: ({ ctx, width, height, time }) => {
ctx.clearRect(0, 0, width, height);
const cols = xs.current;
const rows = ys.current;
if (cols.length < 2 || rows.length < 2) return;
const list = boxes.current;
// A cell fades in over roughly one pitch outside a box; wider and the
// clearing swallows the layout, tighter and the edge reads as a hard cut.
const fade = gridSize;
// One slow, shared breath keeps the grid alive without ever competing with
// the content for attention. It is a pure function of `time`, which is
// frozen on the reduced-motion still frame — so that frame is deterministic
// and correct, not blank.
const breath = 0.82 + 0.18 * Math.sin(time * 0.9);
const lineAlpha = LINE_ALPHA * gain * breath;
const dotAlpha = DOT_ALPHA * gain * (0.8 + 0.2 * Math.sin(time * 0.9 + 1.2));
// Horizontal runs: each span between two columns is stroked at the mask
// sampled on its midpoint, so a single ruled line brightens away from a box
// and dies out beneath it instead of being drawn whole and dimmed.
ctx.lineWidth = 1;
for (const y of rows) {
for (let i = 0; i < cols.length - 1; i++) {
const a = cols[i]!;
const b = cols[i + 1]!;
const m = mask(list, (a + b) / 2, y, fade);
if (m <= 0.01) continue;
ctx.strokeStyle = hsl(line, line[2], lineAlpha * m);
ctx.beginPath();
ctx.moveTo(a, y);
ctx.lineTo(b, y);
ctx.stroke();
}
}
// Vertical runs, same rule.
for (const x of cols) {
for (let j = 0; j < rows.length - 1; j++) {
const a = rows[j]!;
const b = rows[j + 1]!;
const m = mask(list, x, (a + b) / 2, fade);
if (m <= 0.01) continue;
ctx.strokeStyle = hsl(line, line[2], lineAlpha * m);
ctx.beginPath();
ctx.moveTo(x, a);
ctx.lineTo(x, b);
ctx.stroke();
}
}
// Intersection dots — the accent nodes that make it read as a blueprint
// rather than graph paper. Same mask, so they clear with the lines.
for (const x of cols) {
for (const y of rows) {
const m = mask(list, x, y, fade);
if (m <= 0.01) continue;
ctx.fillStyle = hsl(dot, dot[2], dotAlpha * m);
ctx.fillRect(x - DOT_HALF, y - DOT_HALF, DOT, DOT);
}
}
if (debugBoxes) {
ctx.save();
ctx.lineWidth = 1;
ctx.font = "600 9px ui-monospace, SFMono-Regular, Menlo, monospace";
for (const box of list) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = hsl(dot, dot[2], 0.85);
ctx.fillStyle = hsl(dot, dot[2], 0.05);
ctx.fillRect(box.l, box.t, box.r - box.l, box.b - box.t);
ctx.strokeRect(box.l + 0.5, box.t + 0.5, box.r - box.l - 1, box.b - box.t - 1);
ctx.setLineDash([]);
ctx.fillStyle = hsl(dot, dot[2], 0.95);
ctx.fillText(avoid, box.l + 4, box.t - 4);
}
ctx.restore();
}
},
});
return (
<div
aria-hidden
className={cx("pointer-events-none absolute inset-0 overflow-hidden", className)}
{...props}
>
<canvas ref={ref} className="absolute inset-0 h-full w-full" />
</div>
);
}
/** Fallback for `--wisp-line`, the structural ruling token — `--line-strong` in
* the shipped palette. Read before CSS paints (SSR, the first client frame). */
const DEFAULT_LINE: Hsl = [198, 13, 24];
/** Relative peak alpha of the ruled lines, before gain and the mask. */
const LINE_ALPHA = 0.34;
/** Relative peak alpha of the intersection dots — brighter, so nodes read. */
const DOT_ALPHA = 0.6;
/** Intersection dot size, CSS px, and its half for centring the fill. */
const DOT = 1.6;
const DOT_HALF = DOT / 2;
/**
* Grid line offsets along one axis, centred so the margins at the two edges
* match rather than leaving a full cell on one side and a sliver on the other.
*/
function buildAxis(extent: number, step: number): number[] {
const count = Math.floor(extent / step);
const start = (extent - count * step) / 2;
const out: number[] = [];
for (let i = 0; i <= count; i++) out.push(start + i * step);
return out;
}
/**
* Shortest distance from a point to a box's rectangle, `0` when the point is
* inside it. Euclidean, so the falloff wraps a corner diagonally instead of in
* an L — the difference between a clearing and a stamped-out rectangle.
*/
function distToBox(box: Box, x: number, y: number): number {
const dx = Math.max(box.l - x, 0, x - box.r);
const dy = Math.max(box.t - y, 0, y - box.b);
return Math.hypot(dx, dy);
}
/**
* How much grid to paint at a point: `0` inside any box (and on its edge),
* smoothly up to `1` once a point is a full `fade` clear of the nearest box.
* With no measured content it is a flat `1`, so the grid is whole.
*/
function mask(boxes: readonly Box[], x: number, y: number, fade: number): number {
let d = Infinity;
for (const box of boxes) {
d = Math.min(d, distToBox(box, x, y));
if (d === 0) return 0; // inside content — nothing to paint, and no closer to find
}
if (d === Infinity) return 1;
const t = Math.min(d / fade, 1);
return t * t * (3 - 2 * t); // smoothstep — no visible seam where the fade begins
}
Certified against the contract
- ✓Reduced motion paints one composed still frame — never a blank box
- ✓Pauses off-screen and in hidden tabs
- ✓Re-reads design tokens when the theme changes
- ✓SSR-safe: no hydration mismatch, no layout shift
- ✓Decorative layers are aria-hidden and pointer-events-none
- ✓Device pixel ratio clamped
- ✓Zero runtime dependencies beyond React