Feature Grid ASCII Rule

Grid

A feature grid where hovering or focusing a cell draws real box-drawing connectors to its related features, routed orthogonally through the grid's gutters and retracted glyph by glyph on leave.

Install
npx shadcn add https://design.helpmarq.com/r/feature-grid-ascii-rule.json
Source
registry/core/feature-grid-ascii-rule/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// FeatureGridAsciiRule — a feature grid where hovering or focusing a cell
// draws real box-drawing connectors between it and its related features,
// routed orthogonally through the grid's own gutters (never straight through
// a neighbouring cell). Every related pair always meets at the ONE shared
// horizontal gutter between the two rows: the connector drops from the
// source cell's gutter-facing edge, runs across at the gutter's mid-line,
// then rises into the target cell's gutter-facing edge — a uniform 3-segment
// Manhattan route regardless of which rows the two cells sit in, rasterized
// into individual ─ │ ┌ ┐ └ ┘ glyphs at a fixed pitch and revealed glyph by
// glyph (source -> target) on entry, retracted glyph by glyph in the
// opposite order (target -> source) on leave. This is a distinct mechanic
// from grid-bento-ascii (one static seam that structurally disappears when a
// single tile re-spans the whole grid — no relationships, no routing, no
// second cell involved once expanded), grid-bento-dense (FLIP reflow of tile
// footprints, no connectors at all) and grid-magnetic-lattice (a continuous
// cursor-warped field sampled by every line at once, not discrete per-pair
// routes tied to a data relationship).
// ---------------------------------------------------------------------------

export interface FeatureGridAsciiRuleItem {
  id: string;
  title: string;
  description: string;
  /** ids of other items in this same array this feature relates to */
  relatedIds: string[];
}

export interface FeatureGridAsciiRuleProps {
  items: FeatureGridAsciiRuleItem[];
  cols?: number;
  className?: string;
}

interface Tokens {
  fg: string;
  border: string;
  accent: string;
}

function readTokens(): Tokens {
  const cs = getComputedStyle(document.documentElement);
  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;
  return {
    fg: get("--foreground", "#ededed"),
    border: get("--border", "#2e2e2e"),
    accent: get("--accent", "#006bff"),
  };
}

const PITCH = 11; // px per glyph, both axes
const STAGGER = 26; // ms per glyph step
const FADE = 140; // ms per glyph's own fade

type GlyphKind = "h" | "v" | "corner";

interface Glyph {
  x: number;
  y: number;
  ch: string;
  kind: GlyphKind;
  dist: number; // step index from source (0-based)
}

function corner(vert: "up" | "down", horiz: "left" | "right"): string {
  if (vert === "up" && horiz === "right") return "└"; // └
  if (vert === "up" && horiz === "left") return "┘"; // ┘
  if (vert === "down" && horiz === "right") return "┌"; // ┌
  return "┐"; // ┐
}

/** Builds the glyph list for one source -> target connector, routed through
 *  the single shared horizontal gutter between the two grid rows. */
