Skip to content

Core Concepts

glyphcss does not redraw the scene every frame. At init, the renderer projects every vertex through the camera N times (N = 60 by default), Bresenham-rasterizes each frame into a Uint8Array stamp, and concatenates the N frames into one <pre> separated by \n. CSS then:

  1. Clips the viewport to exactly rows × cell-height pixels (one frame’s worth).
  2. Animates the strip’s transform: translate3d(0, -i × frame-height, 0) with steps(N, end) over --dur seconds.

The visible frame steps through 0..N-1 cleanly. JavaScript runs once. The browser’s compositor handles the rest.

When the user drags or zooms, the strip animation pauses (via a .dragging class) and a single live frame is rendered with rasterize(scene) per pointermove. On release, the strip is re-baked with the new camera state.

Glyphcss-style “every polygon is a DOM node” doesn’t fit ASCII rendering: the visible output is a single character grid, not a set of clickable polygons. Instead, glyphcss exposes a sparse hit layer: you opt-in to interactivity by registering hotspots at specific 3D anchors.

import { GlyphMesh, GlyphHotspot } from "@glyphcss/react";
import { dodecahedronPolygons } from "@glyphcss/core";
const shape = dodecahedronPolygons({ center: [0, 0, 0], size: 1, color: "#cc44ff" });
<GlyphMesh polygons={shape}>
<GlyphHotspot at={[0, 1.2, 0]} onClick={...}>
<span className="badge">Top</span>
</GlyphHotspot>
</GlyphMesh>

Each hotspot becomes a real <div> absolutely positioned at its projected cell, with its own @keyframes stepping through N projected positions. The strip and the hotspot keyframes both use steps(N, end) over the same --dur, so they stay in lockstep through rotation. Hotspots:

  • Render real DOM children (use them for tooltips, badges, hover affordances).
  • Fire normal DOM events (onClick, onMouseEnter, onFocus).
  • Get free CSS :hover and :focus-visible styling.
  • Inspect in DevTools like any other element.

The renderer and the hit layer must use the same camera.project(v, ...) call. This is enforced by both reading from a shared GlyphCamera handle:

const camera = createGlyphPerspectiveCamera({
rotX: 25, rotY: 0, distance: 3, zoom: 50, stretch: 1.0,
});
const scene = buildSceneContext({ camera, grid, wireframe: edges, mode: "wireframe" });
// Renderer
const frames = bakeFrames(scene, 60, "y");
// Hit layer — same camera, same N angles
for (let i = 0; i < 60; i++) {
camera.rotY = (i / 60) * 360;
const cells = projectHotspots(hotspots, camera, cols, rows, cellAspect);
// ...
}

If you ever find yourself threading a separate “current angle” through projection, stop: that’s how the strip and the hit layer drift apart.

<pre> is display: block, so getBoundingClientRect().width returns the container width, not the character width. Always measure on a hidden <span style="display: inline-block"> instead. The probe should have the same font properties as the strip (same font-family, same font-size, line-height: normal).

After measuring, never leave font-size / line-height as inline styles on the strip — the CSS variables on the scene root (--cell-fs, --cell-h) must be the source of truth, or the strip’s rendered frame height will drift from the keyframe translate distance and you’ll see the strip scrolling smoothly instead of stepping.