Skip to main content

ns-ui

Masonry ASCII Settle

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.

Usage

registry/core/masonry-ascii-settle/demo.tsx
"use client";

import { MasonryAsciiSettle, type MasonryAsciiTile } from "./component";

const TILES: MasonryAsciiTile[] = [
  { id: "t1", title: "Meltwater channel", seed: 3, aspect: 0.8 },
  { id: "t2", title: "Condensation study", seed: 11, aspect: 1.25 },
  { id: "t3", title: "Caustic net", seed: 19, aspect: 0.65 },
  { id: "t4", title: "Refraction drift", seed: 27, aspect: 1.05 },
  { id: "t5", title: "Surface tension", seed: 5, aspect: 0.9 },
  { id: "t6", title: "Foam decay", seed: 33, aspect: 1.4 },
  { id: "t7", title: "Light pool census", seed: 41, aspect: 0.75 },
  { id: "t8", title: "Grain field", seed: 17, aspect: 1.1 },
];

export default function MasonryAsciiSettleDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / masonry-ascii-settle
      </p>
      <div className="w-full max-w-3xl">
        <MasonryAsciiSettle tiles={TILES} />
      </div>
      <p className="max-w-md text-center text-xs text-ns-muted">
        Every tile resolves from a coarse ASCII halftone to a fine one as it
        drops into its column. Resize the window to watch it re-pack.
      </p>
    </div>
  );
}

Component

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 {
  /** the tiles, laid into the masonry grid and settling into place */
  tiles: MasonryAsciiTile[];
  /** extra classes merged onto the rendered root element */
  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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/masonry-ascii-settle.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/masonry-ascii-settle.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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.

Props

PropTypeDefaultDescription
tilesMasonryAsciiTile[]—the tiles, laid into the masonry grid and settling into place
className?string—extra classes merged onto the rendered root element