ASCII Turntable
An extruded solid that turns with the cursor and is redrawn as characters on a grid. The lighting is graded for the ramp, so the face lands mid-scale instead of saturating to one glyph.
components/ui/ascii-turntable.tsx
"use client"
import { useEffect, useRef, useState } from "react"
import * as THREE from "three"
import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js"
import { cn } from "@/lib/utils"
/**
* ascii-turntable — an extruded solid that turns with the cursor, redrawn as
* characters on a grid.
*
* The shape is vector and extruded at runtime, so it stays crisp at every angle
* for a couple of KB. The pre-rendered alternative is a frame sequence: dozens
* of PNGs, megabytes, and only as many angles as you exported.
*
* Two decisions worth keeping if you edit this:
*
* LIGHTING IS GRADED FOR THE CHARACTERS, NOT THE SOLID. Sampled to one cell per
* `cell` px, a well-lit face saturates to a single glyph, because ten ramp
* characters cannot carry a specular edge. Exposure sits low so the face lands
* mid-ramp with headroom above it. The 3D layer is never composited: it renders
* only so the ASCII pass has something to read.
*
* THE POINTER NEVER DRAWS. It writes a target and the rAF loop owns painting,
* so a burst of pointermove costs one frame, not one repaint each.
*
* 2026-10-02 audit: the loop rendered WebGL and repainted the glyph grid from mount even off
* screen, which weighs on Browse all where many previews sit at once; an IntersectionObserver
* now stops the loop while the field is out of view and starts it again when it comes back
* (the idle sway stays). The pointer was the only way to turn it, so the field is now
* focusable and the left and right arrow keys turn it 10 degrees a press within the sweep
* (Home brings it back to the centre); under reduced motion each press redraws one still.
*/
export type TurntableShape = "ring" | "bars" | "token"
export interface AsciiTurntableProps {
/** which solid to turn. custom art goes through `path` or `contours`. */
shape?: TurntableShape
/** any single-path SVG mark (`d`, commands M L H V C S Q T A Z) becomes the solid; wins over `shape` */
path?: string
/** the path's viewBox, e.g. "0 0 114.714 99.0732" */
viewBox?: string
/** normalised outer/hole polygons, roughly -1..1, from any traced svg */
contours?: { outer: [number, number][]; holes?: [number, number][][] }[]
/** character cell in css px. smaller keeps more of the form, costs more paint. */
cell?: number
/** dark to light. the first character is treated as empty. */
ramp?: string
/** max yaw either side of centre, in degrees */
sweep?: number
/** overlay repaint cap, held apart from the 3d loop */
fps?: number
className?: string
height?: number | string
}
const RING = () => {
const s = new THREE.Shape()
s.absarc(0, 0, 1, 0, Math.PI * 2, false)
const h = new THREE.Path()
h.absarc(0, 0, 0.46, 0, Math.PI * 2, true)
s.holes.push(h)
return [s]
}
const BARS = () => {
const hs = [0.85, 1.45, 1.05, 1.75]
const w = 0.3
const gap = 0.2
const span = hs.length * w + (hs.length - 1) * gap
return hs.map((h, i) => {
const x = -span / 2 + i * (w + gap)
const s = new THREE.Shape()
const r = 0.06
const y0 = -h / 2
const y1 = h / 2
s.moveTo(x + r, y0)
s.lineTo(x + w - r, y0)
s.quadraticCurveTo(x + w, y0, x + w, y0 + r)
s.lineTo(x + w, y1 - r)
s.quadraticCurveTo(x + w, y1, x + w - r, y1)
s.lineTo(x + r, y1)
s.quadraticCurveTo(x, y1, x, y1 - r)
s.lineTo(x, y0 + r)
s.quadraticCurveTo(x, y0, x + r, y0)
return s
})
}
const TOKEN = () => {
const a = 1.55
const r = 0.34
const s = new THREE.Shape()
s.moveTo(-a + r, -a)
s.lineTo(a - r, -a)
s.quadraticCurveTo(a, -a, a, -a + r)
s.lineTo(a, a - r)
s.quadraticCurveTo(a, a, a - r, a)
s.lineTo(-a + r, a)
s.quadraticCurveTo(-a, a, -a, a - r)
s.lineTo(-a, -a + r)
s.quadraticCurveTo(-a, -a, -a + r, -a)
const h = new THREE.Path()
h.absarc(0, 0, 0.62, 0, Math.PI * 2, true)
s.holes.push(h)
return [s]
}
/** an SVG path as extruded shapes: three's SVGLoader flattens the commands and sorts holes by
* winding, then the art is centred and scaled to the built-in token's reach (about ±1.4), y up */
function pathShapes(d: string, viewBox: string): THREE.Shape[] {
const [x0, y0, w, h] = viewBox.split(/[\s,]+/).map(Number)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}"><path d="${d.replace(/"/g, "")}"/></svg>`
const shapes = new SVGLoader().parse(svg).paths.flatMap((p) => p.toShapes())
const s = 2.8 / Math.max(w, h)
const m = new THREE.Matrix3().set(s, 0, -(x0 + w / 2) * s, 0, -s, (y0 + h / 2) * s, 0, 0, 1)
const map = (pts: THREE.Vector2[]) => pts.map((p) => p.clone().applyMatrix3(m))
return shapes.map((sh) => {
const out = new THREE.Shape(map(sh.getPoints(12)))
sh.holes.forEach((hole) => out.holes.push(new THREE.Path(map(hole.getPoints(12)))))
return out
})
}
const BUILTIN: Record<TurntableShape, () => THREE.Shape[]> = {
ring: RING,
bars: BARS,
token: TOKEN,
}
export function AsciiTurntable({
shape = "token",
path,
viewBox = "0 0 24 24",
contours,
cell = 10,
ramp = " .:-=+*#%@",
sweep = 58,
fps = 30,
className = "",
height = 420,
}: AsciiTurntableProps) {
const glRef = useRef<HTMLCanvasElement>(null)
const asciiRef = useRef<HTMLCanvasElement>(null)
const hostRef = useRef<HTMLDivElement>(null)
const [noGl, setNoGl] = useState(false)
useEffect(() => {
const host = hostRef.current!
const canvas = glRef.current!
const aCanvas = asciiRef.current!
const aCtx = aCanvas.getContext("2d")!
const sampler = document.createElement("canvas")
const sCtx = sampler.getContext("2d", { willReadFrequently: true })!
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches
/* The ink follows the theme's foreground so this reads on a light ground
too. It is handed to canvas verbatim rather than parsed: Tailwind v4
resolves `color` to oklab(), and pulling digits out of that string gave
rgb(0, 999994, 0) — a bright green mark on every theme. Canvas accepts
modern colour syntax, so alpha rides on globalAlpha instead. */
const ink = getComputedStyle(host).color || "rgb(232,234,238)"
/* No WebGL, no turntable. A device with it off or blocklisted (some privacy browsers, old
GPUs, too many live contexts on one page) makes the constructor THROW, and uncaught that
took the whole preview down with it (Sentry, 2026-09-20). Say so quietly instead. */
let renderer: THREE.WebGLRenderer
try {
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true, // drawImage off a webgl canvas needs the buffer kept
})
} catch {
setNoGl(true)
return
}
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
camera.position.set(0, 0, 7.2)
const group = new THREE.Group()
scene.add(group)
scene.add(new THREE.AmbientLight(0xffffff, 0.2))
const key = new THREE.DirectionalLight(0xffffff, 2.75)
key.position.set(-3, 4, 6)
const fill = new THREE.DirectionalLight(0xffffff, 0.4)
fill.position.set(4, 2, 4)
scene.add(key, fill)
const mat = new THREE.MeshStandardMaterial({
color: 0xb8c0cc,
metalness: 0.3,
roughness: 0.52,
})
const shapes: THREE.Shape[] = path
? pathShapes(path, viewBox)
: contours
? contours.map((c) => {
const holes = c.holes ?? []
const sh = new THREE.Shape(c.outer.map((p) => new THREE.Vector2(p[0], p[1])))
holes.forEach((h) =>
sh.holes.push(new THREE.Path(h.map((p) => new THREE.Vector2(p[0], p[1])))),
)
return sh
})
: BUILTIN[shape]()
shapes.forEach((sh) =>
group.add(
new THREE.Mesh(
new THREE.ExtrudeGeometry(sh, {
depth: 0.34,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.028,
bevelSegments: 4,
curveSegments: 24,
}),
mat,
),
),
)
// centre the whole mark, not each piece: per-geometry centring stacks them
const box = new THREE.Box3().setFromObject(group)
const c0 = box.getCenter(new THREE.Vector3())
group.children.forEach((m) => m.position.sub(c0))
const size = box.getSize(new THREE.Vector3())
group.scale.setScalar(3.15 / Math.max(size.x, size.y))
const SWEEP = THREE.MathUtils.degToRad(sweep)
let target = 0
let current = 0
let engaged = false
let cols = 0
let rows = 0
let w = 0
let h = 0
let raf = 0
let lastPaint = 0
const t0 = performance.now()
const resize = () => {
w = host.clientWidth
h = host.clientHeight
if (!w || !h) return
renderer.setSize(w, h, false)
camera.aspect = w / h
camera.updateProjectionMatrix()
const dpr = Math.min(devicePixelRatio || 1, 2)
aCanvas.width = w * dpr
aCanvas.height = h * dpr
aCtx.setTransform(dpr, 0, 0, dpr, 0, 0)
aCtx.textBaseline = "top"
aCtx.font = `${Math.round(cell * 1.02)}px "SF Mono", Menlo, ui-monospace, monospace`
cols = Math.ceil(w / cell)
rows = Math.ceil(h / cell)
sampler.width = cols
sampler.height = rows
}
const draw = () => {
aCtx.globalAlpha = 1
sCtx.clearRect(0, 0, cols, rows)
sCtx.drawImage(canvas, 0, 0, cols, rows)
const d = sCtx.getImageData(0, 0, cols, rows).data
aCtx.clearRect(0, 0, w, h)
aCtx.fillStyle = ink
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = (y * cols + x) * 4
const a = d[i + 3] / 255
if (a < 0.06) continue
let lum = ((0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]) / 255) * a
// the lit band is narrow by design, so it is stretched over the ramp
lum = Math.min(1, Math.max(0, (lum - 0.32) / 0.48))
const k = Math.min(ramp.length - 1, Math.floor(lum * ramp.length))
if (k <= 0) continue
aCtx.globalAlpha = 0.35 + 0.65 * lum
aCtx.fillText(ramp[k], x * cell, y * cell)
}
}
}
/* pointer writes a target only; the loop owns painting */
const onMove = (e: PointerEvent) => {
const r = host.getBoundingClientRect()
target = ((e.clientX - r.left) / r.width - 0.5) * 2 * SWEEP
engaged = true
}
host.addEventListener("pointermove", onMove, { passive: true })
/* the keyboard turns it too: 10 degrees a press, held inside the sweep */
const STEP = THREE.MathUtils.degToRad(10)
const onKey = (e: KeyboardEvent) => {
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0
if (!dir && e.key !== "Home") return
e.preventDefault()
const from = engaged ? target : current
target = dir ? THREE.MathUtils.clamp(from + dir * STEP, -SWEEP, SWEEP) : 0
engaged = true
if (reduce) {
current = target
group.rotation.y = current
group.rotation.x = -current * 0.06
renderer.render(scene, camera)
draw()
}
}
host.addEventListener("keydown", onKey)
const ro = new ResizeObserver(() => {
resize()
if (reduce) {
renderer.render(scene, camera)
draw()
}
})
ro.observe(host)
const tick = (now: number) => {
raf = requestAnimationFrame(tick)
if (!engaged) current = Math.sin(((performance.now() - t0) / 1000) * 0.35) * SWEEP * 0.55
else current += (target - current) * 0.08
group.rotation.y = current
group.rotation.x = -current * 0.06
renderer.render(scene, camera)
if (now - lastPaint >= 1000 / fps) {
lastPaint = now
draw()
}
}
resize()
canvas.style.opacity = "0"; // renders for sampling only
let io: IntersectionObserver | undefined
if (reduce) {
renderer.render(scene, camera)
draw()
} else if (typeof IntersectionObserver === "undefined") {
raf = requestAnimationFrame(tick)
} else {
/* the loop runs only while the field is on screen */
io = new IntersectionObserver(([entry]) => {
cancelAnimationFrame(raf)
raf = entry.isIntersecting ? requestAnimationFrame(tick) : 0
})
io.observe(host)
}
return () => {
cancelAnimationFrame(raf)
io?.disconnect()
ro.disconnect()
host.removeEventListener("pointermove", onMove)
host.removeEventListener("keydown", onKey)
group.traverse((o) => {
if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose()
})
mat.dispose()
renderer.dispose()
}
}, [shape, path, viewBox, contours, cell, ramp, sweep, fps])
return (
<div
ref={hostRef}
/* no card: the glyph field sits straight on the page ground */
tabIndex={0}
role="img"
aria-label="A solid drawn in characters; the left and right arrow keys turn it"
className={cn("relative w-full overflow-hidden text-foreground/[0.85]", className)}
style={{ height }}
>
<canvas ref={glRef} className="absolute inset-0 h-full w-full" />
<canvas ref={asciiRef} className="absolute inset-0 h-full w-full" />
{noGl && (
<p className="absolute inset-0 flex items-center justify-center text-[13px] text-current opacity-50">
3D preview needs WebGL
</p>
)}
</div>
)
}
- Where it goes
- data-table
- Interaction
- cursor-follow
- Use cases
- dashboard
DependenciesBundle weight · heavy
- webgl
- three · heavy