function routeGlyphs(
  a: DOMRect,
  b: DOMRect,
  aIsTopRow: boolean,
  bIsTopRow: boolean,
  containerRect: DOMRect,
  gutterY: number
): Glyph[] {
  const ax = a.left + a.width / 2 - containerRect.left;
  const bx = b.left + b.width / 2 - containerRect.left;
  const aEdgeY = (aIsTopRow ? a.bottom : a.top) - containerRect.top;
  const bEdgeY = (bIsTopRow ? b.bottom : b.top) - containerRect.top;

  const glyphs: Glyph[] = [];
  let step = 0;

  // Anchor dot right on the source cell's own edge — without an explicit
  // mark here the connector's first glyph was drawn centered ON the
  // boundary line (half hidden behind the card), so the wire read as
  // fading into nothing rather than visibly plugging into the cell.
  glyphs.push({ x: ax, y: aEdgeY, ch: "●", kind: "corner", dist: step++ });

  // vertical run from the source cell's gutter-facing edge to the gutter line
  const v1Steps = Math.max(1, Math.round(Math.abs(gutterY - aEdgeY) / PITCH));
  for (let i = 1; i <= v1Steps; i++) {
    const y = aEdgeY + ((gutterY - aEdgeY) * i) / v1Steps;
    glyphs.push({ x: ax, y, ch: "│", kind: "v", dist: step++ });
  }

  // bend 1: connects back toward the source (vert) and across toward bend 2 (horiz)
  const vert1: "up" | "down" = aIsTopRow ? "up" : "down";
  const horiz1: "left" | "right" = bx >= ax ? "right" : "left";
  if (Math.abs(bx - ax) > 1) {
    glyphs.push({ x: ax, y: gutterY, ch: corner(vert1, horiz1), kind: "corner", dist: step++ });

    // horizontal run across the gutter
    const hSteps = Math.max(1, Math.round(Math.abs(bx - ax) / PITCH));
    for (let i = 1; i < hSteps; i++) {
      const x = ax + ((bx - ax) * i) / hSteps;
      glyphs.push({ x, y: gutterY, ch: "─", kind: "h", dist: step++ });
    }

    // bend 2: connects back toward bend 1 (horiz) and up/down toward the target (vert)
    const vert2: "up" | "down" = bIsTopRow ? "up" : "down";
    const horiz2: "left" | "right" = horiz1 === "right" ? "left" : "right";
    glyphs.push({ x: bx, y: gutterY, ch: corner(vert2, horiz2), kind: "corner", dist: step++ });
  } else {
    // same column: no bend needed, straight vertical line the whole way
  }

  // vertical run from the gutter to the target cell's gutter-facing edge
  const v2Steps = Math.max(1, Math.round(Math.abs(bEdgeY - gutterY) / PITCH));
  for (let i = 1; i <= v2Steps; i++) {
    const y = gutterY + ((bEdgeY - gutterY) * i) / v2Steps;
    glyphs.push({ x: bx, y, ch: "│", kind: "v", dist: step++ });
  }

  // Anchor dot on the target cell's edge, same reasoning as the source
  // anchor — this is the glyph that reads "arrived", so it's also the
  // first thing to fade on leave (retraction runs target -> source).
  glyphs.push({ x: bx, y: bEdgeY, ch: "●", kind: "corner", dist: step++ });

  return glyphs;
}

function easeOutCubic(t: number): number {
  const c = Math.min(1, Math.max(0, t));
  return 1 - Math.pow(1 - c, 3);
}

