Studio Pieces & Glyph Artv0.4.0New
Glyph Map Card
An image redrawn as a living glyph map that shifts under the cursor.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/glyph-map-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/glyph/canvas-loop.ts/** * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame. * Framework-free: start it in an effect and dispose it in the cleanup. Browser only. */ export interface CanvasLoopFrame { /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */ dt: number; /** Seconds the loop has been running. */ time: number; /** True under reduced motion: draw a calm, finished-looking frame. */ still: boolean; /** Host size in CSS pixels. */ width: number; height: number; /** Device pixel ratio of the canvas (capped). */ dpr: number; } export interface CanvasLoopOptions { /** Element observed for size and visibility. */ host: HTMLElement; /** Canvas sized to the host times the pixel ratio. */ canvas: HTMLCanvasElement; /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */ render: (frame: CanvasLoopFrame) => void; /** Called after the canvas is resized, before it is redrawn. */ resize?: (frame: CanvasLoopFrame) => void; /** Read before every frame; while it returns true the loop stops and the last frame stays. */ isPaused?: () => boolean; /** Highest device pixel ratio used. @default 2 */ maxDpr?: number; /** Margin around the viewport that still counts as on screen. @default "120px" */ rootMargin?: string; } export interface CanvasLoop { /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */ wake(): void; /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */ redraw(): void; /** Stops the loop and removes every observer and listener. */ dispose(): void; } export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop { const { host, canvas, rootMargin = "120px" } = options; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let width = 0; let height = 0; let dpr = 1; let raf = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr }); const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.(); const tick = (now: number) => { raf = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; options.render(frame(dt)); raf = requestAnimationFrame(tick); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } return; } if (raf) cancelAnimationFrame(raf); raf = 0; }; const redraw = () => { if (!disposed && !raf && width > 0) options.render(frame(0)); }; const resize = () => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } options.resize?.(frame(0)); // Resizing clears the canvas, so repaint right away even while running. options.render(frame(0)); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin }, ); const onVisibility = () => wake(); const onMotionChange = () => { wake(); redraw(); }; resize(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", onMotionChange); return { wake, redraw, dispose() { disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0; resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", onMotionChange); }, }; }lib/glyph/glyph-atlas.ts/** * Glyph sprite atlases: every glyph of a ramp is rendered once per color into one canvas, and * cells are blitted from it with `drawImage`. Drawing text per cell per frame (`fillText`) is far * slower, so glyph canvases never do it. Browser only. */ export interface GlyphAtlasOptions { /** Glyphs to pre-render, e.g. `rampGlyphs("classic")`. Spaces are kept as empty sprites. */ glyphs: readonly string[]; /** One tint per color, as any color a canvas accepts. Resolve CSS variables first with `resolveCanvasColors`. */ colors: readonly string[]; /** Size of one square cell in CSS pixels. */ cell: number; /** Device pixel ratio to render for, capped at 2. @default 1 */ dpr?: number; /** Font stack. @default Geist Mono (`--font-geist-mono`), then system monospace */ fontFamily?: string; /** Font weight. @default 500 */ fontWeight?: number | string; /** Glyph size as a share of the cell. @default 0.8 */ fontScale?: number; /** Largest scale a sprite will be drawn at, so enlarged glyphs stay sharp. @default 1 */ maxScale?: number; } export interface GlyphAtlas { readonly canvas: HTMLCanvasElement; readonly glyphs: readonly string[]; readonly colors: readonly string[]; /** Cell size in CSS pixels. */ readonly cell: number; /** Index of a glyph in the atlas, or -1. */ index(glyph: string): number; /** * Blits a glyph in a color with its cell's top-left corner at (x, y), in the context's current * units (CSS pixels when the context is scaled by the device pixel ratio). `scale` grows it * around the cell center. Set `globalAlpha` on the context for faded glyphs. */ draw(ctx: CanvasRenderingContext2D, glyph: number, color: number, x: number, y: number, scale?: number): void; } const FALLBACK_MONO = 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Roboto Mono", monospace'; const SHADES: Record<string, number> = { "░": 0.25, "▒": 0.5, "▓": 0.75, "█": 1 }; const GUTTER = 1; const MAX_CACHED = 16; const cache = new Map<string, GlyphAtlas>(); /** Geist Mono as set up by the site (`--font-geist-mono`), followed by system monospace fonts. */ export function monoFontFamily(element: Element = document.documentElement): string { const geist = getComputedStyle(element).getPropertyValue("--font-geist-mono").trim(); return geist ? `${geist}, ${FALLBACK_MONO}` : FALLBACK_MONO; } /** * Turns CSS colors a canvas can't parse (`var(--primary)`, `color-mix(…)`, `currentColor`) into * computed `rgb()` strings by styling a hidden probe inside `element`. Plain colors pass through. */ export function resolveCanvasColors(colors: readonly string[], element: Element): string[] { let probe: HTMLSpanElement | null = null; const out = colors.map((color) => { if (!/var\(|color-mix\(|currentcolor/i.test(color)) return color; if (!probe) { probe = document.createElement("span"); probe.style.display = "none"; element.appendChild(probe); } probe.style.color = ""; probe.style.color = color; return getComputedStyle(probe).color || color; }); (probe as HTMLSpanElement | null)?.remove(); return out; } const fontSpec = (options: GlyphAtlasOptions, size: number) => `${options.fontWeight ?? 500} ${size}px ${options.fontFamily ?? FALLBACK_MONO}`; /** Resolves once the atlas font is loaded (or failed to load), so atlases built after it use the real face. */ export async function loadGlyphFont(options: Pick<GlyphAtlasOptions, "glyphs" | "fontFamily" | "fontWeight">): Promise<void> { try { await document.fonts?.load(fontSpec({ ...options, colors: [], cell: 16 }, 16), options.glyphs.join("").replace(/\s/g, "") || "0"); } catch { // A missing font falls back to the system monospace face. } } function createAtlas(options: GlyphAtlasOptions): GlyphAtlas { const { glyphs, colors, cell } = options; const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const sprite = Math.max(2, Math.ceil(cell * dpr * Math.max(1, options.maxScale ?? 1))); const stride = sprite + GUTTER * 2; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, glyphs.length * stride); canvas.height = Math.max(1, colors.length * stride); const ctx = canvas.getContext("2d"); if (ctx) { ctx.font = fontSpec(options, Math.round(sprite * (options.fontScale ?? 0.8))); ctx.textAlign = "center"; ctx.textBaseline = "middle"; colors.forEach((color, row) => { ctx.fillStyle = color; glyphs.forEach((glyph, col) => { const x = col * stride + GUTTER; const y = row * stride + GUTTER; const shade = SHADES[glyph]; if (shade === 1) ctx.fillRect(x, y, sprite, sprite); else if (shade) { // Shade blocks as crisp dot screens rather than whatever the font draws. const dots = 4; const size = sprite / dots; for (let dy = 0; dy < dots; dy++) { for (let dx = 0; dx < dots; dx++) { const on = shade === 0.5 ? (dx + dy) % 2 === 0 : shade === 0.25 ? dx % 2 === 0 && dy % 2 === 0 : !(dx % 2 === 1 && dy % 2 === 1); if (on) ctx.fillRect(x + dx * size, y + dy * size, Math.ceil(size), Math.ceil(size)); } } } else if (glyph.trim()) ctx.fillText(glyph, x + sprite / 2, y + sprite / 2 + sprite * 0.04); }); }); } const lookup = new Map(glyphs.map((g, i) => [g, i])); return { canvas, glyphs, colors, cell, index: (glyph) => lookup.get(glyph) ?? -1, draw(target, glyph, color, x, y, scale = 1) { if (glyph < 0 || color < 0 || glyph >= glyphs.length || color >= colors.length) return; const size = cell * scale; const offset = (size - cell) / 2; target.drawImage(canvas, glyph * stride + GUTTER, color * stride + GUTTER, sprite, sprite, x - offset, y - offset, size, size); }, }; } /** * Returns a glyph atlas for these options, building it on first use. Atlases are shared between * components with identical options. Once the font has loaded, the next call builds a fresh atlas * with the real face instead of reusing one drawn with the fallback. */ export function getGlyphAtlas(options: GlyphAtlasOptions): GlyphAtlas { const dpr = Math.min(2, Math.max(1, options.dpr ?? 1)); const fontReady = typeof document === "undefined" || !document.fonts ? true : document.fonts.check(fontSpec(options, 16)); const key = [options.glyphs.join(""), options.colors.join("|"), options.cell, dpr, options.fontFamily, options.fontWeight, options.fontScale, options.maxScale, fontReady].join("§"); const hit = cache.get(key); if (hit) { cache.delete(key); cache.set(key, hit); return hit; } const atlas = createAtlas({ ...options, dpr }); cache.set(key, atlas); while (cache.size > MAX_CACHED) cache.delete(cache.keys().next().value as string); return atlas; }lib/glyph/ramp.ts/** * Glyph ramps: characters ordered from sparse (light, index 0) to dense (dark, last index). * Index 0 is usually a space, so the brightest areas of an image stay empty. */ export const GLYPH_RAMPS = { /** The classic ASCII ramp. */ classic: " .:-=+*#%@", /** Binary digits only: empty, then 1, then 0 for the darkest areas. */ binary: " 10", /** Shade blocks. */ blocks: " ░▒▓█", /** Dots that grow. */ dots: " ·•●", } as const; export type GlyphRampName = keyof typeof GLYPH_RAMPS; /** A built-in ramp name or your own string of glyphs, sparse to dense. */ export type GlyphRamp = GlyphRampName | (string & {}); /** The glyphs of a ramp as an array (handles characters outside the BMP). Unknown names are used as literal glyphs. */ export function rampGlyphs(ramp: GlyphRamp): string[] { const source = ramp in GLYPH_RAMPS ? GLYPH_RAMPS[ramp as GlyphRampName] : ramp; const glyphs = Array.from(source); return glyphs.length ? glyphs : Array.from(GLYPH_RAMPS.classic); } /** * Index of the glyph for a luminance (0 = black, 1 = white) in a ramp of `count` glyphs. * Dark values pick dense glyphs (high indices), bright values sparse ones. */ export function glyphIndex(luminance: number, count: number): number { const darkness = 1 - Math.min(1, Math.max(0, luminance)); return Math.min(count - 1, Math.floor(darkness * count)); } /** The glyph for a luminance (0 = black, 1 = white): dense glyphs for dark areas, sparse ones for bright areas. */ export function pickGlyph(luminance: number, ramp: GlyphRamp = "classic"): string { const glyphs = rampGlyphs(ramp); return glyphs[glyphIndex(luminance, glyphs.length)]; }lib/glyph/sample-image.ts/** * Image sampling for glyph art: an image is downsampled so each glyph cell gets one pixel, and * each cell's luminance and alpha are read back. Browser only. */ /** One value per glyph cell, row by row. */ export interface GlyphSample { cols: number; rows: number; /** Perceived brightness per cell, 0 (black) to 1 (white). */ luminance: Float32Array; /** Opacity per cell, 0 (transparent, or outside the image under `contain`) to 1. */ alpha: Float32Array; } /** How the image maps onto the grid: crop to fill it, fit inside it, or stretch. */ export type SampleFit = "cover" | "contain" | "fill"; const MAX_CACHED = 48; const images = new Map<string, Promise<HTMLImageElement>>(); const samples = new Map<string, Promise<GlyphSample>>(); /** Loads an image once per `src` and resolves when it can be drawn. */ export function loadImage(src: string): Promise<HTMLImageElement> { let pending = images.get(src); if (!pending) { pending = new Promise<HTMLImageElement>((resolve, reject) => { const image = new Image(); image.decoding = "async"; // Same-origin images are unaffected; cross-origin ones must send CORS headers to be readable. image.crossOrigin = "anonymous"; image.onload = () => resolve(image); image.onerror = () => reject(new Error(`Could not load image: ${src}`)); image.src = src; }); pending.catch(() => images.delete(src)); images.set(src, pending); } return pending; } function makeCanvas(width: number, height: number) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) throw new Error("2D canvas is unavailable"); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "high"; return { canvas, ctx }; } /** * Samples an already loaded source (image, canvas, video frame) into a `cols × rows` grid. * Large sources are halved step by step first, so every cell averages its whole area. */ export function sampleSource( source: CanvasImageSource, sourceWidth: number, sourceHeight: number, cols: number, rows: number, fit: SampleFit = "cover", ): GlyphSample { const width = Math.max(1, Math.round(cols)); const height = Math.max(1, Math.round(rows)); const sourceAspect = sourceWidth / Math.max(1, sourceHeight); const gridAspect = width / height; // Source crop (cover) and destination rect (contain) in grid cells. let sx = 0; let sy = 0; let sw = sourceWidth; let sh = sourceHeight; let dx = 0; let dy = 0; let dw = width; let dh = height; if (fit === "cover") { if (sourceAspect > gridAspect) { sw = sourceHeight * gridAspect; sx = (sourceWidth - sw) / 2; } else { sh = sourceWidth / gridAspect; sy = (sourceHeight - sh) / 2; } } else if (fit === "contain") { if (sourceAspect > gridAspect) { dh = Math.max(1, Math.round(width / sourceAspect)); dy = Math.floor((height - dh) / 2); } else { dw = Math.max(1, Math.round(height * sourceAspect)); dx = Math.floor((width - dw) / 2); } } let current: CanvasImageSource = source; while (sw > dw * 2 && sh > dh * 2) { const nw = Math.max(dw, Math.ceil(sw / 2)); const nh = Math.max(dh, Math.ceil(sh / 2)); const step = makeCanvas(nw, nh); step.ctx.drawImage(current, sx, sy, sw, sh, 0, 0, nw, nh); current = step.canvas; sx = 0; sy = 0; sw = nw; sh = nh; } const out = makeCanvas(width, height); out.ctx.drawImage(current, sx, sy, sw, sh, dx, dy, dw, dh); const { data } = out.ctx.getImageData(0, 0, width, height); const luminance = new Float32Array(width * height); const alpha = new Float32Array(width * height); for (let i = 0; i < width * height; i++) { const o = i * 4; luminance[i] = (0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2]) / 255; alpha[i] = data[o + 3] / 255; } return { cols: width, rows: height, luminance, alpha }; } /** * Loads `src` and samples it into a `cols × rows` grid (one pixel per glyph cell). Results are * cached per source and grid size, so cards that share an image and cell size sample it once. */ export function sampleImage(src: string, cols: number, rows: number, fit: SampleFit = "cover"): Promise<GlyphSample> { const key = `${src}|${Math.round(cols)}x${Math.round(rows)}|${fit}`; const cached = samples.get(key); if (cached) { // Refresh its place in the LRU order. samples.delete(key); samples.set(key, cached); return cached; } const pending = loadImage(src).then((image) => sampleSource(image, image.naturalWidth, image.naturalHeight, cols, rows, fit)); pending.catch(() => samples.delete(key)); samples.set(key, pending); while (samples.size > MAX_CACHED) samples.delete(samples.keys().next().value as string); return pending; }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/glyph-map-card/glyph-map-card.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Dialog } from "radix-ui"; import { Check, MapPin, Maximize2, Plus, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop"; import { getGlyphAtlas, loadGlyphFont, monoFontFamily, type GlyphAtlas } from "@/lib/glyph/glyph-atlas"; import { rampGlyphs } from "@/lib/glyph/ramp"; import { sampleImage, sampleSource, type GlyphSample } from "@/lib/glyph/sample-image"; export type GlyphMapTone = "green" | "indigo" | "ember" | "mono"; export type GlyphMapRamp = "classic" | "binary" | "blocks"; export interface GlyphMapWatcher { /** Used for the avatar's alt text and initials. */ name: string; /** Avatar image. Without it, initials are shown. */ image?: string; } export interface GlyphMapCardProps extends Omit<ComponentProps<"div">, "title" | "children"> { /** Image redrawn as glyphs (any size; it is cropped to fill). Without it, a built-in sphere study is used. */ image?: string; /** Describes the image for screen readers; also the default caption in the expanded view. */ imageAlt?: string; /** Card title, set in a serif display face. @default "Quiet Orbit" */ title?: string; /** Short description under the title. */ description?: string; /** Small label at the top of the colored panel. @default "Stock Card" */ label?: string; /** Place shown with a pin icon. */ location?: string; /** Avatars of people watching (up to 3 are shown) and the total count. */ watchers?: { people: GlyphMapWatcher[]; total: number }; /** Color of the glyphs and the panel. @default "indigo" */ tone?: GlyphMapTone; /** Glyph ramp: classic ASCII, binary digits or shade blocks. @default "classic" */ ramp?: GlyphMapRamp; /** Glyph cell size in px. @default 9 */ cell?: number; /** Controlled "added" state of the Add card button. */ added?: boolean; /** Initial "added" state when uncontrolled. @default false */ defaultAdded?: boolean; /** Called with the new state when Add card is pressed. */ onAdd?: (added: boolean) => void; /** Called when the expanded view opens or closes. */ onExpand?: (expanded: boolean) => void; /** Caption under the glyph map in the expanded view. @default imageAlt */ caption?: string; } interface ToneColors { ground: string; ink: string; soft: string; hot: string; panel: string; panelFg: string; panelMuted: string; } const TONES: Record<GlyphMapTone, ToneColors> = { green: { ground: "#eaf5ee", ink: "#17663f", soft: "#86bf9c", hot: "#1f9d55", panel: "#17663f", panelFg: "#f2fbf5", panelMuted: "#cfe9da" }, indigo: { ground: "#edf0ff", ink: "#2b3fcf", soft: "#9ba7f3", hot: "#4d68ff", panel: "#2b3fcf", panelFg: "#f5f6ff", panelMuted: "#d3d9ff" }, ember: { ground: "#fff1e8", ink: "#a3320a", soft: "#f0a47e", hot: "#ee6316", panel: "#a3320a", panelFg: "#fff7f2", panelMuted: "#ffd9c4" }, mono: { ground: "#f4f4f5", ink: "#18181b", soft: "#a1a1aa", hot: "#52525b", panel: "#18181b", panelFg: "#fafafa", panelMuted: "#d4d4d8" }, }; const SERIF = "[font-family:var(--font-geist-serif),ui-serif,Georgia,'Times_New_Roman',serif]"; const MORPH = { type: "spring", stiffness: 300, damping: 32 } as const; /* ------------------------------------------------------------------ default art */ let defaultArt: HTMLCanvasElement | null = null; /** A shaded sphere with a ring and a moon, drawn once: the image used when none is given. */ function getDefaultArt() { if (defaultArt) return defaultArt; const canvas = document.createElement("canvas"); canvas.width = 500; canvas.height = 400; const ctx = canvas.getContext("2d"); if (ctx) { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, 500, 400); const ring = (front: boolean) => { ctx.save(); ctx.translate(250, 205); ctx.rotate(-0.32); ctx.beginPath(); ctx.ellipse(0, 0, 205, 52, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2); ctx.lineWidth = 22; ctx.strokeStyle = front ? "#3a3a3a" : "#8a8a8a"; ctx.stroke(); ctx.restore(); }; ring(false); // Mostly mid to dark so the whole sphere fills with glyphs, lighter toward the top left. const sphere = ctx.createRadialGradient(205, 160, 10, 240, 200, 150); sphere.addColorStop(0, "#9c9c9c"); sphere.addColorStop(0.5, "#4c4c4c"); sphere.addColorStop(1, "#0d0d0d"); ctx.fillStyle = sphere; ctx.beginPath(); ctx.arc(250, 205, 122, 0, Math.PI * 2); ctx.fill(); ring(true); const moon = ctx.createRadialGradient(410, 80, 2, 420, 90, 34); moon.addColorStop(0, "#d8d8d8"); moon.addColorStop(1, "#262626"); ctx.fillStyle = moon; ctx.beginPath(); ctx.arc(420, 90, 30, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#555555"; for (const [x, y, r] of [[60, 70, 4], [110, 320, 3], [440, 300, 5], [80, 220, 2.5], [330, 40, 3]]) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } } defaultArt = canvas; return canvas; } /* ------------------------------------------------------------------ glyph field */ const hash = (n: number) => { let h = Math.imul(n | 0, 2654435761) ^ 0x9e3779b9; h = Math.imul(h ^ (h >>> 15), 2246822507); return ((h ^ (h >>> 13)) >>> 0) / 4294967296; }; interface GlyphFieldProps { image?: string; colors: ToneColors; ramp: GlyphMapRamp; cell: number; className?: string; style?: CSSProperties; } /** * The live glyph map: one canvas, glyphs blitted from a shared atlas. Cells re-roll toward * neighboring densities over time; near the pointer they heat up, scramble, grow and push * outward, then cool and settle back. */ function GlyphField({ image, colors, ramp, cell, className, style }: GlyphFieldProps) { const hostRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const { ink, soft, hot } = colors; useEffect(() => { const host = hostRef.current; const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!host || !canvas || !ctx) return; const glyphs = rampGlyphs(ramp); const count = glyphs.length; const fontFamily = monoFontFamily(host); let atlas: GlyphAtlas | null = null; let sample: GlyphSample | null = null; let cols = 0; let rows = 0; let ox = 0; let oy = 0; let dpr = 1; let base = new Uint8Array(0); let current = new Uint8Array(0); let heat = new Float32Array(0); let timer = new Float32Array(0); let alpha = new Float32Array(0); let lastDraw = -1; let rolls = 0; let disposed = false; let loop: CanvasLoop | null = null; const pointer = { x: 0, y: 0, tx: 0, ty: 0, strength: 0, inside: false }; const buildAtlas = () => { atlas = getGlyphAtlas({ glyphs, colors: [ink, soft, hot], cell, dpr, fontFamily, fontWeight: 500, fontScale: 0.92, maxScale: 1.6 }); }; /** Darkness per cell from the sample, with a little contrast and a floor so pale ground stays empty. */ const applySample = (next: GlyphSample) => { if (next.cols !== cols || next.rows !== rows) return; sample = next; for (let i = 0; i < cols * rows; i++) { const dark = Math.min(1, Math.max(0, ((1 - next.luminance[i] - 0.5) * 1.15 + 0.5 - 0.1) / 0.9)); const level = next.alpha[i] < 0.05 ? 0 : Math.min(count - 1, Math.floor(dark * count)); base[i] = level; current[i] = level; alpha[i] = next.alpha[i]; timer[i] = 0.5 + hash(i * 7 + 3) * 4; } lastDraw = -1; // During the first resize the loop isn't assigned yet; it renders right after anyway. loop?.redraw(); canvas.style.opacity = "1"; }; const resample = () => { if (!cols || !rows) return; if (!image) return applySample(sampleSource(getDefaultArt(), 500, 400, cols, rows, "cover")); const want = { cols, rows }; sampleImage(image, cols, rows, "cover") .then((next) => { if (!disposed && want.cols === cols && want.rows === rows) applySample(next); }) .catch(() => { // An image that fails to load leaves the tinted ground on its own. }); }; const reroll = (i: number) => { rolls++; const b = base[i]; const r = hash(i * 131 + rolls * 977); if (heat[i] > 0.3) { // Scrambling: any non-empty glyph. current[i] = 1 + Math.floor(r * (count - 1)); return; } if (b === 0) { // Pale cells mostly stay empty, with the odd faint sparkle (rarer when the lightest glyph is heavy). current[i] = r < (count > 3 ? 0.035 : 0.012) ? 1 : 0; return; } // Drift to a neighboring density, leaning back toward the true one. current[i] = r < 0.55 ? b : Math.max(1, Math.min(count - 1, b + (r < 0.78 ? -1 : 1))); }; const draw = () => { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); if (!atlas || !sample) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.imageSmoothingEnabled = true; const radius = Math.max(56, cell * 7); let faded = false; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { const i = y * cols + x; const g = current[i]; if (g === 0) continue; const h = heat[i]; let px = ox + x * cell; let py = oy + y * cell; if (h > 0.01) { const dx = px + cell / 2 - pointer.x; const dy = py + cell / 2 - pointer.y; const d = Math.hypot(dx, dy) || 1; const push = h * cell * 1.9 * Math.min(1, d / (radius * 0.3)); px += (dx / d) * push; py += (dy / d) * push; } const a = alpha[i]; if (a < 0.98) { ctx.globalAlpha = a; faded = true; } else if (faded) { ctx.globalAlpha = 1; faded = false; } const color = h > 0.45 ? 2 : g >= count / 2 ? 0 : 1; atlas.draw(ctx, g, color, px, py, 1 + h * 0.55); } } ctx.globalAlpha = 1; }; loop = runCanvasLoop({ host, canvas, resize: (frame) => { dpr = frame.dpr; buildAtlas(); const nextCols = Math.max(1, Math.floor(frame.width / cell)); const nextRows = Math.max(1, Math.floor(frame.height / cell)); ox = (frame.width - nextCols * cell) / 2; oy = (frame.height - nextRows * cell) / 2; if (nextCols !== cols || nextRows !== rows) { cols = nextCols; rows = nextRows; const n = cols * rows; base = new Uint8Array(n); current = new Uint8Array(n); heat = new Float32Array(n); timer = new Float32Array(n); alpha = new Float32Array(n); sample = null; resample(); } }, render: ({ dt, time, still }) => { if (still || !sample) { // Reduced motion (or nothing to animate yet): the true glyphs, still. if (sample) { current.set(base); heat.fill(0); } draw(); return; } const k = 1 - Math.exp(-dt * 10); pointer.x += (pointer.tx - pointer.x) * k; pointer.y += (pointer.ty - pointer.y) * k; pointer.strength += ((pointer.inside ? 1 : 0) - pointer.strength) * (1 - Math.exp(-dt * (pointer.inside ? 8 : 2.5))); const radius = Math.max(56, cell * 7); const cool = Math.exp(-dt * 2.2); let hottest = 0; for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { const i = y * cols + x; let target = 0; if (pointer.strength > 0.01) { const d = Math.hypot(ox + x * cell + cell / 2 - pointer.x, oy + y * cell + cell / 2 - pointer.y); if (d < radius) target = pointer.strength * (1 - d / radius) ** 2; } heat[i] = target > heat[i] ? heat[i] + (target - heat[i]) * Math.min(1, dt * 14) : heat[i] * cool; if (heat[i] < 0.002) heat[i] = 0; hottest = Math.max(hottest, heat[i]); timer[i] -= dt * (1 + heat[i] * 40); if (timer[i] <= 0) { reroll(i); timer[i] = heat[i] > 0.3 ? 0.04 + hash(i + rolls) * 0.08 : 1.4 + hash(i * 3 + rolls) * 3.6; } } } // Idle shimmer only needs ~16 fps; the pointer gets every frame. if (hottest < 0.01 && lastDraw >= 0 && time - lastDraw < 1 / 16) return; lastDraw = time; draw(); }, }); const local = (event: PointerEvent) => { const rect = host.getBoundingClientRect(); // Divide by the rendered scale so pointer math holds inside scaled (animating) parents. const sx = rect.width / (host.clientWidth || 1) || 1; const sy = rect.height / (host.clientHeight || 1) || 1; return { x: (event.clientX - rect.left) / sx, y: (event.clientY - rect.top) / sy }; }; const onMove = (event: PointerEvent) => { const p = local(event); if (!pointer.inside && pointer.strength < 0.05) { pointer.x = p.x; pointer.y = p.y; } pointer.tx = p.x; pointer.ty = p.y; pointer.inside = true; loop?.wake(); }; const onLeave = () => { pointer.inside = false; }; host.addEventListener("pointermove", onMove); host.addEventListener("pointerdown", onMove); host.addEventListener("pointerleave", onLeave); host.addEventListener("pointercancel", onLeave); // Rebuild the atlas with Geist Mono once it has loaded. loadGlyphFont({ glyphs, fontFamily }).then(() => { if (disposed) return; buildAtlas(); lastDraw = -1; loop?.redraw(); }); return () => { disposed = true; loop?.dispose(); host.removeEventListener("pointermove", onMove); host.removeEventListener("pointerdown", onMove); host.removeEventListener("pointerleave", onLeave); host.removeEventListener("pointercancel", onLeave); }; }, [image, ramp, cell, ink, soft, hot]); return ( <div ref={hostRef} className={cn("relative overflow-hidden", className)} style={{ background: colors.ground, ...style }}> <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 size-full opacity-0 transition-opacity duration-500" /> </div> ); } /* ------------------------------------------------------------------ pieces */ const initials = (name: string) => name .split(/\s+/) .map((w) => w[0]) .slice(0, 2) .join("") .toUpperCase(); function Watchers({ watchers, colors }: { watchers: NonNullable<GlyphMapCardProps["watchers"]>; colors: ToneColors }) { const shown = watchers.people.slice(0, 3); const more = Math.max(0, watchers.total - shown.length); return ( <div className="flex min-w-0 items-center gap-2.5"> <div className="flex shrink-0 -space-x-2"> {shown.map((person) => person.image ? ( // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic <img key={person.name} src={person.image} alt={person.name} className="size-6 rounded-full object-cover ring-2" style={{ ["--tw-ring-color" as string]: colors.panel }} /> ) : ( <span key={person.name} role="img" aria-label={person.name} className="flex size-6 items-center justify-center rounded-full text-[9px] font-semibold ring-2" style={{ background: colors.panelMuted, color: colors.panel, ["--tw-ring-color" as string]: colors.panel }} > {initials(person.name)} </span> ), )} </div> <p className="truncate text-[12.5px]" style={{ color: colors.panelMuted }}> {more > 0 ? ( <> +{more} {/* The long form only where it fits beside the Expand button. */} <span className="hidden @[21.75rem]/card:inline"> more are</span> watching </> ) : ( `${watchers.total} watching` )} </p> </div> ); } function AddButton({ added, onToggle, colors, className }: { added: boolean; onToggle: () => void; colors: ToneColors; className?: string }) { const reduceMotion = useReducedMotion(); return ( <button type="button" aria-pressed={added} onClick={onToggle} className={cn( "inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium outline-none transition-[transform,background-color] active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2", className, )} style={{ background: colors.panelFg, color: colors.panel, ["--tw-ring-offset-color" as string]: colors.panel }} > <span className="relative inline-flex size-3.5 items-center justify-center" aria-hidden="true"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={added ? "check" : "plus"} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: added ? -90 : 90 }} animate={{ opacity: 1, scale: 1, rotate: 0 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.4, rotate: added ? 90 : -90 }} transition={{ duration: duration.quick, ease: ease.out }} className="absolute inset-0 flex items-center justify-center" > {added ? <Check className="size-3.5" strokeWidth={2.5} /> : <Plus className="size-3.5" strokeWidth={2.5} />} </motion.span> </AnimatePresence> </span> {added ? "Added" : "Add card"} </button> ); } /* ------------------------------------------------------------------ card */ /** * An image redrawn as a living glyph map in a rounded card: glyphs gently re-roll, and near the * pointer they scramble, grow and push outward before settling. Below sits a colored panel with a * label, an Add card toggle, a serif title, description, location, watchers and an Expand button * that opens a larger view. */ export function GlyphMapCard({ image, imageAlt, title = "Quiet Orbit", description = "A shaded sphere and its ring, redrawn in glyphs that shift as you move.", label = "Stock Card", location, watchers, tone = "indigo", ramp = "classic", cell = 9, added, defaultAdded = false, onAdd, onExpand, caption, className, style, ...props }: GlyphMapCardProps) { const reduceMotion = useReducedMotion(); const id = useId(); const colors = TONES[tone] ?? TONES.indigo; const [addedState, setAddedState] = useState(defaultAdded); const isAdded = added ?? addedState; const [open, setOpen] = useState(false); const [spacer, setSpacer] = useState(0); const cardRef = useRef<HTMLDivElement>(null); const expandRef = useRef<HTMLButtonElement>(null); const morph = reduceMotion ? { duration: 0 } : MORPH; const alt = imageAlt ?? (image ? `${title}, drawn in glyphs` : "A ringed sphere and a small moon, drawn in glyphs"); const toggleAdd = () => { const next = !isAdded; if (added === undefined) setAddedState(next); onAdd?.(next); }; const setExpanded = (next: boolean) => { if (next) setSpacer(cardRef.current?.offsetHeight ?? 0); setOpen(next); onExpand?.(next); }; const panelHead = ( <div className="flex items-center justify-between gap-3"> <span className="inline-flex h-6 items-center rounded-full px-2.5 font-mono text-[10.5px] tracking-[0.08em] uppercase" style={{ background: "rgb(255 255 255 / 0.14)", color: colors.panelFg }} > {label} </span> <AddButton added={isAdded} onToggle={toggleAdd} colors={colors} /> </div> ); const locationRow = location ? ( <p className="flex items-center gap-1.5 text-[12.5px]" style={{ color: colors.panelMuted }}> <MapPin className="size-3.5 shrink-0" aria-hidden="true" /> <span className="truncate">{location}</span> </p> ) : null; return ( <div className={cn("relative w-full max-w-[23rem]", className)} style={style} {...props}> {open ? ( <div aria-hidden="true" style={{ height: spacer }} /> ) : ( <motion.div ref={cardRef} layoutId={`${id}-card`} transition={morph} style={{ borderRadius: 26, background: colors.panel }} className="@container/card relative overflow-hidden shadow-[0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-12px_rgb(0_0_0/0.25)]" > <motion.div layoutId={`${id}-map`} transition={morph} role="img" aria-label={alt} className="relative"> <GlyphField image={image} colors={colors} ramp={ramp} cell={cell} className="aspect-[5/4] w-full" /> </motion.div> <div className="flex flex-col gap-3 p-5" style={{ color: colors.panelFg }}> {panelHead} <div className="space-y-1.5"> <motion.h3 layoutId={`${id}-title`} layout="position" transition={morph} className={cn(SERIF, "text-[1.75rem] leading-[1.05] tracking-[-0.01em]")}> {title} </motion.h3> {description ? ( <p className="line-clamp-2 text-[13.5px] leading-5" style={{ color: colors.panelMuted }}> {description} </p> ) : null} </div> {locationRow} <div className="mt-1 flex items-center justify-between gap-3 border-t pt-3.5" style={{ borderColor: "rgb(255 255 255 / 0.16)" }}> {watchers ? <Watchers watchers={watchers} colors={colors} /> : <span />} <button ref={expandRef} type="button" onClick={() => setExpanded(true)} aria-haspopup="dialog" className="inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-[12.5px] font-medium outline-none transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/80" style={{ borderColor: "rgb(255 255 255 / 0.3)", color: colors.panelFg }} > <Maximize2 className="size-3.5" aria-hidden="true" /> Expand </button> </div> </div> </motion.div> )} <Dialog.Root open={open} onOpenChange={setExpanded}> <AnimatePresence> {open ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div className="fixed inset-0 z-50 bg-zinc-950/40 backdrop-blur-[2px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.base }} /> </Dialog.Overlay> <div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6"> <Dialog.Content asChild forceMount {...(description ? {} : { "aria-describedby": undefined })} onCloseAutoFocus={(event) => { // The Expand button re-mounts with the card, so return focus to it ourselves. event.preventDefault(); requestAnimationFrame(() => expandRef.current?.focus()); }} > <motion.div layoutId={`${id}-card`} transition={morph} style={{ borderRadius: 30, background: colors.panel }} className="pointer-events-auto relative grid max-h-[calc(100dvh-1.5rem)] w-full max-w-4xl overflow-y-auto shadow-2xl outline-none md:grid-cols-[1.55fr_1fr]" > <motion.figure layoutId={`${id}-map`} transition={morph} className="relative m-0 overflow-hidden"> <div role="img" aria-label={alt} className="md:h-full"> <GlyphField image={image} colors={colors} ramp={ramp} cell={cell} className="aspect-[5/4] w-full md:aspect-auto md:h-full md:min-h-[30rem]" /> </div> <figcaption className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-5 py-3 font-mono text-[11px]" style={{ color: colors.ink, background: `linear-gradient(to top, ${colors.ground}, transparent)` }} > <span aria-hidden="true">Fig. 1</span> <span className="truncate">{caption ?? alt}</span> </figcaption> </motion.figure> <div className="@container/card flex flex-col gap-4 p-6" style={{ color: colors.panelFg }}> <div className="md:pr-10">{panelHead}</div> <div className="space-y-2"> <Dialog.Title asChild> <motion.h2 layoutId={`${id}-title`} layout="position" transition={morph} className={cn(SERIF, "pr-8 text-[2.25rem] leading-[1.02] tracking-[-0.01em]")}> {title} </motion.h2> </Dialog.Title> {description ? ( <Dialog.Description className="text-[14px] leading-6" style={{ color: colors.panelMuted }}> {description} </Dialog.Description> ) : null} </div> {locationRow} <motion.div className="mt-auto space-y-3 border-t pt-4" style={{ borderColor: "rgb(255 255 255 / 0.16)" }} initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: reduceMotion ? 0 : 0.15, duration: duration.base } }} exit={{ opacity: 0, transition: { duration: duration.instant } }} > {watchers ? <Watchers watchers={watchers} colors={colors} /> : null} <p className="font-mono text-[11px]" style={{ color: colors.panelMuted }}> Move across the map to stir the glyphs. </p> </motion.div> </div> <Dialog.Close aria-label="Close" className="absolute top-4 right-4 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full outline-none transition-colors hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/80 md:top-5 md:right-5" style={{ color: colors.panelFg, background: "rgb(255 255 255 / 0.1)" }} > <X className="size-4" aria-hidden="true" /> </Dialog.Close> </motion.div> </Dialog.Content> </div> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> </div> ); }
Usage
import { GlyphMapCard } from "@/components/ui/glyph-map-card/glyph-map-card";
<GlyphMapCard
image="/images/reef-fish.webp"
imageAlt="A striped reef fish"
title="Reef Drifter"
description="A striped reef fish, sampled into glyphs."
location="Tidepool Studio, Bay 3"
watchers={{ people: [{ name: "Maya Lindqvist", image: "/avatars/maya.webp" }], total: 31 }}
tone="indigo"
ramp="classic"
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
image | string | – | Image redrawn as glyphs (any size; it is cropped to fill). Without it, a built-in sphere study is used. |
imageAlt | string | – | Describes the image for screen readers; also the default caption in the expanded view. |
title | string | "Quiet Orbit" | Card title, set in a serif display face. |
description | string | – | Short description under the title. |
label | string | "Stock Card" | Small label at the top of the colored panel. |
location | string | – | Place shown with a pin icon. |
watchers | { people: GlyphMapWatcher[]; total: number } | – | Avatars of people watching (up to 3 are shown) and the total count. |
tone | GlyphMapTone | "indigo" | Color of the glyphs and the panel. |
ramp | GlyphMapRamp | "classic" | Glyph ramp: classic ASCII, binary digits or shade blocks. |
cell | number | 9 | Glyph cell size in px. |
added | boolean | – | Controlled "added" state of the Add card button. |
defaultAdded | boolean | false | Initial "added" state when uncontrolled. |
onAdd | (added: boolean) => void | – | Called with the new state when Add card is pressed. |
onExpand | (expanded: boolean) => void | – | Called when the expanded view opens or closes. |
caption | string | imageAlt | Caption under the glyph map in the expanded view. |
Also accepts every native <div> attribute, including ref and className.
- The image is downsampled to one pixel per cell (`src/lib/glyph/sample-image.ts`, cached per image and grid size) and each cell picks a glyph by darkness: dense glyphs for dark areas, empty cells for the pale ground. Glyphs are blitted from a shared sprite atlas in Geist Mono, never drawn with fillText per cell.
- Images must be same-origin or served with CORS headers, since the canvas reads their pixels. Bold silhouettes on a pale background read best.
- The title uses `--font-geist-serif` when it is defined (Geist Serif, from the `geist` package) and falls back to the system serif.
- `ramp` picks classic ASCII (` .:-=+*#%@`), binary (`1` and `0`) or shade blocks (`░▒▓█`, drawn as crisp dot screens). Smaller `cell` values draw more, smaller glyphs.
Accessibility
- The glyph map is a canvas inside an element with role="img" and the `imageAlt` label; the title, description, buttons and watcher names are real DOM text.
- Add card is a toggle button with aria-pressed. Expand opens a modal dialog labelled by the title; Escape or the Close button closes it and focus returns to Expand.
- Under reduced motion the map is drawn once with the true glyphs: no re-rolling, no scramble under the pointer, and the expand morph is instant. The map pauses offscreen and in hidden tabs.







