Build animated ASCII banners for CLI tools and web interfaces. Frame-based animation, ANSI color systems, terminal compatibility, accessibility, and web-based ASCII shaders.
Animated ASCII banners create personality in CLI tools and terminal-aesthetic web UIs. This skill covers both terminal-native (Node.js/Python CLI) and web-based (canvas/WebGL) implementations.
Key challenges: Terminal inconsistency, ANSI color fragmentation, screen reader accessibility, flicker prevention, and cross-platform rendering.
project/
frames/ # Each .txt file is one animation frame
frame-001.txt
frame-002.txt
...
colors/ # Color map per frame (optional)
frame-001.json
src/
renderer.ts # Animation engine
palette.ts # ANSI color role mapping
detect.ts # Terminal capability detection
import fs from "fs";
import readline from "readline";
const frames = fs
.readdirSync("./frames")
.filter(f => f.endsWith(".txt"))
.sort()
.map(f => fs.readFileSync(`./frames/${f}`, "utf8"));
let current = 0;
let running = true;
function render() {
if (!running) return;
readline.cursorTo(process.stdout, 0, 0);
readline.clearScreenDown(process.stdout);
process.stdout.write(frames[current]);
current = (current + 1) % frames.length;
}
// 75ms = ~13fps — safe for most terminals
const interval = setInterval(render, 75);
// Graceful cleanup
process.on("SIGINT", () => {
running = false;
clearInterval(interval);
readline.cursorTo(process.stdout, 0, 0);
readline.clearScreenDown(process.stdout);
process.exit(0);
});
// Auto-stop after one loop
setTimeout(() => {
clearInterval(interval);
running = false;
}, frames.length * 75);
Use semantic color roles, not hardcoded values. Terminals remap colors based on user themes.
// Color role mapping — degrade gracefully across terminals
const ANSI_ROLES = {
primary: "\x1b[32m", // Green (accent)
secondary: "\x1b[36m", // Cyan
highlight: "\x1b[97m", // Bright white
shadow: "\x1b[90m", // Dark gray
dim: "\x1b[2m", // Dim modifier
reset: "\x1b[0m",
};
function colorize(char, role) {
if (!role || role === "none") return char;
return `${ANSI_ROLES[role] || ""}${char}${ANSI_ROLES.reset}`;
}
ANSI color modes:
| Mode | Colors | Support | Use |
|---|---|---|---|
| 4-bit | 16 colors | Universal | Safe default — use this |
| 8-bit | 256 colors | Most modern terminals | Extended palette |
| 24-bit (truecolor) | 16M colors | iTerm2, Kitty, modern terminals | Brand-exact colors |
Terminal detection:
function getColorSupport() {
const env = process.env;
if (env.NO_COLOR) return "none";
if (env.COLORTERM === "truecolor" || env.COLORTERM === "24bit") return "24bit";
if (env.TERM_PROGRAM === "iTerm.app") return "24bit";
if (env.TERM?.includes("256color")) return "8bit";
if (process.stdout.isTTY) return "4bit";
return "none";
}
Problem: clearScreen + full repaint causes visible flicker.
Solution: Differential rendering — only repaint changed characters:
let previousFrame = "";
function renderDiff(frame) {
const lines = frame.split("\n");
const prevLines = previousFrame.split("\n");
for (let y = 0; y < lines.length; y++) {
if (lines[y] !== prevLines[y]) {
readline.cursorTo(process.stdout, 0, y);
process.stdout.write(lines[y] + "\x1b[K"); // Clear to end of line
}
}
previousFrame = frame;
}
Additional techniques:
\x1b[?1049h to enter, \x1b[?1049l to exit)\x1b[?25l, restore with \x1b[?25h)Mandatory requirements:
| Requirement | Implementation |
|---|---|
| Opt-in animation | Behind a flag (--banner, --animate) — never auto-play |
| Screen reader safe | Use aria-live equivalent: announce start/end, skip frames |
| Reduced motion | Respect REDUCE_MOTION env var or OS setting |
| Graceful degradation | Static ASCII art fallback when animation is disabled |
| Color-independent | Art must be recognizable without color (shape > color) |
function shouldAnimate() {
if (process.env.NO_ANIMATION) return false;
if (process.env.REDUCE_MOTION) return false;
if (!process.stdout.isTTY) return false;
if (process.env.TERM === "dumb") return false;
return true;
}
Character density (for shading):
Light → Dense: . : - = + * # % @
Common block characters:
Borders: ┌ ─ ┐ │ └ ┘ ╔ ═ ╗ ║ ╚ ╝
Blocks: ░ ▒ ▓ █ ▄ ▀ ▐ ▌
Geometry: ╱ ╲ △ ▽ ◇ ○ ●
Arrows: → ← ↑ ↓ ⟶ ⟵
figlet for text banners:
# Install
npm install figlet
# or
pip install pyfiglet
# Generate
figlet -f slant "SKILLS"
pyfiglet -f slant "SKILLS"
Popular figlet fonts: slant, banner3, big, doom, standard, small
Convert any visual (3D scene, video, image) to ASCII in the browser:
const CHARS = " .:-=+*#%@";
function renderAscii(ctx, canvas, source, cellW, cellH) {
// Draw source to small offscreen canvas
const cols = Math.floor(canvas.width / cellW);
const rows = Math.floor(canvas.height / cellH);
const offscreen = new OffscreenCanvas(cols, rows);
const offCtx = offscreen.getContext("2d");
offCtx.drawImage(source, 0, 0, cols, rows);
const pixels = offCtx.getImageData(0, 0, cols, rows).data;
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = `${cellH - 2}px monospace`;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = (y * cols + x) * 4;
const brightness = (pixels[i] * 0.299 + pixels[i+1] * 0.587 + pixels[i+2] * 0.114) / 255;
if (brightness < 0.02) continue;
const char = CHARS[Math.floor(brightness * (CHARS.length - 1))];
const green = Math.floor(40 + brightness * 215);
ctx.fillStyle = `rgba(0,${green},${Math.floor(green*0.55)},${0.3 + brightness * 0.7})`;
ctx.fillText(char, x * cellW, y * cellH + cellH - 2);
}
}
}
For animated 3D scenes rendered as ASCII:
import * as THREE from "three";
// 1. Create scene with geometry
const scene = new THREE.Scene();
const geometry = new THREE.TorusKnotGeometry(1, 0.35, 128, 32);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff88 });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 2. Render to offscreen WebGL
const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height);
// 3. Read pixels → ASCII conversion (same as canvas method)
// 4. Output to visible canvas as ASCII characters
// Animation loop
function animate() {
mesh.rotation.x += 0.01;
mesh.rotation.y += 0.007;
renderer.render(scene, camera);
renderAscii(asciiCtx, asciiCanvas, renderer.domElement, 8, 14);
requestAnimationFrame(animate);
}
| Technique | Impact | Implementation |
|---|---|---|
| Skip black pixels | 30-50% fewer draw calls | if (brightness < threshold) continue |
| Throttle FPS | Reduce CPU usage | requestAnimationFrame with timestamp check |
| Reduce resolution | Fewer cells to render | Smaller offscreen canvas |
| Cache character metrics | Avoid repeated measureText | Pre-compute once |
Use willReadFrequently | Faster getImageData | Pass to canvas context options |
| Gradient fade | Visual polish | CSS gradient overlay at edges |
From image to ASCII (Python):
from PIL import Image
CHARS = " .:-=+*#%@"
def image_to_ascii(path, width=80):
img = Image.open(path).convert("L")
aspect = img.height / img.width
height = int(width * aspect * 0.5) # Terminal chars are ~2:1
img = img.resize((width, height))
ascii_art = ""
for y in range(height):
for x in range(width):
brightness = img.getpixel((x, y)) / 255
ascii_art += CHARS[int(brightness * (len(CHARS) - 1))]
ascii_art += "\n"
return ascii_art
From text to ASCII banner:
# Quick branded banner
figlet -f slant "skills.ws" | sed 's/^/ /'
# With color (bash)
echo -e "\033[32m$(figlet -f slant 'skills.ws')\033[0m"