Masonry ASCII Settle

Masonry

A masonry gallery whose tiles render as live ASCII halftone and resolve from a coarse print to a fine one as they drop into their packed column slot, re-packing and re-dropping every tile when a resize crosses a column-count breakpoint.

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

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

// ---------------------------------------------------------------------------
// MasonryAsciiSettle — a masonry gallery where every tile renders as live
// ASCII halftone (a deterministic noise field mapped to a " .:-=+*#%@" glyph
// ramp, redrawn on <canvas>) that RESOLVES from a coarse print to a fine one
// exactly as the tile drops into its packed column slot: glyph pitch and drop
// offset ease together on one rAF loop, so "settling" is a real resolution
// change, not a decorative fade layered over a CSS `columns` snap. Column
// packing is this component's own shortest-column-first math (own math, not
// CSS `columns`, which flows top-to-bottom per column and can't be re-packed
// on demand). A container resize that crosses a column-count breakpoint
// re-packs every tile into its new column and replays the full coarse ->
// fine drop for all of them, staggered by row order — the reflow IS the
// interaction. This differs from gallery-coverflow-caustic (a 3D coverflow
// carousel with frosted-glass caustics, one card focused at a time, no
// masonry packing and no ASCII), testimonial-wall-reflow (FLIP-reflows real
// text cards on an expand/collapse toggle, no image content and no
// resolution mechanic), and logo-cloud-settle (drops static SVG marks once
// per viewport entry with no live rendering and no re-pack on resize).
// ---------------------------------------------------------------------------

export interface MasonryAsciiTile {
  id: string;
  title: string;
  /** noise seed for the generated ASCII field */
  seed: number;
  /** height as a multiple of the column width */
  aspect: number;
}

export interface MasonryAsciiSettleProps {
  tiles: MasonryAsciiTile[];
  className?: string;
}

const GAP = 14;
const COARSE_PITCH = 15;
const FINE_PITCH = 6;
const DROP_MS = 620;
const STAGGER_MS = 55;
const DROP_PX = 26;
const RAMP = " .:-=+*#%@";

function columnsFor(width: number): number {
  if (width < 520) return 1;
  if (width < 860) return 2;
  return 3;
}

