ns-ui
uiascii-engraving-contoursource: ok · refreshed
ASCII Engraving Contour
ASCII rendered as a real engraving: glyph density traces concentric contour bands through an orbiting metaball field instead of mapping brightness straight to weight, so the plate reads as ridged hatch linework over a soft rounded subject, with a pointer-driven polish trail and a self-healing burin-slip glitch.
npx shadcn add "https://design.helpmarq.com/r/ascii-engraving-contour.json"free
Visual style
pastel
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 8:25:56 AM
registry/loud/ascii-engraving-contour/component.tsx
"use client";
import { useEffect, useRef } from "react";
// ---------------------------------------------------------------------------
// BurinEtch — ASCII art rendered as a genuine engraving, not a brightness
// photograph in monospace. Most "image to text" renderers map luminance
// straight to glyph weight; this instead traces CONTOUR bands through a
// scalar field the way a burin cuts hatch lines across a curved plate —
// density comes from how close a cell sits to one of a stack of concentric
// iso-lines through the field, so the surface reads as ridged linework, not
// a gradient. The field is a small cluster of metaballs that slowly orbit a
// shared center, so the plate depicts a soft rounded subject with true
// engraved contour hatching over its body and a clean vignette where the
// field falls below the plate's edge threshold.
//
// A polish trail follows the pointer: cells it has recently crossed get
// finer, denser hatching that decays back to the ambient coarseness over
// roughly a second, like burnished metal dulling. Independently, on a fixed
// interval, a short burst of rows re-samples from a jittered column offset —
// a burin slip — and self-heals a couple hundred ms later. Direct-DOM rAF
// loop with no React state on the hot path, glyph ink pinned to the live
// --foreground token so both themes render correctly, single static frame
// (no polish, no slip) under prefers-reduced-motion.
// ---------------------------------------------------------------------------
const RAMP = " ·-+=*#%@"; // sparse -> dense; a hatch line is densest at its own center
interface Ball {
baseX: number;
baseY: number;
orbitR: number;
speed: number;
phase: number;
r: number;
}
function makeBalls(w: number, h: number): Ball[] {
const cx = w / 2;
const cy = h / 2;
const unit = Math.min(w, h);
return [
{ baseX: cx, baseY: cy, orbitR: unit * 0.06, speed: 0.18, phase: 0, r: unit * 0.22 },
{ baseX: cx - unit * 0.1, baseY: cy - unit * 0.05, orbitR: unit * 0.14, speed: 0.27, phase: 1.7, r: unit * 0.16 },
{ baseX: cx + unit * 0.12, baseY: cy + unit * 0.06, orbitR: unit * 0.12, speed: 0.22, phase: 3.4, r: unit * 0.15 },
{ baseX: cx + unit * 0.02, baseY: cy - unit * 0.14, orbitR: unit * 0.1, speed: 0.31, phase: 5.1, r: unit * 0.12 },
];
}
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export interface BurinEtchProps {
/** extra classes merged onto the rendered root element */
className?: string;
/** grid cell size in px */
cellSize?: number;
/** hatch lines per field unit — higher reads finer */
ringDensity?: number;
/** fraction of each ring cycle that draws ink, the rest is the gap between hatch lines */
ringWidth?: number;
/** px radius of the pointer's polish influence */
polishRadius?: number;
/** extra hatch density under a fresh polish trail, 0-1 */
polishStrength?: number;
/** ms between burin-slip glitch bursts; 0 disables */
glitchIntervalMs?: number;
}
export function BurinEtch({
className = "",
cellSize = 11,
ringDensity = 0.09,
ringWidth = 0.4,
polishRadius = 130,
polishStrength = 0.85,
glitchIntervalMs = 2600,
}: BurinEtchProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let fg = getComputedStyle(canvas).color;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let dpr = 1;
let balls: Ball[] = [];
let polish = new Float32Array(0);
let glitchBands = new Map<number, number>();
let nextGlitchAt = 0;
let glitchUntil = 0;
let raf = 0;
const pointer = { x: -1e4, y: -1e4, active: false };
const resize = () => {
const rect = container.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = Math.max(1, rect.width);
height = Math.max(1, rect.height);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = `${Math.round(cellSize * 0.92)}px "GeistMono", ui-monospace, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
cols = Math.ceil(width / cellSize);
rows = Math.ceil(height / cellSize);
polish = new Float32Array(cols * rows);
balls = makeBalls(width, height);
};
const updateGlitch = (now: number) => {
if (!glitchIntervalMs || now < nextGlitchAt) return;
glitchBands = new Map();
const bandCount = 1 + Math.floor(Math.random() * 2);
for (let b = 0; b < bandCount; b++) {
const row = Math.floor(Math.random() * rows);
const offset = (Math.random() - 0.5) * cellSize * 6;
glitchBands.set(row, offset);
}
glitchUntil = now + 90 + Math.random() * 110;
nextGlitchAt = now + glitchIntervalMs * (0.7 + Math.random() * 0.6);
};
const draw = (now: number, live: boolean) => {
if (width === 0 || height === 0) return;
const t = live ? now * 0.001 : 0;
const bx: number[] = [];
const by: number[] = [];
for (const ball of balls) {
bx.push(ball.baseX + Math.cos(t * ball.speed + ball.phase) * ball.orbitR);
by.push(ball.baseY + Math.sin(t * ball.speed * 1.3 + ball.phase) * ball.orbitR);
}
if (live) {
updateGlitch(now);
if (now > glitchUntil) glitchBands.clear();
}
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fg;
const rPolish2 = polishRadius * polishRadius;
for (let row = 0; row < rows; row++) {
const rowOffset = glitchBands.get(row) ?? 0;
for (let col = 0; col < cols; col++) {
const px = col * cellSize + cellSize / 2;
const py = row * cellSize + cellSize / 2;
const idx = row * cols + col;
let pol = live ? polish[idx] * 0.965 : 0;
if (live && pointer.active) {
const dxp = px - pointer.x;
const dyp = py - pointer.y;
const d2p = dxp * dxp + dyp * dyp;
if (d2p < rPolish2) {
const boost = 1 - Math.sqrt(d2p) / polishRadius;
if (boost > pol) pol = boost;
}
}
if (live) polish[idx] = pol;
let v = 0;
for (let b = 0; b < balls.length; b++) {
const dx = px - bx[b]!;
const dy = py - by[b]!;
const r = balls[b]!.r;
v += (r * r) / (dx * dx + dy * dy + 1);
}
const edgeAlpha = smoothstep(0.16, 0.42, v);
if (edgeAlpha <= 0.01) continue;
const density = ringDensity * (1 + pol * polishStrength);
const cycle = v * density;
const phase = cycle - Math.floor(cycle);
if (phase > ringWidth) continue;
const ringT = 1 - phase / ringWidth;
const charIdx = Math.min(RAMP.length - 1, Math.floor(ringT * (RAMP.length - 1)));
const ch = RAMP[charIdx];
if (!ch || ch === " ") continue;
ctx.globalAlpha = edgeAlpha * (0.3 + 0.7 * ringT);
ctx.fillText(ch, px + rowOffset, py);
}
}
ctx.globalAlpha = 1;
};
const loop = (now: number) => {
draw(now, true);
raf = requestAnimationFrame(loop);
};
const onPointer = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
pointer.active = true;
};
const onLeave = () => {
pointer.active = false;
};
const onThemeChange = () => {
fg = getComputedStyle(canvas).color;
if (reduced) draw(0, false);
};
// ResizeObserver fires once asynchronously right after observe() even
// with no actual size change — canvas.width/height assignment in resize()
// clears the backing store and resets its transform, so the single
// reduced-motion draw() must be re-run after every resize, not just once
// up front, or that first async callback silently wipes the static frame.
const handleResize = () => {
resize();
if (reduced) draw(0, false);
};
handleResize();
const ro = new ResizeObserver(handleResize);
ro.observe(container);
const themeObserver = new MutationObserver(onThemeChange);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
if (!reduced) {
raf = requestAnimationFrame(loop);
canvas.addEventListener("pointermove", onPointer);
canvas.addEventListener("pointerleave", onLeave);
}
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
themeObserver.disconnect();
canvas.removeEventListener("pointermove", onPointer);
canvas.removeEventListener("pointerleave", onLeave);
};
}, [cellSize, ringDensity, ringWidth, polishRadius, polishStrength, glitchIntervalMs]);
return (
<div ref={containerRef} data-ascii-engraving-contour className={`relative h-full w-full overflow-hidden ${className}`}>
<canvas ref={canvasRef} aria-hidden className="block h-full w-full text-foreground" />
</div>
);
}