Flow

Effects / Canvas 2D

ASCII art

A rotating 3D shape drawn entirely with text characters, brighter characters for lit surfaces.

Prompt to build it

Build an ASCII-art rotating torus in green on black. Compute the surface per character cell with a light direction, map luminance to the ramp ' .,-~:;=!*#$@', and render with fillText on a monospace grid. Make it responsive, cap devicePixelRatio at 2, pause when off-screen, and show a still frame when prefers-reduced-motion is set. No external libraries.

Tested code

One self-contained HTML file with no libraries: the same code that runs above. Your AI gets it from Flow's get_effect tool.

Show the code
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>ASCII art | Flow effect</title> <!-- Flow effect: ASCII art (https://flow.donta.io/effects/ascii-art) Self-contained: no libraries. Copy the .fx-stage element, the CSS and the script into your project and size .fx-stage however you like. --> <style> :root { --display: "Bricolage Grotesque", ui-sans-serif, system-ui, sans-serif; --body: ui-sans-serif, system-ui, sans-serif; } html, body { margin: 0; min-height: 100%; background: #0e0e10; display: grid; place-items: center; } .fx-stage { width: min(100vw, 960px); } /* Stage that every effect fills. Container units (cqw/cqmin) size the art. */ .fx-stage { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 16px; container-type: size; background: #111; isolation: isolate; } .fx-fill { position: absolute; inset: 0; } .fx-fallback { position: absolute; inset: 0; } .fx-center { display: grid; place-items: center; } @media (prefers-reduced-motion: reduce) { .fx-stage *, .fx-stage *::before, .fx-stage *::after { animation-play-state: paused !important; } } </style> </head> <body> <div class="fx-stage"><canvas class="fx-fill" aria-hidden="true" style="width:100%;height:100%;display:block"></canvas></div> <script> function flowLoop(el, frame, opts) { opts = opts || {}; if (matchMedia("(prefers-reduced-motion: reduce)").matches) { for (let i = 0; i < (opts.stillFrames || 1); i++) frame(2 + i / 60); return function () {}; } let raf = 0, onScreen = true, last = 0; const start = performance.now(), gap = opts.fps ? 1000 / opts.fps : 0; const io = new IntersectionObserver(function (e) { onScreen = e[0].isIntersecting; }); io.observe(el); function tick(now) { raf = requestAnimationFrame(tick); if (!onScreen || document.hidden || (gap && now - last < gap)) return; last = now; frame((now - start) / 1000); } raf = requestAnimationFrame(tick); return function () { cancelAnimationFrame(raf); io.disconnect(); }; } function flowCanvas(canvas, o) { const ctx = canvas.getContext("2d"); const dpr = Math.min(devicePixelRatio || 1, 2); function size() { const w = o.fixed ? o.fixed : Math.max(1, Math.round(canvas.clientWidth * dpr)); const h = o.fixed ? o.fixed : Math.max(1, Math.round(canvas.clientHeight * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; if (o.init) o.init(ctx, w, h); } } if (o.fixed) { canvas.style.imageRendering = "pixelated"; canvas.style.objectFit = "cover"; } size(); new ResizeObserver(size).observe(canvas); return flowLoop(canvas, function (t) { o.draw(ctx, t, canvas.width, canvas.height); }, o); } /** Canvas 2D effect: ascii. Self-contained; no imports. */ const RAMP = " .,-~:;=!*#$@"; function drawAscii(ctx , t , w , h ) { const cell = Math.max(8, Math.round(w / 46)); const cols = Math.floor(w / (cell * 0.62)); const rows = Math.floor(h / cell); const out = new Array (cols * rows).fill(" "); const zbuf = new Float32Array(cols * rows); const A = t * 0.9; const B = t * 0.5; const cA = Math.cos(A), sA = Math.sin(A), cB = Math.cos(B), sB = Math.sin(B); const K2 = 5; const K1 = (Math.min(cols * 0.62, rows) * K2 * 3) / (8 * 3); for (let th = 0; th < 6.283; th += 0.07) { const ct = Math.cos(th), st = Math.sin(th); for (let ph = 0; ph < 6.283; ph += 0.02) { const cp = Math.cos(ph), sp = Math.sin(ph); const cx = 2 + ct; // R2 + R1 cos(th), R1=1, R2=2 const cy = 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 xp = Math.floor(cols / 2 + (K1 / 0.62) * ooz * x); const yp = Math.floor(rows / 2 - K1 * ooz * y); const L = cp * ct * sB - cA * ct * sp - sA * st + cB * (cA * st - ct * sA * sp); if (L > 0 && xp >= 0 && xp < cols && yp >= 0 && yp < rows) { const i = xp + yp * cols; if (ooz > zbuf[i]) { zbuf[i] = ooz; out[i] = RAMP[Math.min(RAMP.length - 1, Math.floor(L * 8.5))]; } } } } ctx.fillStyle = "#050b07"; ctx.fillRect(0, 0, w, h); ctx.fillStyle = "#4cf08a"; ctx.font = `${cell}px ui-monospace, Menlo, monospace`; ctx.textBaseline = "top"; const ox = (w - cols * cell * 0.62) / 2; for (let r = 0; r < rows; r++) { ctx.fillText(out.slice(r * cols, (r + 1) * cols).join(""), ox, r * cell); } } flowCanvas(document.querySelector(".fx-stage canvas"), { draw: drawAscii, fps: 30 }); </script> </body> </html>

How it works

Compute a lit 3D shape (a torus or sphere) per character cell, map brightness to a ramp like ' .:-=+*#%@', and draw the characters on a monospace grid each frame.

Guardrails

  • Mark the canvas aria-hidden; provide real text elsewhere.
  • Respect prefers-reduced-motion: show a still frame instead of the animation.
  • Pause when off-screen or when the tab is hidden (IntersectionObserver + visibilitychange).
  • Keep text off busy animated areas, or give it a solid backing that passes WCAG contrast.
  • One showpiece effect per page. If everything moves, nothing stands out.