Backgrounds
The classic donut.c torus, lit and z-buffered.
Controls
pnpm dlx shadcn@latest add @asciicn/ascii-donutPulls in the engine at lib/ascii on first install. The @asciicn namespace needs one line in components.json, see Installation.
Install the engine first with npx shadcn@latest add @asciicn/ascii-core, or copy lib/ascii from the repository.
Copy this into components/ascii/ascii-donut.tsx:
"use client"
import { AsciiCanvas, type AsciiCanvasProps, type AsciiFrame } from "@/lib/ascii"
export type AsciiDonutProps = Omit<AsciiCanvasProps, "field" | "frame" | "source"> & {
/** Rotation speed multiplier. */
speed?: number
/** Tube radius relative to the ring radius. */
thickness?: number
}
/** The classic donut.c torus, lit and z-buffered on the ASCII grid. */
export function AsciiDonut({ speed = 1, thickness = 0.5, charset = "donut", ...props }: AsciiDonutProps) {
const frame: AsciiFrame = (f, t, s) => {
const A = t * speed
const B = t * speed * 0.5
const cA = Math.cos(A)
const sA = Math.sin(A)
const cB = Math.cos(B)
const sB = Math.sin(B)
const R1 = thickness
const R2 = 1
const K2 = 5
// Screen is 1 unit tall and `aspect` wide; scale so the torus fills ~80% of the shorter side.
const scale = Math.min(s.aspect, 1) * 0.4 * K2 / (R1 + R2)
const zbuf = new Float32Array(f.width * f.height)
for (let theta = 0; theta < Math.PI * 2; theta += 0.04) {
const ct = Math.cos(theta)
const st = Math.sin(theta)
for (let phi = 0; phi < Math.PI * 2; phi += 0.015) {
const cp = Math.cos(phi)
const sp = Math.sin(phi)
const cx = R2 + R1 * ct
const cy = R1 * st
const x = cx * (cB * cp + sA * sB * sp) - cy * cA * sB
const y = cx * (sB * cp - sA * cB * sp) + cy * cA * cB
const z = K2 + cA * cx * sp + cy * sA
const ooz = 1 / z
const px = Math.floor(((s.aspect / 2 + scale * x * ooz) / s.aspect) * f.width)
const py = Math.floor((0.5 - scale * y * ooz) * f.height)
if (px < 0 || px >= f.width || py < 0 || py >= f.height) continue
const L = cp * ct * sB - cA * ct * sp - sA * st + cB * (cA * st - ct * sA * sp)
const i = py * f.width + px
if (ooz > zbuf[i]) {
zbuf[i] = ooz
f.tone[i] = L > 0 ? 0.08 + (L / Math.SQRT2) * 0.92 : 0.04
}
}
}
}
return <AsciiCanvas aria-label="Spinning ASCII donut" frame={frame} charset={charset} {...props} />
}
import { AsciiDonut } from "@/components/ascii/ascii-donut"
<AsciiDonut className="h-80 text-primary" />color takes gradient stops from faint to full ink, so the lit side glows hot.
Braille packs 2×4 dots into every cell, which gives four times the resolution at the same font size.
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | number | 1 | Rotation speed multiplier. |
| thickness | number | 0.5 | Tube radius relative to the ring. |
| charset | string | "donut" | The original .,-~:;=!*#$@ ramp. |
| Prop | Type | Default | Description |
|---|---|---|---|
| charset | CharsetName | string | "standard" | Named ramp or your own string, empty to dense. "braille" packs 2×4 dots per cell, "quadrants" 2×2 blocks. |
| fontSize | number | 12 | Cell size in CSS pixels. Smaller means more detail. |
| dither | "none" | "bayer" | "floyd" | "none" | Quantisation. Floyd–Steinberg for photos, Bayer for a retro look. |
| color | "ink" | "source" | string[] | "ink" | Ink uses the CSS colour (theme with text-*). Source keeps pixel colours. An array maps tone onto gradient stops, e.g. palettes.thermal. |
| edges | number | 0 | Outline contours with | / - \ where the tone gradient beats this strength. Try 0.3. Ramps only. |
| opacity | boolean | false | Fade each glyph by its tone. |
| brightness | number | 0 | Added after contrast, -1 to 1. |
| contrast | number | 1 | Multiplier around mid grey. |
| gamma | number | 1 | Tone curve exponent. |
| invert | boolean | false | Swap ink and blank. |
| animate | boolean | auto | On for fields, frames and video. Off under reduced motion, except video. |
| fps | number | 30 | Frame cap. |
| ref | Ref<AsciiHandle> | — | Exposes text(), svg(), png() and the canvas. |
aria-label to describe it in your own words, or aria-hidden when it is decoration.prefers-reduced-motion it freezes on the current frame.color, so text-primary or text-foreground/40 themes them. Theme changes repaint at once.ref.current.text(), svg() or png().