function hash2(x: number, y: number) {
  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
  return n - Math.floor(n);
}
function vnoise(x: number, y: number) {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hash2(xi, yi);
  const b = hash2(xi + 1, yi);
  const c = hash2(xi, yi + 1);
  const d = hash2(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
function noise2(x: number, y: number) {
  return 0.6 * vnoise(x, y) + 0.4 * vnoise(x * 2.1 + 11.3, y * 2.1 + 4.7);
}

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

interface Tokens {
  fg: string;
  bg: 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"), bg: get("--surface", "#171717") };
}

type Pos = { x: number; y: number; w: number; h: number };

function pack(order: string[], sizeOf: (id: string) => number, cols: number, colW: number) {
  const colH = new Array(cols).fill(0);
  const pos: Record<string, Pos> = {};
  for (const id of order) {
    let target = 0;
    for (let c = 1; c < cols; c++) if (colH[c] < colH[target]) target = c;
    const h = sizeOf(id);
    pos[id] = { x: target * (colW + GAP), y: colH[target], w: colW, h };
    colH[target] += h + GAP;
  }
  const maxH = Math.max(0, ...colH.map((h) => Math.max(0, h - GAP)));
  return { pos, maxH };
}

export function MasonryAsciiSettle({ tiles, className = "" }: MasonryAsciiSettleProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRefs = useRef<Record<string, HTMLCanvasElement | null>>({});
  const wrapRefs = useRef<Record<string, HTMLDivElement | null>>({});
  const prevColsRef = useRef<number | null>(null);
  const dropStartRef = useRef<Record<string, number>>({});
  const tokensRef = useRef<Tokens>(typeof document === "undefined" ? { fg: "#ededed", bg: "#171717" } : readTokens());
  const reducedRef = useRef(false);
  const rafRef = useRef(0);
  const [containerHeight, setContainerHeight] = useState(0);
  const [colW, setColW] = useState(0);
  const [posMap, setPosMap] = useState<Record<string, Pos>>({});

  const ids = useMemo(() => tiles.map((t) => t.id), [tiles]);
  const byId = useMemo(() => {
    const m = new Map<string, MasonryAsciiTile>();
    tiles.forEach((t) => m.set(t.id, t));
    return m;
  }, [tiles]);

  const layout = useCallback(
    (replay: boolean) => {
      const container = containerRef.current;
      if (!container) return;
      const width = container.getBoundingClientRect().width;
      if (width < 4) return;
      const cols = columnsFor(width);
      const w = (width - GAP * (cols - 1)) / cols;
      setColW(w);

      const sizeOf = (id: string) => w * (byId.get(id)?.aspect ?? 1);
      const { pos, maxH } = pack(ids, sizeOf, cols, w);
      setPosMap(pos);
      setContainerHeight(maxH);

      const colsChanged = prevColsRef.current !== null && prevColsRef.current !== cols;
      prevColsRef.current = cols;

      if (replay && (colsChanged || dropStartRef.current["__first"] === undefined)) {
        dropStartRef.current["__first"] = 1;
        const now = performance.now();
        // stagger by final row (y), then column (x), so the reflow reads top
        // to bottom, left to right
        const order = [...ids].sort((a, b) => {
          const pa = pos[a];
          const pb = pos[b];
          if (!pa || !pb) return 0;
          if (Math.abs(pa.y - pb.y) > 1) return pa.y - pb.y;
          return pa.x - pb.x;
        });
        order.forEach((id, i) => {
          dropStartRef.current[id] = now + i * STAGGER_MS;
        });
        wake();
      }
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [ids, byId]
  );

  const wake = () => {
    if (rafRef.current) return;
    rafRef.current = requestAnimationFrame(loop);
  };

  const loop = () => {
    rafRef.current = 0;
    const now = performance.now();
    let stillAnimating = false;

    for (const id of ids) {
      const canvas = canvasRefs.current[id];
      const wrap = wrapRefs.current[id];
      if (!canvas || !wrap) continue;
      const start = dropStartRef.current[id];
      const tile = byId.get(id);
      if (!tile) continue;

      let t = 1;
      if (start !== undefined && !reducedRef.current) {
        const elapsed = now - start;
        if (elapsed < 0) {
          t = 0;
          stillAnimating = true;
        } else {
          t = Math.min(1, elapsed / DROP_MS);
          if (t < 1) stillAnimating = true;
        }
      }
      const eased = easeOutCubic(t);
      wrap.style.transform = `translateY(${(1 - eased) * -DROP_PX}px)`;
      wrap.style.opacity = String(0.15 + 0.85 * eased);

      const pitch = COARSE_PITCH + (FINE_PITCH - COARSE_PITCH) * eased;
      paintTile(canvas, tile.seed, pitch);
    }

    if (stillAnimating) rafRef.current = requestAnimationFrame(loop);
  };

  const paintTile = (canvas: HTMLCanvasElement, seed: number, pitch: number) => {
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const cssW = canvas.clientWidth || 1;
    const cssH = canvas.clientHeight || 1;
    const targetW = Math.ceil(cssW * dpr);
    const targetH = Math.ceil(cssH * dpr);
    if (canvas.width !== targetW || canvas.height !== targetH) {
      canvas.width = targetW;
      canvas.height = targetH;
    }
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    const { fg, bg } = tokensRef.current;
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, cssW, cssH);

    const cols = Math.max(1, Math.ceil(cssW / pitch));
    const rows = Math.max(1, Math.ceil(cssH / pitch));
    const cellW = cssW / cols;
    const cellH = cssH / rows;
    ctx.fillStyle = fg;
    ctx.font = `${Math.max(6, Math.round(cellH * 0.95))}px "GeistMono", ui-monospace, monospace`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    for (let gy = 0; gy < rows; gy++) {
      for (let gx = 0; gx < cols; gx++) {
        const raw = Math.min(1, Math.max(0, noise2(gx * 0.3 + seed * 13.7, gy * 0.3 + seed * 5.1)));
        // Raw value-noise clusters tightly around the middle of the ramp —
        // every cell prints some mid-weight character and the tile reads as
        // uniform static, no negative space, no filaments. Power-sharpening
        // the same field (same seed, same mechanic) pushes most cells to
        // blank and only real peaks to the top of the ramp, so a tile reads
        // as sparse bright structure on paper rather than gray noise.
        const v = Math.pow(raw, 2.4);
        const idx = Math.min(RAMP.length - 1, Math.floor(v * RAMP.length));
        const ch = RAMP[idx];
        if (!ch || ch === " ") continue;
        ctx.fillText(ch, gx * cellW + cellW / 2, gy * cellH + cellH / 2);
      }
    }
  };

  useLayoutEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    tokensRef.current = readTokens();
    layout(true);
    const container = containerRef.current;
    if (!container) return;
    const ro = new ResizeObserver(() => layout(true));
    ro.observe(container);
    const mo = new MutationObserver(() => {
      tokensRef.current = readTokens();
      wake();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
    return () => {
      ro.disconnect();
      mo.disconnect();
      cancelAnimationFrame(rafRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    layout(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tiles]);

  return (
    <div
      ref={containerRef}
      className={`relative w-full ${className}`}
      style={{ height: containerHeight || undefined }}
    >
      {tiles.map((tile) => {
        const p = posMap[tile.id];
        return (
          <div
            key={tile.id}
            ref={(el) => {
              wrapRefs.current[tile.id] = el;
            }}
            data-masonry-tile={tile.id}
            className="absolute overflow-hidden rounded-md border border-border will-change-transform"
            style={{
              left: p?.x ?? 0,
              top: p?.y ?? 0,
              width: p?.w ?? colW,
              height: p?.h ?? colW,
            }}
          >
            <canvas
              ref={(el) => {
                canvasRefs.current[tile.id] = el;
              }}
              aria-hidden
              className="block h-full w-full"
            />
            <span className="pointer-events-none absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-background/90 to-transparent px-3 py-2 font-mono text-[11px] text-foreground">
              {tile.title}
            </span>
          </div>
        );
      })}
      <p className="sr-only">
        {tiles.length} images: {tiles.map((t) => t.title).join(", ")}.
      </p>
    </div>
  );
}
Use when

an image grid where the resolution mechanic itself (coarse-to-fine ASCII print) and a real re-packing reflow on resize are the point — pick gallery-coverflow-caustic instead for a single-focus 3D carousel with frosted-glass caustics, or testimonial-wall-reflow for FLIP-reflowing text cards with no image content and no resolution change.

Build spec

Build <MasonryAsciiSettle tiles className?> where tiles is MasonryAsciiTile[] ({id, title, seed, aspect}), aspect being the tile's height as a multiple of its column width. PACKING: own shortest-column-first math (never CSS `columns`, which flows top-to-bottom per column and cannot be re-packed on demand) — columnsFor(width) breaks at 1 column below 520px, 2 below 860px, 3 above; column width is (containerWidth - gap*(cols-1))/cols and each tile's height is columnWidth*aspect, packed by always placing the next tile into the currently-shortest column. THE MECHANIC: each tile is a <canvas> rendering a deterministic 2-octave value-noise field (per-tile seed) as literal ASCII glyphs from a " .:-=+*#%@" luminance ramp, drawn via ctx.fillText at a glyph pitch that EASES from a coarse ~15px cell down to a fine ~6px cell over ~620ms (eased, one rAF loop redrawing every animating tile's canvas each frame) — this is a real resolution change, not a fade or blur filter over a fixed image. That same eased progress simultaneously drives the tile's drop: translateY from -26px to 0 and opacity from ~0.15 to 1, so the tile visibly resolves into focus at the exact moment it lands in its slot. Every tile's drop is staggered ~55ms apart in final top-to-bottom, left-to-right order. On mount, and again any time a ResizeObserver on the container detects the column COUNT itself has changed (not just column width), every tile's position is recomputed via the packing pass and the FULL coarse-to-fine drop replays for every tile, staggered by its NEW position — a resize that changes column count is a real re-pack with visible re-drops, not a CSS reflow that snaps. A resize that keeps the same column count only updates each tile's pixel position/size with no replay. Canvas ink is var(--foreground) on a var(--surface) tile background, both 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. This is a display-only gallery — no buttons, nothing to tab to — so an sr-only paragraph lists every tile's title as its accessible content. `prefers-reduced-motion` renders every tile already at its fine pitch and final position on the first paint, skipping the drop and the resolution ease entirely, though re-packing on a column-count-changing resize still recomputes positions instantly. Zero dependencies.

Tags
masonrygalleryasciicanvassettleink