Glyph Field
ASCII Art Effect for React
Your mark drawn in type. A word, an SVG path or a photo is printed as a grid of monospace glyphs that run from bare paper through dots to a dense @. It prints left to right the first time it scrolls into view, and the pointer inks the glyphs it passes over in your accent, which then dry back to their resting weight.
- Canvas
- Hero
- Hover
- Text
Uses motion, clsx, tailwind-merge
<GlyphField />replays the demo when you let go.
Halcyon shares one build cache across your team and every coding agent, so the rebuild that took eleven minutes lands in four seconds.
import { GlyphField } from "@/components/ui/glyph-field";
export function GlyphFieldDemo() {
return (
<GlyphField
cellSize={12}
glyphClassName="text-zinc-400 dark:text-zinc-500"
accentClassName="text-violet-600 dark:text-violet-400"
canvasClassName="opacity-70 sm:opacity-100"
className="flex h-[var(--demo-height,36rem)] w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-zinc-950/10 bg-zinc-50 px-6 text-center text-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:text-white"
>
<a
href="#"
className="inline-flex items-center gap-2 rounded-full border border-zinc-950/10 bg-white/80 py-1 pr-3 pl-1 text-xs font-medium text-zinc-600 backdrop-blur-sm transition-colors hover:text-zinc-950 dark:border-white/10 dark:bg-zinc-900/80 dark:text-zinc-400 dark:hover:text-white"
>
<span className="rounded-full bg-violet-600 px-2 py-0.5 text-[11px] text-white dark:bg-violet-500">New</span>
Remote cache for coding agents
<svg viewBox="0 0 16 16" aria-hidden className="size-3">
<path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
<h2 className="mt-6 max-w-[16ch] text-4xl/[1.04] font-semibold tracking-[-0.035em] text-balance sm:text-6xl/[1.02]">
Every build your agents run, <span className="text-zinc-400 dark:text-zinc-500">already cached.</span>
</h2>
<p className="mt-5 max-w-md text-[15px]/6 text-pretty text-zinc-600 dark:text-zinc-400">
Halcyon shares one build cache across your team and every coding agent, so the rebuild that took
eleven minutes lands in four seconds.
</p>
<div className="mt-8 flex items-center gap-5">
<a
href="#"
className="text-sm font-medium text-zinc-700 underline-offset-4 transition-colors hover:text-zinc-950 hover:underline dark:text-zinc-300 dark:hover:text-white"
>
Read the docs
</a>
<a
href="#"
className="inline-flex h-10 items-center gap-1.5 rounded-full bg-zinc-950 px-5 text-sm font-medium text-white shadow-sm transition-transform active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
Start caching
<svg viewBox="0 0 16 16" aria-hidden className="size-3.5">
<path d="M3.5 8h9m-3.5-3.5L12.5 8 9 11.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
</div>
</GlyphField>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Glyph Field: Your mark drawn in type. A word, an SVG path or a photo is printed as a grid of monospace glyphs that run from bare paper through dots to a dense @. It prints left to right the first time it scrolls into view, and the pointer inks the glyphs it passes over in your accent, which then dry back to their resting weight. This demo, Orbit Hero: A dev tool's hero with no source set, so the field prints its lit orbit around the headline. Move across it and the glyphs ink violet and dry back. 1. Install it: npx shadcn@latest add @keyframeui/glyph-field-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/glyph-field-demo.json instead.) It adds the demo, components/glyph-field-demo.tsx, and the component, components/ui/glyph-field.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <GlyphField> on its own. 3. These props set its feel. Change them before you change the file: - cellSize (default 12): Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall; smaller cells keep more detail. - decode (default true): Print the mark left to right, with a band of flickering glyphs at the print head, the first time it scrolls into view. - drift (default true): Let a few glyphs slip a step lighter or heavier and back at idle, so the mark looks alive. - printDuration (default 0.75): How long the print head takes to cross the mark the first time it prints, in seconds. - dry (default 0.42): How quickly the ink dries back to the resting glyphs, in seconds. It's a time constant, so the ink is gone after about three times this. 4. Don't change, in components/ui/glyph-field.tsx: - Each glyph and color step is drawn once into an atlas, and a frame redraws only the cells whose glyph or shade changed, each with a single `drawImage`. - One rAF loop runs only while something moves (the print, the brush, wet ink) and stops off screen, on a hidden tab or once the ink has dried; drift runs on a slow interval instead. - The print runs once per mark, the first time it scrolls into view; a resize resets the grid without reprinting. - The brush lands where the pointer enters instead of gliding in, and stamps along its path so a fast stroke stays unbroken. - Under reduced motion it prints at once with no drift, the brush follows the pointer exactly, and ink dries on the short `dryReduced`. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/glyph-field.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/glyph-field.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/glyph-fieldInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/glyph-field.tsx"use client"; import * as React from "react"; import { useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - Each glyph and color step is drawn once into an atlas, and a frame redraws only the cells whose * glyph or shade changed, each with a single `drawImage`. * - One rAF loop runs only while something moves (the print, the brush, wet ink) and stops off * screen, on a hidden tab or once the ink has dried; drift runs on a slow interval instead. * - The print runs once per mark, the first time it scrolls into view; a resize resets the grid * without reprinting. * - The brush lands where the pointer enters instead of gliding in, and stamps along its path so a * fast stroke stays unbroken. * - Under reduced motion it prints at once with no drift, the brush follows the pointer exactly, * and ink dries on the short `dryReduced`. */ const TIMING = { /** * The print head, in seconds: the wait after it scrolls into view, crossing the mark (the * `printDuration` prop) and the flickering band at its edge. `fps` is the flicker's rate. */ print: { delay: 0.12, duration: 0.75, band: 0.28, fps: 30 }, /** The ink drying back, as a time constant in seconds (the `dry` prop). */ dry: 0.42, /** The same under reduced motion, in seconds. */ dryReduced: 0.1, /** The spring smoothing the pointer. */ brush: { stiffness: 320, damping: 34 }, /** Drift: ms between slips, and the range in seconds a slipped glyph holds. */ drift: { every: 140, hold: [0.6, 1.4] }, } as const; /* ───────────────────────────────────────────────────────── * GLYPH FIELD STORYBOARD * * rest your mark set in type. Every cell of a monospace * grid holds the glyph whose weight matches the shape * under it, from bare paper through . : - = + to a * dense @. A word, an SVG path or a photo; with none * of them, a lit orbit * print the first time it scrolls into view, a print head * runs left to right. Ahead of it the paper is blank; * in a 0.28s band at its edge each cell flickers * through random glyphs at 30fps; behind it every * cell has settled. 0.75s across, about 1.1s in all * ink the pointer is a wet brush, smoothed by a spring * (320 / 34). Cells under it gain weight (up to a full * @ on the shape, a faint mark on bare paper) and take * the accent color, then dry back to their resting * glyph over about 1.2s (time constant 0.42s) * touch a tap or a drag inks the same way; the page still * scrolls * drift at idle a few glyphs (0.4% of the mark every 140ms) * slip one step lighter or heavier for about a second, * like ink settling into paper * again a new word, path or photo prints again; a resize * only resets the grid, it doesn't reprint * sleep nothing runs off screen, on a hidden tab, or once * the ink has dried and the pointer is still * reduced printed at once and no drift; the brush follows the * pointer exactly and dries in about 0.3s * ───────────────────────────────────────────────────────── */ const GLYPH = { lineHeight: 1.2, // cell height, as a share of `cellSize`; the width is the font's own advance supersample: 3, // samples per cell side when the shape is read print: { scatter: 0.14, slant: 0.12 }, // share of the sweep, share of the height ink: { radius: 88, paper: 0.4, shape: 0.55, grain: 0.25 }, // px, weight gained on bare paper, on the shape (× its weight), brush edge noise drift: { share: 0.004 }, // share of the mark per slip alpha: [0.38, 1], // glyph opacity, from the lightest mark to the densest floor: 0.05, // weight under which a cell is bare paper shades: 6, // color steps from the glyph color to the accent maxDpr: 2, } as const; // The default shape: an orbit seen from a little above, lit from the upper left. const ORBIT = { margin: 0.94, // share of the box the outer orbit reaches, at `fit="contain"` squash: 0.6, // height of the ellipse, as a share of its width tilt: -0.22, // rad thin: 0.028, // band width at the back, share of the radius thick: 0.1, // and at the front front: 2.2, // rad, where the band is widest light: -2.3, // rad, where it's lit halo: 1.16, // a faint outer orbit, as a share of the radius haloWeight: 0.3, } as const; type Tone = "auto" | "dark" | "light"; type Fit = "contain" | "cover"; type Source = | { kind: "orbit" } | { kind: "text"; text: string; font: string } | { kind: "path"; d: string; box: [number, number, number, number] } | { kind: "image"; src: string }; type Options = { cellSize: number; glyphs: string; tone: Tone; fit: Fit; softness: number; radius: number; decode: boolean; drift: boolean; sweep: number; dry: number; reduced: boolean; }; const DEFAULT_GLYPHS = " .:-=+*#%@"; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); const smoothstep = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; /** A stable 0…1 value for a cell and a tick, so a flicker frame looks the same every time it's drawn. */ const hash = (i: number, tick: number) => (Math.imul(i ^ Math.imul(tick, 40503), 2654435761) >>> 0) / 4294967296; function sourceKey(s: Source) { switch (s.kind) { case "orbit": return "orbit"; case "text": return `text:${s.font}:${s.text}`; case "path": return `path:${s.box.join(",")}:${s.d}`; case "image": return `image:${s.src}`; } } /** Box blur along rows and columns, in place. Radii are in cells. */ function blur(values: Float32Array, cols: number, rows: number, rx: number, ry: number) { const tmp = new Float32Array(values.length); if (rx > 0) { for (let r = 0; r < rows; r++) { const o = r * cols; for (let c = 0; c < cols; c++) { let sum = 0; let n = 0; for (let k = Math.max(0, c - rx); k <= Math.min(cols - 1, c + rx); k++) { sum += values[o + k]; n++; } tmp[o + c] = sum / n; } } values.set(tmp); } if (ry > 0) { for (let c = 0; c < cols; c++) { for (let r = 0; r < rows; r++) { let sum = 0; let n = 0; for (let k = Math.max(0, r - ry); k <= Math.min(rows - 1, r + ry); k++) { sum += values[k * cols + c]; n++; } tmp[r * cols + c] = sum / n; } } values.set(tmp); } } function percentile(values: Float32Array, p: number) { const sorted = Float32Array.from(values).sort(); return sorted[Math.min(sorted.length - 1, Math.max(0, Math.round(p * (sorted.length - 1))))]; } function createField() { let root: HTMLElement | null = null; let canvas: HTMLCanvasElement | null = null; let ctx: CanvasRenderingContext2D | null = null; let probes: { glyph: HTMLElement; accent: HTMLElement } | null = null; let options: Options = { cellSize: 12, glyphs: DEFAULT_GLYPHS, tone: "auto", fit: "contain", softness: 0.4, radius: GLYPH.ink.radius, decode: true, drift: true, sweep: TIMING.print.duration, dry: TIMING.dry, reduced: false, }; let source: Source = { kind: "orbit" }; let image: HTMLImageElement | null = null; // loaded and ready to read let warned = false; // The grid, in device pixels. let dpr = 1; let width = 0; let height = 0; let cw = 1; let ch = 1; let cols = 0; let rows = 0; let ox = 0; let oy = 0; let count = 0; let weight = new Float32Array(0); // 0…1, the shape under each cell let ink = new Float32Array(0); // 0…1, wet ink on each cell let prevInk = new Float32Array(0); let grain = new Float32Array(0); // 0…1 per cell, a seeded random let settle = new Float32Array(0); // s after the print starts when a cell lands let drawn = new Int16Array(0); // atlas slot drawn in each cell, -1 for bare paper let slip = new Int8Array(0); // drift: -1, 0 or +1 glyph steps let marked = new Int32Array(0); // cells with a glyph at rest, for drift to pick from const slips: { i: number; until: number }[] = []; let atlas: HTMLCanvasElement | null = null; let colors: string[] = []; let printing = false; let printStart = 0; let printEnd = 0; let printed = false; // the print has run (or was skipped) for the current source let ready = false; // the source has been read into `weight` const brush = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, lx: 0, ly: 0, inside: false, down: false }; let raf = 0; let last = 0; let visible = false; let driftTimer = 0; const cleanups: (() => void)[] = []; const now = () => performance.now() / 1000; const glyphCount = () => options.glyphs.length; function readColors() { if (!probes) return; const glyph = getComputedStyle(probes.glyph).color; const accent = getComputedStyle(probes.accent).color; colors = []; for (let i = 0; i < GLYPH.shades; i++) { const share = Math.round((i / (GLYPH.shades - 1)) * 100); probes.glyph.style.color = `color-mix(in oklab, ${glyph}, ${accent} ${share}%)`; colors.push(getComputedStyle(probes.glyph).color); } probes.glyph.style.color = ""; } /** The glyph font comes from the canvas, which is `font-mono` unless `canvasClassName` says otherwise. */ function font(px: number) { const style = canvas ? getComputedStyle(canvas) : null; return `${style?.fontWeight ?? 400} ${px}px ${style?.fontFamily || "ui-monospace, monospace"}`; } /** One slot per glyph and color step, drawn once, so each cell is a single drawImage. */ function buildAtlas() { const n = glyphCount(); atlas ??= document.createElement("canvas"); atlas.width = n * cw; atlas.height = GLYPH.shades * ch; const a = atlas.getContext("2d"); if (!a) return; a.clearRect(0, 0, atlas.width, atlas.height); a.font = font(options.cellSize * dpr); a.textAlign = "center"; a.textBaseline = "middle"; for (let s = 0; s < GLYPH.shades; s++) { a.fillStyle = colors[s] ?? "#71717a"; for (let g = 1; g < n; g++) { a.globalAlpha = GLYPH.alpha[0] + ((GLYPH.alpha[1] - GLYPH.alpha[0]) * (g - 1)) / Math.max(1, n - 2); a.save(); a.beginPath(); a.rect(g * cw, s * ch, cw, ch); a.clip(); a.fillText(options.glyphs[g], g * cw + cw / 2, s * ch + ch / 2); a.restore(); } } } function layout() { if (!canvas || !ctx) return; const w = canvas.clientWidth; const h = canvas.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, GLYPH.maxDpr); width = Math.round(w * dpr); height = Math.round(h * dpr); canvas.width = width; canvas.height = height; ctx.font = font(options.cellSize * dpr); cw = Math.max(1, Math.round(ctx.measureText("M").width)); ch = Math.max(1, Math.round(options.cellSize * GLYPH.lineHeight * dpr)); cols = Math.max(0, Math.floor(width / cw)); rows = Math.max(0, Math.floor(height / ch)); ox = Math.floor((width - cols * cw) / 2); oy = Math.floor((height - rows * ch) / 2); if (cols * rows !== count) { count = cols * rows; weight = new Float32Array(count); ink = new Float32Array(count); prevInk = new Float32Array(count); settle = new Float32Array(count); drawn = new Int16Array(count); slip = new Int8Array(count); slips.length = 0; grain = new Float32Array(count); // Seeded, so the brush's grain and the print's scatter are the same on every render. let seed = 48271; for (let i = 0; i < count; i++) grain[i] = (seed = (seed * 16807) % 2147483647) / 2147483647; } read(); buildAtlas(); repaint(); } /** Reads the source into one weight per cell. */ function read() { ready = false; if (!count) return; const k = GLYPH.supersample; const gw = (cols * cw) / dpr; // the grid, in CSS px const gh = (rows * ch) / dpr; const cx = gw / 2; const cy = gh / 2; weight.fill(0); if (source.kind === "orbit") { // Stood on end in a tall box, so it frames the content on a phone too. const tilt = gh > gw * 1.15 ? Math.PI / 2 + ORBIT.tilt : ORBIT.tilt; const cos = Math.cos(tilt); const sin = Math.sin(tilt); // Half the width and height of the tilted orbit at R = 1, out to the halo's edge. const outer = ORBIT.halo + ORBIT.thin; const ex = Math.hypot(cos, ORBIT.squash * sin) * outer; const ey = Math.hypot(sin, ORBIT.squash * cos) * outer; const R = (options.fit === "cover" ? Math.max : Math.min)((gw * ORBIT.margin) / 2 / ex, (gh * ORBIT.margin) / 2 / ey); const a = R; const b = R * ORBIT.squash; const band = (e: number, theta: number, ring: number, thinness: number) => { // Distance from the ellipse, roughly in px: how far off the ring the point sits, times the radius along its ray. const d = Math.abs(e - ring) * Math.hypot(a * Math.cos(theta), b * Math.sin(theta)); const half = R * thinness; return 1 - smoothstep(half * 0.25, half, d); }; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const x = ((c + 0.5) * cw) / dpr - cx; const y = ((r + 0.5) * ch) / dpr - cy; const u = x * cos + y * sin; const v = -x * sin + y * cos; const e = Math.hypot(u / a, v / b); const theta = Math.atan2(v / b, u / a); const front = 0.5 + 0.5 * Math.cos(theta - ORBIT.front); const lit = 0.5 + 0.5 * Math.cos(theta - ORBIT.light); const main = band(e, theta, 1, ORBIT.thin + (ORBIT.thick - ORBIT.thin) * front * front) * (0.3 + 0.7 * lit); const halo = band(e, theta, ORBIT.halo, ORBIT.thin * 0.6) * ORBIT.haloWeight * (0.4 + 0.6 * (1 - lit)); weight[r * cols + c] = Math.max(main, halo); } } finish(false); return; } if (source.kind === "image" && !image) return; const sample = document.createElement("canvas"); sample.width = cols * k; sample.height = rows * k; const s = sample.getContext("2d", { willReadFrequently: true }); if (!s) return; // Draw in CSS px; the transform maps them onto k × k samples per cell. const kx = (k * dpr) / cw; const ky = (k * dpr) / ch; const place = (w: number, h: number, pad: number) => { const scale = options.fit === "cover" ? Math.max(gw / w, gh / h) : Math.min((gw * pad) / w, (gh * pad) / h); return { scale, x: cx - (w * scale) / 2, y: cy - (h * scale) / 2 }; }; if (source.kind === "text") { const lines = source.text.split("\n"); const base = 100; s.font = `800 ${base}px ${source.font}`; const metrics = lines.map((line) => s.measureText(line)); const ascent = Math.max(...metrics.map((m) => m.actualBoundingBoxAscent)); const descent = Math.max(...metrics.map((m) => m.actualBoundingBoxDescent)); const lineHeight = (ascent + descent) * 1.1; const w = Math.max(...metrics.map((m) => m.width)); const h = ascent + descent + lineHeight * (lines.length - 1); const p = place(w, h, 0.9); s.setTransform(kx * p.scale, 0, 0, ky * p.scale, kx * p.x, ky * p.y); s.fillStyle = "#fff"; s.textBaseline = "alphabetic"; lines.forEach((line, i) => s.fillText(line, (w - metrics[i].width) / 2, ascent + i * lineHeight)); } else if (source.kind === "path") { const [minX, minY, w, h] = source.box; const p = place(w, h, 0.9); s.setTransform(kx * p.scale, 0, 0, ky * p.scale, kx * (p.x - minX * p.scale), ky * (p.y - minY * p.scale)); s.fillStyle = "#fff"; s.fill(new Path2D(source.d)); } else if (image) { const w = image.naturalWidth; const h = image.naturalHeight; const p = place(w, h, 1); s.setTransform(kx * p.scale, 0, 0, ky * p.scale, kx * p.x, ky * p.y); s.drawImage(image, 0, 0, w, h); } let data: Uint8ClampedArray; try { data = s.getImageData(0, 0, sample.width, sample.height).data; } catch { // A cross-origin image without CORS headers taints the canvas: print nothing rather than throw. if (!warned) console.warn("GlyphField: the image can't be read. Serve it with CORS headers (Access-Control-Allow-Origin)."); warned = true; finish(false); return; } const lum = new Float32Array(count); const cover = new Float32Array(count); const stride = cols * k; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { let l = 0; let a = 0; for (let sy = 0; sy < k; sy++) { for (let sx = 0; sx < k; sx++) { const p = ((r * k + sy) * stride + c * k + sx) * 4; const alpha = data[p + 3] / 255; a += alpha; l += ((0.2126 * data[p] + 0.7152 * data[p + 1] + 0.0722 * data[p + 2]) / 255) * alpha; } } const i = r * cols + c; cover[i] = a / (k * k); lum[i] = a > 0 ? l / a : 0; } } if (source.kind !== "image") { weight.set(cover); finish(true); return; } // A photo: which tones print heavy. `auto` reads the backdrop from the photo's edges (their median, // so a shoulder touching the bottom doesn't skew it) and prints whatever differs from it: dark tones // on a light backdrop, light tones on a dark one. The backdrop stays paper in either color scheme. const rim: number[] = []; let edges = 0; for (let i = 0; i < count; i++) { const c = i % cols; const r = (i / cols) | 0; if (r === 0 || c === 0 || r === rows - 1 || c === cols - 1) { edges++; if (cover[i] >= 0.5) rim.push(lum[i]); } } const transparent = rim.length < edges / 2; // a cut-out: print its shape rim.sort((x, y) => x - y); const edge = rim.length ? rim[rim.length >> 1] : 1; const tone = options.tone !== "auto" ? options.tone : edge > 0.6 ? "dark" : edge < 0.4 ? "light" : "away"; const raw = new Float32Array(count); for (let i = 0; i < count; i++) { const l = lum[i]; const toned = transparent ? 1 : tone === "dark" ? 1 - l : tone === "light" ? l : Math.abs(l - edge); raw[i] = toned * cover[i]; } // The backdrop's own tone maps to bare paper, so a near-white wall doesn't print as a field of dots. const backdrop = transparent ? 0 : tone === "dark" ? 1 - edge : tone === "light" ? edge : 0; const lo = Math.max(percentile(raw, 0.02), backdrop + 0.04); const hi = percentile(raw, 0.985); for (let i = 0; i < count; i++) weight[i] = hi > lo ? Math.pow(clamp01((raw[i] - lo) / (hi - lo)), 0.85) : 0; // Sharpen: at a few glyphs per feature, eyes and edges need the extra contrast to read. const soft = Float32Array.from(weight); blur(soft, cols, rows, 2, 1); for (let i = 0; i < count; i++) weight[i] = clamp01(weight[i] + (weight[i] - soft[i]) * 0.9); finish(false); } /** Softens a hard-edged shape so it shades from dense to dots, then lists the marked cells. */ function finish(soften: boolean) { if (soften && options.softness > 0) { const rx = Math.round(options.softness * 4); const ry = Math.round((options.softness * 4 * cw) / ch); blur(weight, cols, rows, rx, ry); blur(weight, cols, rows, rx, ry); // Light from the upper left: the side of each stroke facing it runs heavier, the far side lighter. const lit = Float32Array.from(weight); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const i = r * cols + c; const w = lit[i]; if (w <= 0) continue; const gx = (c > 0 ? lit[i - 1] : w) - (c < cols - 1 ? lit[i + 1] : w); const gy = (r > 0 ? lit[i - cols] : w) - (r < rows - 1 ? lit[i + cols] : w); weight[i] = clamp01(w * 1.15 + (gx * 0.6 + gy * 0.9) * 0.9 - (1 - w) * 0.08); } } } let n = 0; for (let i = 0; i < count; i++) { if (weight[i] < GLYPH.floor) weight[i] = 0; else n++; } marked = new Int32Array(n); for (let i = 0, j = 0; i < count; i++) if (weight[i] > 0) marked[j++] = i; for (const s of slips) slip[s.i] = 0; slips.length = 0; ready = true; } /** Clears the canvas and lets the next frame draw every cell. */ function repaint() { if (!ctx) return; ctx.clearRect(0, 0, width, height); drawn.fill(-1); wake(); } function startPrint() { if (!ready || printed || !visible) return; printed = true; if (!options.decode || options.reduced) { printing = false; wake(); return; } const { delay, band } = TIMING.print; const { scatter, slant } = GLYPH.print; const { sweep } = options; let end = 0; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const i = r * cols + c; const order = (c / Math.max(1, cols - 1)) * (1 - slant) + (r / Math.max(1, rows - 1)) * slant; settle[i] = delay + band + (order * (1 - scatter) + grain[i] * scatter) * sweep; end = Math.max(end, settle[i]); } } printing = true; printStart = now(); printEnd = printStart + end; wake(); } function stamp(x: number, y: number) { const R = options.radius; const cwc = cw / dpr; const chc = ch / dpr; const oxc = ox / dpr; const oyc = oy / dpr; const c0 = Math.max(0, Math.floor((x - R - oxc) / cwc)); const c1 = Math.min(cols - 1, Math.floor((x + R - oxc) / cwc)); const r0 = Math.max(0, Math.floor((y - R - oyc) / chc)); const r1 = Math.min(rows - 1, Math.floor((y + R - oyc) / chc)); const R2 = R * R; for (let r = r0; r <= r1; r++) { const dy = oyc + (r + 0.5) * chc - y; for (let c = c0; c <= c1; c++) { const dx = oxc + (c + 0.5) * cwc - x; const d2 = dx * dx + dy * dy; if (d2 >= R2) continue; const i = r * cols + c; const q = 1 - d2 / R2; const f = q * q * (1 - GLYPH.ink.grain + GLYPH.ink.grain * grain[i]); if (f > ink[i]) ink[i] = f; } } } function frame() { raf = 0; if (!ctx || !atlas || !count) return; const t = now(); const dt = Math.min(last ? t - last : 1 / 60, 1 / 20); last = t; let busy = false; const { reduced, radius } = options; // The brush: a spring toward the pointer, stamping along the way so a fast stroke stays unbroken. if (brush.inside && radius > 0) { if (reduced) { brush.x = brush.tx; brush.y = brush.ty; } else { const { stiffness, damping } = TIMING.brush; brush.vx += (stiffness * (brush.tx - brush.x) - damping * brush.vx) * dt; brush.vy += (stiffness * (brush.ty - brush.y) - damping * brush.vy) * dt; brush.x += brush.vx * dt; brush.y += brush.vy * dt; if (Math.hypot(brush.tx - brush.x, brush.ty - brush.y) < 0.3 && Math.hypot(brush.vx, brush.vy) < 6) { brush.x = brush.tx; brush.y = brush.ty; brush.vx = brush.vy = 0; } else busy = true; } } prevInk.set(ink); const dry = Math.exp(-dt / (reduced ? TIMING.dryReduced : options.dry)); for (let i = 0; i < count; i++) { if (ink[i] > 0) ink[i] = ink[i] * dry < 0.02 ? 0 : ink[i] * dry; } if (brush.inside && radius > 0) { const steps = Math.max(1, Math.ceil(Math.hypot(brush.x - brush.lx, brush.y - brush.ly) / (radius * 0.3))); for (let s = 1; s <= steps; s++) { stamp(brush.lx + ((brush.x - brush.lx) * s) / steps, brush.ly + ((brush.y - brush.ly) * s) / steps); } brush.lx = brush.x; brush.ly = brush.y; } const n = glyphCount(); const top = n - 1; const shades = GLYPH.shades; const elapsed = t - printStart; const tick = Math.floor(elapsed * TIMING.print.fps); const { band } = TIMING.print; const { paper, shape } = GLYPH.ink; if (printing && t >= printEnd) printing = false; if (printing) busy = true; for (let i = 0; i < count; i++) { const w = ink[i]; if (w !== prevInk[i]) busy = true; let base = ready && printed ? weight[i] : 0; let flicker = false; if (printing && base > 0) { const s = settle[i]; if (elapsed < s - band) base = 0; else if (elapsed < s) flicker = true; } let g: number; if (flicker) { g = 1 + Math.floor(hash(i, tick) * Math.min(top, Math.round(base * top) + 3)); if (g > top) g = top; } else { g = Math.round(Math.min(1, base + w * (paper + shape * base)) * top); if (slip[i] && base > 0) g = Math.min(top, Math.max(1, g + slip[i])); } const key = g <= 0 ? -1 : g * shades + (w > 0 ? Math.min(shades - 1, Math.round(w * (shades - 1))) : 0); if (key === drawn[i]) continue; const x = ox + (i % cols) * cw; const y = oy + ((i / cols) | 0) * ch; ctx.clearRect(x, y, cw, ch); if (key >= 0) { const slot = key % shades; ctx.drawImage(atlas, ((key - slot) / shades) * cw, slot * ch, cw, ch, x, y, cw, ch); } drawn[i] = key; } if (busy && visible && !document.hidden) raf = requestAnimationFrame(frame); else last = 0; } function wake() { if (raf || !visible || !ctx || document.hidden) return; raf = requestAnimationFrame(frame); } /** Drift runs on a slow timer, not every frame: a handful of cells slip a step and back. */ function syncDrift() { const want = visible && !document.hidden && options.drift && !options.reduced && marked.length > 0; if (want && !driftTimer) { driftTimer = window.setInterval(() => { if (printing || !printed) return; const t = now(); for (let j = slips.length - 1; j >= 0; j--) { if (slips[j].until < t) { slip[slips[j].i] = 0; slips.splice(j, 1); } } const [a, b] = TIMING.drift.hold; const picks = Math.max(1, Math.round(marked.length * GLYPH.drift.share)); for (let p = 0; p < picks; p++) { const i = marked[(Math.random() * marked.length) | 0]; if (slip[i]) continue; slip[i] = Math.random() < 0.5 ? -1 : 1; slips.push({ i, until: t + a + Math.random() * (b - a) }); } wake(); }, TIMING.drift.every); } else if (!want && driftTimer) { clearInterval(driftTimer); driftTimer = 0; for (const s of slips) slip[s.i] = 0; slips.length = 0; wake(); } } function loadImage(src: string) { image = null; const img = new Image(); img.crossOrigin = "anonymous"; img.decoding = "async"; img.onload = () => { if (source.kind !== "image" || source.src !== src) return; image = img; read(); repaint(); startPrint(); syncDrift(); }; img.src = src; } return { connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; glyph: HTMLElement; accent: HTMLElement }) { root = el.root; canvas = el.canvas; ctx = canvas.getContext("2d"); probes = { glyph: el.glyph, accent: el.accent }; readColors(); layout(); const resize = new ResizeObserver(() => { if (!canvas) return; const w = Math.round(canvas.clientWidth * Math.min(window.devicePixelRatio || 1, GLYPH.maxDpr)); const h = Math.round(canvas.clientHeight * Math.min(window.devicePixelRatio || 1, GLYPH.maxDpr)); if (w === width && h === height) return; layout(); syncDrift(); }); resize.observe(canvas); const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible) { startPrint(); wake(); } syncDrift(); }); io.observe(canvas); // The glyph width comes from the font: measure again once web fonts have loaded. document.fonts?.ready.then(() => { if (canvas) layout(); }); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); const recolor = () => { readColors(); buildAtlas(); repaint(); }; scheme.addEventListener("change", recolor); const themeAttrs = new MutationObserver(recolor); themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] }); const pageVisibility = () => { if (!document.hidden) wake(); syncDrift(); }; document.addEventListener("visibilitychange", pageVisibility); const point = (e: PointerEvent) => { if (!canvas) return; const r = canvas.getBoundingClientRect(); brush.tx = e.clientX - r.left; brush.ty = e.clientY - r.top; if (!brush.inside) { // Land where the pointer is, not glide in from the last place it left. brush.x = brush.lx = brush.tx; brush.y = brush.ly = brush.ty; brush.vx = brush.vy = 0; brush.inside = true; } wake(); }; const move = (e: PointerEvent) => { if (e.pointerType === "touch" && !brush.down) return; point(e); }; const down = (e: PointerEvent) => { if (e.pointerType === "touch") brush.down = true; point(e); }; const up = (e: PointerEvent) => { if (e.pointerType !== "touch") return; brush.down = false; brush.inside = false; }; const leave = () => { brush.down = false; brush.inside = false; }; root.addEventListener("pointermove", move); root.addEventListener("pointerdown", down); root.addEventListener("pointerup", up); root.addEventListener("pointerleave", leave); root.addEventListener("pointercancel", leave); cleanups.push( () => resize.disconnect(), () => io.disconnect(), () => scheme.removeEventListener("change", recolor), () => themeAttrs.disconnect(), () => document.removeEventListener("visibilitychange", pageVisibility), () => { root?.removeEventListener("pointermove", move); root?.removeEventListener("pointerdown", down); root?.removeEventListener("pointerup", up); root?.removeEventListener("pointerleave", leave); root?.removeEventListener("pointercancel", leave); }, ); return () => { cancelAnimationFrame(raf); raf = 0; clearInterval(driftTimer); driftTimer = 0; visible = false; cleanups.splice(0).forEach((fn) => fn()); root = canvas = null; ctx = null; probes = null; }; }, setOptions(next: Options) { const prev = options; options = next; if (!ctx) return; if (next.reduced && printing) printing = false; if (next.cellSize !== prev.cellSize || next.glyphs !== prev.glyphs) layout(); else if (next.fit !== prev.fit || next.tone !== prev.tone || next.softness !== prev.softness) { read(); repaint(); } syncDrift(); wake(); }, setSource(next: Source) { if (sourceKey(next) === sourceKey(source)) return; source = next; // A new mark prints again; one that hasn't printed yet just waits for its turn. printed = false; printing = false; if (next.kind === "image") loadImage(next.src); else image = null; if (!ctx) return; read(); repaint(); startPrint(); syncDrift(); }, }; } type Field = ReturnType<typeof createField>; function parseViewBox(viewBox: string): [number, number, number, number] { const n = viewBox.split(/[\s,]+/).map(Number); return n.length === 4 && n.every(Number.isFinite) && n[2] > 0 && n[3] > 0 ? [n[0], n[1], n[2], n[3]] : [0, 0, 24, 24]; } export interface GlyphFieldProps extends React.HTMLAttributes<HTMLDivElement> { /** A word or short line to print. Use `\n` for a second line. */ text?: string; /** Font family `text` is set in, at weight 800, before it's turned into glyphs. */ textFont?: string; /** An SVG path (`d`) to print, such as your logo mark. Read against `viewBox`. */ path?: string; /** The coordinate box `path` is drawn in, like an SVG's `viewBox`. */ viewBox?: string; /** An image to print, mapped by tone. It must be served with CORS headers (Unsplash is). */ src?: string; /** For `src`: which tones print heavy. `auto` reads the backdrop from the photo's edges and prints what differs from it (dark tones on a light backdrop, light on a dark one), so the subject comes up as ink and the backdrop stays paper. */ tone?: Tone; /** `contain` fits the whole mark inside the field; `cover` fills the field and crops. */ fit?: Fit; /** Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall. */ cellSize?: number; /** The glyphs from lightest to densest. Start with a space so the paper stays bare. */ glyphs?: string; /** For `text` and `path`: how far the edges shade off into dots, from 0 (hard) to 1 (soft). */ softness?: number; /** The pointer inks the glyphs it passes over, which then dry back. A number sets the brush radius in px. */ ink?: boolean | number; /** Print the mark left to right, with a band of flickering glyphs, the first time it scrolls into view. */ decode?: boolean; /** Let a few glyphs slip a step and back at idle, so the mark looks alive. */ drift?: boolean; /** Seconds the print head takes to cross the mark when it prints. */ printDuration?: number; /** How quickly the ink dries back, in seconds: a time constant, so it's gone after about three times this. */ dry?: number; /** Describes the mark for screen readers. Without it, the field is decorative. */ label?: string; /** A `text-*` color for the glyphs. The canvas reads it, so `dark:` variants work. */ glyphClassName?: string; /** A `text-*` color the ink takes. */ accentClassName?: string; /** Classes for the canvas. It fills the field by default; position it to print the mark in one part only. Its font (`font-mono` by default) is the glyphs' font. */ canvasClassName?: string; } export function GlyphField({ text, textFont = "ui-sans-serif, system-ui, sans-serif", path, viewBox = "0 0 24 24", src, tone = "auto", fit = "contain", cellSize = 12, glyphs = DEFAULT_GLYPHS, softness = 0.4, ink = true, decode = true, drift = true, printDuration = TIMING.print.duration, dry = TIMING.dry, label, glyphClassName, accentClassName, canvasClassName, className, children, ...props }: GlyphFieldProps) { const rootRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); const glyphRef = React.useRef<HTMLSpanElement>(null); const accentRef = React.useRef<HTMLSpanElement>(null); const [field] = React.useState<Field>(createField); const reduced = useReducedMotion() ?? false; React.useLayoutEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const glyph = glyphRef.current; const accent = accentRef.current; if (!root || !canvas || !glyph || !accent) return; return field.connect({ root, canvas, glyph, accent }); }, [field]); const radius = ink === true ? GLYPH.ink.radius : ink === false ? 0 : Math.max(0, ink); const ramp = glyphs.length >= 2 ? glyphs : DEFAULT_GLYPHS; React.useEffect(() => { field.setOptions({ cellSize: Math.max(4, cellSize), glyphs: ramp, tone, fit, softness: clamp01(softness), radius, decode, drift, sweep: Math.max(0, printDuration), dry: Math.max(0.01, dry), reduced, }); }, [field, cellSize, ramp, tone, fit, softness, radius, decode, drift, printDuration, dry, reduced]); React.useEffect(() => { if (text) field.setSource({ kind: "text", text, font: textFont }); else if (path) field.setSource({ kind: "path", d: path, box: parseViewBox(viewBox) }); else if (src) field.setSource({ kind: "image", src }); else field.setSource({ kind: "orbit" }); }, [field, text, textFont, path, viewBox, src]); return ( <div ref={rootRef} className={cn("relative isolate", className)} {...props}> <canvas ref={canvasRef} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true} className={cn("pointer-events-none absolute inset-0 -z-10 size-full font-mono", canvasClassName)} /> {/* Probes the canvas reads its colors from. Defaults apply only when no class is given, so a `dark:` default can't win over yours. */} <span ref={glyphRef} aria-hidden className={cn("hidden", glyphClassName ?? "text-zinc-500 dark:text-zinc-400")} /> <span ref={accentRef} aria-hidden className={cn("hidden", accentClassName ?? "text-violet-600 dark:text-violet-400")} /> {children} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Glyph Field. Each one installs whole, with the component included.
Not Found
A docs site's 404 with the number printed from `text`, soft edges shading off into dots, and a way back to the page that moved.
GET /docs/webhooks/retries → 404
Retries moved into the delivery guide in Tinta 3.2. The link you followed points at the old address.
import { GlyphField } from "@/components/ui/glyph-field";
export function GlyphField404Demo() {
return (
<div className="flex h-[var(--demo-height,32rem)] w-full flex-col items-center justify-center overflow-hidden rounded-2xl border border-zinc-950/10 bg-white px-6 text-center text-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:text-white">
<GlyphField
text="404"
cellSize={9}
softness={0.55}
label="404"
glyphClassName="text-zinc-900 dark:text-zinc-100"
accentClassName="text-emerald-600 dark:text-emerald-400"
className="h-44 w-full max-w-xl sm:h-56"
/>
<p className="mt-6 font-mono text-xs text-zinc-500 dark:text-zinc-400">
GET /docs/webhooks/retries <span className="text-zinc-300 dark:text-zinc-600">→</span> 404
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.025em] text-balance sm:text-3xl">
This page was never printed
</h2>
<p className="mt-2 max-w-sm text-sm/6 text-pretty text-zinc-600 dark:text-zinc-400">
Retries moved into the delivery guide in Tinta 3.2. The link you followed points at the old
address.
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
<a
href="#"
className="inline-flex h-10 items-center rounded-full bg-zinc-950 px-5 whitespace-nowrap text-sm font-medium text-white transition-transform active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
Open the delivery guide
</a>
<a
href="#"
className="inline-flex h-10 items-center rounded-full px-4 whitespace-nowrap text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
>
Search the docs
</a>
</div>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Glyph Field: Your mark drawn in type. A word, an SVG path or a photo is printed as a grid of monospace glyphs that run from bare paper through dots to a dense @. It prints left to right the first time it scrolls into view, and the pointer inks the glyphs it passes over in your accent, which then dry back to their resting weight. This demo, Not Found: A docs site's 404 with the number printed from `text`, soft edges shading off into dots, and a way back to the page that moved. 1. Install it: npx shadcn@latest add @keyframeui/glyph-field-404-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/glyph-field-404-demo.json instead.) It adds the demo, components/glyph-field-404-demo.tsx, and the component, components/ui/glyph-field.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <GlyphField> on its own. 3. These props set its feel. Change them before you change the file: - cellSize (default 12): Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall; smaller cells keep more detail. - decode (default true): Print the mark left to right, with a band of flickering glyphs at the print head, the first time it scrolls into view. - drift (default true): Let a few glyphs slip a step lighter or heavier and back at idle, so the mark looks alive. - printDuration (default 0.75): How long the print head takes to cross the mark the first time it prints, in seconds. - dry (default 0.42): How quickly the ink dries back to the resting glyphs, in seconds. It's a time constant, so the ink is gone after about three times this. 4. Don't change, in components/ui/glyph-field.tsx: - Each glyph and color step is drawn once into an atlas, and a frame redraws only the cells whose glyph or shade changed, each with a single `drawImage`. - One rAF loop runs only while something moves (the print, the brush, wet ink) and stops off screen, on a hidden tab or once the ink has dried; drift runs on a slow interval instead. - The print runs once per mark, the first time it scrolls into view; a resize resets the grid without reprinting. - The brush lands where the pointer enters instead of gliding in, and stamps along its path so a fast stroke stays unbroken. - Under reduced motion it prints at once with no drift, the brush follows the pointer exactly, and ink dries on the short `dryReduced`. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/glyph-field.md
Studio Team
A studio's team cards with each portrait printed from a photo. `tone="auto"` keeps the white backdrops as paper in both color schemes, and the brush inks orange.
Field & Fold · Lisbon
- Ines Duarte
Creative director
Twelve years of identities, two of them for orchestras.
- Jonah Pike
Motion lead
Builds every brand a way to move before it gets a logo.
- Mara Lindqvist
Type and editorial
Draws the custom faces. Owns the studio's only Linotype.
import { GlyphField } from "@/components/ui/glyph-field";
const photo = (id: string) => `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=600&h=750&q=80`;
const team = [
{
name: "Ines Duarte",
role: "Creative director",
note: "Twelve years of identities, two of them for orchestras.",
photo: "1765005204058-10418f5123c5",
},
{
name: "Jonah Pike",
role: "Motion lead",
note: "Builds every brand a way to move before it gets a logo.",
photo: "1791573454618-67d47fca4dc8",
},
{
name: "Mara Lindqvist",
role: "Type and editorial",
note: "Draws the custom faces. Owns the studio's only Linotype.",
photo: "1580489944761-15a19d654956",
},
];
export function GlyphFieldPortraitDemo() {
return (
<section className="@container w-full max-w-4xl rounded-2xl border border-stone-900/10 bg-stone-50 p-5 text-stone-900 sm:p-8 dark:border-white/10 dark:bg-stone-950 dark:text-stone-100">
<div className="flex flex-wrap items-end justify-between gap-3">
<h2 className="font-serif text-3xl tracking-[-0.02em] sm:text-4xl">The studio, in type</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">Field & Fold · Lisbon</p>
</div>
<ul className="mt-6 grid grid-cols-1 gap-4 @md:grid-cols-3">
{team.map((person) => (
<li key={person.name} className="group rounded-xl border border-stone-900/10 bg-white p-2 dark:border-white/10 dark:bg-stone-900">
<GlyphField
src={photo(person.photo)}
fit="cover"
cellSize={8}
glyphs=" .,:;i1tfLCG08@"
ink={64}
label={`Portrait of ${person.name}, printed in type`}
glyphClassName="text-stone-900 dark:text-stone-200"
accentClassName="text-orange-600 dark:text-orange-400"
className="aspect-[4/5] w-full overflow-hidden rounded-lg bg-stone-100 dark:bg-stone-950"
/>
<div className="px-2 pt-3 pb-2">
<div className="flex items-baseline justify-between gap-2">
<h3 className="font-medium">{person.name}</h3>
<p className="text-xs text-stone-500 dark:text-stone-400">{person.role}</p>
</div>
<p className="mt-1 text-sm/5 text-pretty text-stone-600 dark:text-stone-400">{person.note}</p>
</div>
</li>
))}
</ul>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Glyph Field: Your mark drawn in type. A word, an SVG path or a photo is printed as a grid of monospace glyphs that run from bare paper through dots to a dense @. It prints left to right the first time it scrolls into view, and the pointer inks the glyphs it passes over in your accent, which then dry back to their resting weight. This demo, Studio Team: A studio's team cards with each portrait printed from a photo. `tone="auto"` keeps the white backdrops as paper in both color schemes, and the brush inks orange. 1. Install it: npx shadcn@latest add @keyframeui/glyph-field-portrait-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/glyph-field-portrait-demo.json instead.) It adds the demo, components/glyph-field-portrait-demo.tsx, and the component, components/ui/glyph-field.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <GlyphField> on its own. 3. These props set its feel. Change them before you change the file: - cellSize (default 12): Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall; smaller cells keep more detail. - decode (default true): Print the mark left to right, with a band of flickering glyphs at the print head, the first time it scrolls into view. - drift (default true): Let a few glyphs slip a step lighter or heavier and back at idle, so the mark looks alive. - printDuration (default 0.75): How long the print head takes to cross the mark the first time it prints, in seconds. - dry (default 0.42): How quickly the ink dries back to the resting glyphs, in seconds. It's a time constant, so the ink is gone after about three times this. 4. Don't change, in components/ui/glyph-field.tsx: - Each glyph and color step is drawn once into an atlas, and a frame redraws only the cells whose glyph or shade changed, each with a single `drawImage`. - One rAF loop runs only while something moves (the print, the brush, wet ink) and stops off screen, on a hidden tab or once the ink has dried; drift runs on a slow interval instead. - The print runs once per mark, the first time it scrolls into view; a resize resets the grid without reprinting. - The brush lands where the pointer enters instead of gliding in, and stamps along its path so a fast stroke stays unbroken. - Under reduced motion it prints at once with no drift, the brush follows the pointer exactly, and ink dries on the short `dryReduced`. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/glyph-field.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | None | A word or short line to print, set at weight 800. Use \n for a second line. |
textFont | string | "ui-sans-serif, system-ui, sans-serif" | Font family text is set in before it's turned into glyphs. |
path | string | None | An SVG path (d) to print, such as your logo mark. Read against viewBox. |
viewBox | string | "0 0 24 24" | The coordinate box path is drawn in, like an SVG's viewBox. |
src | string | None | An image to print, mapped by tone. It must be served with CORS headers (Unsplash is). With no text, path or src, it prints a lit orbit. |
tone | "auto" | "dark" | "light" | "auto" | For src: which tones print heavy. auto reads the backdrop from the photo's edges and prints what differs from it (dark tones on a light backdrop, light tones on a dark one), so the subject comes up as ink and the backdrop stays paper in either color scheme. |
fit | "contain" | "cover" | "contain" | contain fits the whole mark inside the field; cover fills the field and crops. |
cellSize | number | 12 | Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall; smaller cells keep more detail. |
glyphs | string | " .:-=+*#%@" | The glyphs from lightest to densest. Start with a space so the paper stays bare. |
softness | number | 0.4 | For text and path: how far the edges shade off into dots, from 0 (hard) to 1 (soft). |
ink | boolean | number | true | The pointer inks the glyphs it passes over, which dry back over about a second. A number sets the brush radius in px (88 by default). Touch inks on tap and drag. |
decode | boolean | true | Print the mark left to right, with a band of flickering glyphs at the print head, the first time it scrolls into view. |
drift | boolean | true | Let a few glyphs slip a step lighter or heavier and back at idle, so the mark looks alive. |
printDuration | number | 0.75 | How long the print head takes to cross the mark the first time it prints, in seconds. |
dry | number | 0.42 | How quickly the ink dries back to the resting glyphs, in seconds. It's a time constant, so the ink is gone after about three times this. |
label | string | None | Describes the mark for screen readers. Without it, the field is decorative and hidden from them. |
glyphClassName | string | "text-zinc-500 dark:text-zinc-400" | A text-* color for the glyphs. The canvas reads it, so dark: variants work; lighter glyphs are drawn fainter. |
accentClassName | string | "text-violet-600 dark:text-violet-400" | A text-* color the ink takes. |
canvasClassName | string | None | Classes for the canvas. It fills the field by default; position it to print the mark in one part only. Its font (font-mono) is the glyphs' font. |
children | React.ReactNode | None | Content drawn over the field, like a headline. The pointer still inks through it. |
className | string | None | Classes for the field. Give it a size (a height, an aspect ratio, or absolute inset-0 inside a section). |
textType
stringA word or short line to print, set at weight 800. Use
\nfor a second line.textFontType
stringDefault"ui-sans-serif, system-ui, sans-serif"Font family
textis set in before it's turned into glyphs.pathType
stringAn SVG path (
d) to print, such as your logo mark. Read againstviewBox.viewBoxType
stringDefault"0 0 24 24"The coordinate box
pathis drawn in, like an SVG'sviewBox.srcType
stringAn image to print, mapped by tone. It must be served with CORS headers (Unsplash is). With no
text,pathorsrc, it prints a lit orbit.toneType
"auto" | "dark" | "light"Default"auto"For
src: which tones print heavy.autoreads the backdrop from the photo's edges and prints what differs from it (dark tones on a light backdrop, light tones on a dark one), so the subject comes up as ink and the backdrop stays paper in either color scheme.fitType
"contain" | "cover"Default"contain"containfits the whole mark inside the field;coverfills the field and crops.cellSizeType
numberDefault12Font size of the glyphs, in px. Each cell is one glyph wide and 1.2× this tall; smaller cells keep more detail.
glyphsType
stringDefault" .:-=+*#%@"The glyphs from lightest to densest. Start with a space so the paper stays bare.
softnessType
numberDefault0.4For
textandpath: how far the edges shade off into dots, from0(hard) to1(soft).inkType
boolean | numberDefaulttrueThe pointer inks the glyphs it passes over, which dry back over about a second. A number sets the brush radius in px (88 by default). Touch inks on tap and drag.
decodeType
booleanDefaulttruePrint the mark left to right, with a band of flickering glyphs at the print head, the first time it scrolls into view.
driftType
booleanDefaulttrueLet a few glyphs slip a step lighter or heavier and back at idle, so the mark looks alive.
printDurationType
numberDefault0.75How long the print head takes to cross the mark the first time it prints, in seconds.
dryType
numberDefault0.42How quickly the ink dries back to the resting glyphs, in seconds. It's a time constant, so the ink is gone after about three times this.
labelType
stringDescribes the mark for screen readers. Without it, the field is decorative and hidden from them.
glyphClassNameType
stringDefault"text-zinc-500 dark:text-zinc-400"A
text-*color for the glyphs. The canvas reads it, sodark:variants work; lighter glyphs are drawn fainter.accentClassNameType
stringDefault"text-violet-600 dark:text-violet-400"A
text-*color the ink takes.canvasClassNameType
stringClasses for the canvas. It fills the field by default; position it to print the mark in one part only. Its font (
font-mono) is the glyphs' font.childrenType
React.ReactNodeContent drawn over the field, like a headline. The pointer still inks through it.
classNameType
stringClasses for the field. Give it a size (a height, an aspect ratio, or
absolute inset-0inside a section).