export function FeatureGridAsciiRule({ items, cols = 3, className = "" }: FeatureGridAsciiRuleProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cellRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const [tokens, setTokens] = useState<Tokens>(() =>
    typeof document === "undefined" ? { fg: "#ededed", border: "#2e2e2e", accent: "#006bff" } : readTokens()
  );
  const [activeId, setActiveId] = useState<string | null>(null);
  const reducedRef = useRef(false);

  const byId = useMemo(() => {
    const m = new Map<string, FeatureGridAsciiRuleItem>();
    items.forEach((it) => m.set(it.id, it));
    return m;
  }, [items]);

  const rows = Math.ceil(items.length / cols);

  useEffect(() => {
    const sync = () => setTokens(readTokens());
    sync();
    const mo = new MutationObserver(sync);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    return () => mo.disconnect();
  }, []);

  useEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  }, []);

  // active/last-active tracked with a transition timestamp so leaving
  // animates a real retraction rather than an instant clear
  const stateRef = useRef<{ id: string | null; since: number; leavingId: string | null }>({
    id: null,
    since: 0,
    leavingId: null,
  });

  useEffect(() => {
    const now = performance.now();
    if (activeId) {
      stateRef.current = { id: activeId, since: now, leavingId: null };
    } else if (stateRef.current.id) {
      stateRef.current = { id: null, since: now, leavingId: stateRef.current.id };
    }
  }, [activeId]);

  useEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    if (!container || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let raf = 0;

    const sizeCanvas = () => {
      const rect = container.getBoundingClientRect();
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.ceil(rect.width * dpr);
      canvas.height = Math.ceil(rect.height * dpr);
      canvas.style.width = `${rect.width}px`;
      canvas.style.height = `${rect.height}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    sizeCanvas();
    const ro = new ResizeObserver(sizeCanvas);
    ro.observe(container);

    const draw = () => {
      const rect = container.getBoundingClientRect();
      ctx.clearRect(0, 0, rect.width, rect.height);

      const st = stateRef.current;
      const showId = st.id ?? st.leavingId;
      if (showId) {
        const source = byId.get(showId);
        const sourceEl = cellRefs.current[showId];
        if (source && sourceEl) {
          const aRect = sourceEl.getBoundingClientRect();
          const aRow = Object.keys(cellRefs.current).indexOf(showId); // fallback, unused
          const aIndex = items.findIndex((it) => it.id === showId);
          const aTopRow = Math.floor(aIndex / cols) === 0;
          const rowGutterTop = Math.min(...items.map((_, i) => (Math.floor(i / cols) === 0 ? 1 : 0)));
          void aRow;
          void rowGutterTop;

          // gutter mid-line: between row 0's bottom and row 1's top, using the
          // first cell of each row actually present
          const row0El = cellRefs.current[items[0]?.id ?? ""];
          const row1Idx = items.findIndex((_, i) => Math.floor(i / cols) === 1);
          const row1El = row1Idx >= 0 ? cellRefs.current[items[row1Idx]!.id] : null;
          const gutterY =
            row0El && row1El
              ? (row0El.getBoundingClientRect().bottom + row1El.getBoundingClientRect().top) / 2 -
                rect.top
              : aRect.bottom - rect.top;

          const now = performance.now();
          const elapsed = now - st.since;
          const advancing = st.id === showId;

          ctx.font = `${PITCH}px "GeistMono", ui-monospace, monospace`;
          ctx.textAlign = "center";
          ctx.textBaseline = "middle";

          for (const relId of source.relatedIds) {
            const targetEl = cellRefs.current[relId];
            const bIndex = items.findIndex((it) => it.id === relId);
            if (!targetEl || bIndex < 0) continue;
            const bRect = targetEl.getBoundingClientRect();
            const bTopRow = Math.floor(bIndex / cols) === 0;
            const glyphs = routeGlyphs(aRect, bRect, aTopRow, bTopRow, rect, gutterY);
            const n = Math.max(1, glyphs.length);

            for (const g of glyphs) {
              let opacity: number;
              if (reducedRef.current) {
                opacity = advancing ? 1 : 0;
              } else if (advancing) {
                const t = (elapsed - g.dist * STAGGER) / FADE;
                opacity = easeOutCubic(t);
              } else {
                // retract target-first: glyphs nearer the target (higher dist)
                // fade out earlier than glyphs nearer the source
                const revIndex = n - 1 - g.dist;
                const t = (elapsed - revIndex * STAGGER) / FADE;
                opacity = 1 - easeOutCubic(t);
              }
              if (opacity <= 0.01) continue;
              // Anchor dots draw in --accent so both ends read as a clear
              // "plugged in" terminal against the fg-colored border glyphs;
              // the run between them stays --foreground so the accent reads
              // as an endpoint marker, not a wash over the whole wire.
              ctx.fillStyle = g.ch === "●" ? tokens.accent : tokens.fg;
              ctx.globalAlpha = Math.min(1, Math.max(0, opacity)) * (g.ch === "●" ? 1 : 0.9);
              ctx.fillText(g.ch, g.x, g.y);
            }
          }
          ctx.globalAlpha = 1;
        }
      }

      const st2 = stateRef.current;
      const stillLeaving = st2.leavingId && performance.now() - st2.since < STAGGER * 20 + FADE + 40;
      if (!stillLeaving && st2.leavingId) {
        stateRef.current = { id: st2.id, since: st2.since, leavingId: null };
      }
      const active = st2.id !== null || (st2.leavingId !== null && stillLeaving);
      if (active || !reducedRef.current) {
        raf = requestAnimationFrame(draw);
      }
    };
    raf = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
    };
  }, [byId, items, cols, tokens]);

  return (
    <div ref={containerRef} className={`relative w-full ${className}`}>
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 z-10" />
      <div
        className="relative grid gap-x-10 gap-y-8"
        style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
      >
        {items.map((item) => {
          const isActive = activeId === item.id;
          const isRelated = activeId != null && byId.get(activeId)?.relatedIds.includes(item.id);
          return (
            <button
              key={item.id}
              type="button"
              ref={(el) => {
                cellRefs.current[item.id] = el;
              }}
              data-feature-cell={item.id}
              aria-label={`${item.title}: ${item.description}`}
              onPointerEnter={() => setActiveId(item.id)}
              onPointerLeave={() => setActiveId((c) => (c === item.id ? null : c))}
              onFocus={() => setActiveId(item.id)}
              onBlur={() => setActiveId((c) => (c === item.id ? null : c))}
              className={`group relative flex flex-col items-start gap-1.5 rounded-md border p-4 text-left transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
                isActive
                  ? "border-foreground/40 bg-surface"
                  : isRelated
                    ? "border-foreground/25 bg-surface/60"
                    : "border-border bg-surface hover:border-foreground/20"
              }`}
            >
              <span className="font-mono text-[10px] uppercase tracking-widest text-muted">
                {String(items.indexOf(item) + 1).padStart(2, "0")}
              </span>
              <span className="text-sm font-semibold text-foreground">{item.title}</span>
              <span className="text-xs text-muted">{item.description}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}
Use when

a feature grid where the relationships BETWEEN features are the thing worth showing, drawn live as routed connectors on hover/focus — pick grid-bento-ascii instead for a 2x2 layout primitive with no cross-cell relationships, whose seam structurally disappears when one tile re-spans the grid, or grid-magnetic-lattice for a continuous cursor-warped field with no discrete per-pair routing at all.

Build spec

Build <FeatureGridAsciiRule items cols? className?> where items is FeatureGridAsciiRuleItem[] ({id, title, description, relatedIds: string[]}) laid out in a CSS grid (default 3 columns, 2 rows for 6 items). Each cell is a real <button data-feature-cell={id} aria-label="{title}: {description}"> so hover, focus and click all reach it natively. A single <canvas aria-hidden> absolutely covers the grid. THE MECHANIC: on pointer-enter or focus of a cell, for every id in that cell's relatedIds, a connector is routed from the source cell to the target cell through the ONE shared horizontal gutter between the grid's two rows — never straight through an intervening cell. The route is always 3 orthogonal segments measured live via getBoundingClientRect: a vertical run from the source cell's gutter-facing edge (bottom edge if it's in the top row, top edge if bottom row) down/up to the gutter's mid-line, a horizontal run across the gutter to the target's x, and a vertical run into the target's gutter-facing edge — collapsing to a single straight vertical run when both cells share the same column. This whole path is rasterized into individual box-drawing glyphs (─ for horizontal steps, │ for vertical steps, one of ┌ ┐ └ ┘ at each bend, chosen by which two of up/down/left/right that bend actually connects) at a fixed ~11px pitch, each drawn via ctx.fillText in a monospace face. Glyphs reveal one at a time, source to target, on a per-glyph stagger (~26ms step, ~140ms own fade, eased) while the pointer/focus is on the source cell; on pointer-leave or blur they retract in the OPPOSITE order — the glyphs nearest the target fade first, working back toward the source — driven by one rAF loop keyed off an activation timestamp, never a CSS transition per glyph (there is no DOM node per glyph). Only one cell's connectors are visible at a time. Colors are token-only: canvas ink is var(--foreground) read via getComputedStyle at mount and re-read through a MutationObserver on the root's class/style attributes, so both themes stay correct with no remount; cell borders and backgrounds are Tailwind token utilities. Reduced motion skips the stagger/fade entirely: connectors snap fully in on activation and fully out on deactivation in one paint. Zero dependencies.

Tags
gridfeatureasciimonobox-drawingcanvas