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.json
Backdrop · canvasobstacle-aware
The grid clears around your content.
rebuilt to the standard

MaskedGridesc or double-click to exit full screendouble-tap to exit

atmosphere/masked-grid.tsx
"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