boldkit
uiascii-shapessource: ok · refreshed
ASCII Shapes
7 animated ASCII art components (Spiral, Rose, Wave, Vortex, Pulse, Matrix, Grid) with 5 character sets, 4 sizes, and SSR-safe static mode
npx shadcn add "https://boldkit.dev/r/ascii-shapes.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
@boldkit/utils@boldkit/motion-core
Files1 · source cached 10/8/2026, 3:32:46 AM
registry/default/ui/ascii-shapes.tsx
import * as React from 'react'
import { cn } from '@/lib/utils'
import { prefersReducedMotion, onReducedMotionChange } from '@/lib/motion-core'
// ============================================================================
// Types
// ============================================================================
export type AsciiSize = 'sm' | 'md' | 'lg' | 'hero'
export type AsciiCharset = 'blocks' | 'braille' | 'classic' | 'line' | 'dots'
export type AsciiSpeed = 'slow' | 'normal' | 'fast'
export interface AsciiShapeProps extends React.HTMLAttributes<HTMLPreElement> {
size?: AsciiSize
charset?: AsciiCharset
color?: string
speed?: AsciiSpeed
animated?: boolean
multicolor?: boolean
}
// ============================================================================
// Constants
// ============================================================================
const SIZE_MAP: Record<AsciiSize, { cols: number; rows: number }> = {
sm: { cols: 24, rows: 12 },
md: { cols: 48, rows: 24 },
lg: { cols: 72, rows: 36 },
hero: { cols: 120, rows: 60 },
}
const CHARSETS: Record<AsciiCharset, string[]> = {
blocks: [' ', '░', '▒', '▓', '█'],
braille: [' ', '⠁', '⠃', '⠇', '⠿', '⠷'],
classic: [' ', '.', ':', 'o', '*', '#', '@'],
line: [' ', '-', '/', '|', '\\', '+', 'X'],
dots: [' ', '.', '·', '•', '●'],
}
const SPEED_MAP: Record<AsciiSpeed, number> = {
slow: 0.4,
normal: 1.0,
fast: 2.2,
}
const MULTICOLOR_PALETTE = [
'hsl(var(--primary))',
'hsl(var(--secondary))',
'hsl(var(--accent))',
'hsl(var(--warning))',
'hsl(var(--info))',
'hsl(var(--success))',
]
// ============================================================================
// Grid engine
// ============================================================================
function makeGrid(cols: number, rows: number): string[][] {
return Array.from({ length: rows }, () => Array(cols).fill(' '))
}
function gridToLines(grid: string[][]): string[] {
return grid.map((row) => row.join(''))
}
// ============================================================================
// Draw functions
// ============================================================================
function drawSpiral(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 2.0
const spacing = Math.min(cx * aspect, cy) * 0.35
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dx = (c - cx) * aspect
const dy = r - cy
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 0.5) { grid[r][c] = chars[chars.length - 1]; continue }
const angle = ((Math.atan2(dy, dx) + t * 0.002) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI)
const armPhase = angle / (2 * Math.PI)
const winding = Math.round(dist / spacing - armPhase)
const nearestR = spacing * (armPhase + winding)
const distToArm = Math.abs(dist - nearestR)
const threshold = spacing * 0.38
if (nearestR >= 0 && distToArm < threshold) {
const intensity = 1 - distToArm / threshold
grid[r][c] = chars[Math.floor(intensity * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
function drawRose(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 2.0
const k = 5
const radius = Math.min(cx * aspect, cy) * 0.85
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dx = (c - cx) * aspect
const dy = r - cy
const dist = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx) + t * 0.001
const roseR = radius * Math.abs(Math.cos(k * angle))
const distToRose = Math.abs(dist - roseR)
const threshold = radius * 0.12
if (distToRose < threshold) {
const intensity = 1 - distToRose / threshold
grid[r][c] = chars[Math.floor(intensity * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
function drawWave(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = c / cols
const y = r / rows
const wave =
0.5 * Math.sin(2 * Math.PI * (x * 2 - t * 0.001)) +
0.3 * Math.sin(2 * Math.PI * (x * 3 - t * 0.0015)) +
0.2 * Math.sin(2 * Math.PI * (x * 5 - t * 0.002))
const waveY = 0.5 + wave * 0.3
const distToWave = Math.abs(y - waveY)
const threshold = 0.08
if (distToWave < threshold) {
const intensity = 1 - distToWave / threshold
grid[r][c] = chars[Math.floor(intensity * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
function drawVortex(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 2.0
const maxR = Math.sqrt((cx * aspect) ** 2 + cy ** 2)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dx = (c - cx) * aspect
const dy = r - cy
const dist = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx)
const vortexAngle = angle + dist * 0.3 - t * 0.002
const intensity = ((Math.sin(vortexAngle * 3) + 1) / 2) * Math.exp(-dist / (maxR * 0.8))
if (intensity > 0.08) {
grid[r][c] = chars[Math.min(Math.floor(intensity * (chars.length - 1)), chars.length - 1)]
} else {
grid[r][c] = ' '
}
}
}
}
function drawPulse(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 2.0
const speed = t * 0.02
const ringSpacing = Math.min(cx * aspect, cy) * 0.35
const maxR = Math.sqrt((cx * aspect) ** 2 + cy ** 2)
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const dx = (c - cx) * aspect
const dy = r - cy
const dist = Math.sqrt(dx * dx + dy * dy)
const phase = ((dist - speed) % ringSpacing + ringSpacing) % ringSpacing
const ringIntensity = Math.pow(Math.sin(Math.PI * phase / ringSpacing), 2)
const fade = Math.max(0, 1 - dist / maxR)
const intensity = ringIntensity * fade
if (intensity > 0.05) {
grid[r][c] = chars[Math.floor(intensity * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
interface ColState { offset: number; speed: number }
function makeMatrixState(cols: number): ColState[] {
return Array.from({ length: cols }, (_, i) => ({
offset: (i * 37 % 100),
speed: 0.5 + (i * 13 % 10) * 0.15,
}))
}
function drawMatrix(grid: string[][], cols: number, rows: number, t: number, chars: string[], state: ColState[]): void {
for (let c = 0; c < cols; c++) {
const colT = (t * 0.001 * state[c].speed + state[c].offset) % (rows * 1.8)
for (let r = 0; r < rows; r++) {
const distFromHead = colT - r
if (distFromHead >= 0 && distFromHead < 1) {
grid[r][c] = chars[chars.length - 1]
} else if (distFromHead >= 1 && distFromHead < rows * 0.45) {
const fade = 1 - distFromHead / (rows * 0.45)
grid[r][c] = chars[Math.floor(fade * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
function drawGrid(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cellW = 6, cellH = 3
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const isH = r % cellH === 0
const isV = c % cellW === 0
if (isH && isV) {
const wave = (Math.sin(c / cols * Math.PI * 4 + r / rows * Math.PI * 2 - t * 0.002) + 1) / 2
grid[r][c] = chars[Math.floor(wave * (chars.length - 1))]
} else if (isH || isV) {
const wave = Math.sin(c / cols * Math.PI * 8 + r / rows * Math.PI * 4 - t * 0.001) * 0.3 + 0.3
grid[r][c] = chars[Math.floor(wave * (chars.length - 1))]
} else {
grid[r][c] = ' '
}
}
}
}
function drawTorus(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0012, B = t * 0.0007
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const R1 = 1.0, R2 = 2.2, K2 = 5.0
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const screenScale = Math.min(cols * aspect, rows) * 0.85
const K1 = screenScale * K2 / (K2 + R1 + R2)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
for (let theta = 0; theta < 2 * Math.PI; theta += 0.05) {
const cosT = Math.cos(theta), sinT = Math.sin(theta)
for (let phi = 0; phi < 2 * Math.PI; phi += 0.02) {
const cosP = Math.cos(phi), sinP = Math.sin(phi)
const ox = (R2 + R1 * cosT) * cosP
const oy = (R2 + R1 * cosT) * sinP
const oz = R1 * sinT
const oy1 = oy * cosA - oz * sinA
const oz1 = oy * sinA + oz * cosA
const ox2 = ox * cosB - oy1 * sinB
const oy2 = ox * sinB + oy1 * cosB
const oz2 = oz1
const zDist = K2 - oz2
if (zDist <= 0) continue
const ooz = 1.0 / zDist
const xp = Math.round(cx + K1 * ox2 * ooz)
const yp = Math.round(cy - K1 * oy2 * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
const nx = cosT * cosP, ny = cosT * sinP, nz = sinT
const ny1 = ny * cosA - nz * sinA
const nz1 = ny * sinA + nz * cosA
const nx2 = nx * cosB - ny1 * sinB
const ny2 = nx * sinB + ny1 * cosB
const nz2 = nz1
const L = nx2 * 0.57 + ny2 * 0.57 + nz2 * (-0.57)
if (L > 0 && ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
}
}
}
function drawSphere(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 2.0
const R = Math.min(cols / (aspect * 2), rows / 2) * 0.88
const A = t * 0.0007, B = t * 0.0004
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
// Light from upper-right-front
const lx = 0.577, ly = -0.577, lz = -0.577
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const sx = (c - cx) / (R * aspect)
const sy = (r - cy) / R
const d2 = sx * sx + sy * sy
if (d2 >= 1.0) continue
const sz = Math.sqrt(1.0 - d2) // front-facing z (positive toward viewer)
// Rotate surface point for texture coordinates
const ny1 = sy * cosA - sz * sinA
const nz1 = sy * sinA + sz * cosA
const nx2 = sx * cosB + nz1 * sinB
const nz2 = -sx * sinB + nz1 * cosB
const ny2 = ny1
// Lat/lon grid lines on rotating surface
const lat = Math.asin(Math.max(-1, Math.min(1, ny2)))
const lon = Math.atan2(nx2, nz2)
const onGrid = Math.abs(Math.cos(lat * 5)) > 0.93 || Math.abs(Math.cos(lon * 8)) > 0.91
// Lighting uses unrotated normal (sx, sy, sz)
const L = Math.max(0, sx * lx + sy * ly + sz * lz)
if (onGrid) {
const intensity = 0.3 + L * 0.7
grid[r][c] = chars[Math.min(Math.floor(intensity * (chars.length - 1)), chars.length - 1)]
} else if (L > 0.02) {
grid[r][c] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
// dark side stays ' '
}
}
}
function drawCube(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0009, B = t * 0.0006
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const cx = cols / 2, cy = rows / 2
const K2 = 5.0, S = 1.4, aspect = 0.5
const scale = Math.min(cols * aspect, rows) * 0.72
const K1 = scale * K2 / (K2 + S)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.577, ly = -0.577, lz = -0.577
function rot(px: number, py: number, pz: number): [number, number, number] {
const y1 = py * cosA - pz * sinA
const z1 = py * sinA + pz * cosA
return [px * cosB + z1 * sinB, y1, -px * sinB + z1 * cosB]
}
const step = 0.04
const faces: Array<[[number, number, number], (u: number, v: number) => [number, number, number]]> = [
[[0, 0, 1], (u, v) => [ u*S, v*S, S]],
[[0, 0, -1], (u, v) => [-u*S, v*S, -S]],
[[1, 0, 0], (u, v) => [ S, v*S, -u*S]],
[[-1, 0, 0], (u, v) => [-S, v*S, u*S]],
[[0, -1, 0], (u, v) => [ u*S, -S, v*S]],
[[0, 1, 0], (u, v) => [ u*S, S, -v*S]],
]
for (const [norm, ptFn] of faces) {
const [rnx, rny, rnz] = rot(norm[0], norm[1], norm[2])
const L = Math.max(0, rnx * lx + rny * ly + rnz * lz)
if (L < 0.02) continue
const ch = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
for (let u = -1; u <= 1 + step * 0.5; u += step) {
for (let v = -1; v <= 1 + step * 0.5; v += step) {
const [rx, ry, rz] = rot(...ptFn(u, v))
const zDist = K2 - rz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp >= 0 && xp < cols && yp >= 0 && yp < rows && ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = ch
}
}
}
}
}
function drawDonut(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
// Faithful a1k0n donut.c algorithm — ring lies in XZ plane, tube depth = sinTheta,
// so the hole is ALWAYS visible from any viewing angle.
// A tilts around X-axis, B spins around Z-axis. Luminance from a1k0n's exact formula.
const A = t * 0.00083, B = t * 0.00125
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const R1 = 1.0, R2 = 2.0, K2 = 5.0
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K1 = Math.min(cols * aspect, rows) * K2 / (K2 + R1 + R2) * 0.9
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
// theta: angle around tube cross-section
// phi: angle around the ring center
for (let theta = 0; theta < 2 * Math.PI; theta += 0.07) {
const cosT = Math.cos(theta), sinT = Math.sin(theta)
const h = R2 + R1 * cosT // radial distance from axis to surface point
for (let phi = 0; phi < 2 * Math.PI; phi += 0.02) {
const cosP = Math.cos(phi), sinP = Math.sin(phi)
// y-component after rotating by A around X-axis (before B spin)
const ycomp = sinP * h * cosA - sinT * sinA
// z depth after A rotation (determines perspective + occlusion)
const zDist = sinP * h * sinA + sinT * cosA + K2
if (zDist <= 0) continue
const ooz = 1 / zDist
// Project to screen (B rotates around Z in screen space)
const xp = Math.round(cx + K1 * ooz * (cosP * h * cosB - ycomp * sinB))
const yp = Math.round(cy - K1 * ooz * (cosP * h * sinB + ycomp * cosB) * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
// a1k0n's luminance: dot(rotated_normal, light_direction)
// Produces smooth gradient across the tube with strong highlight on top
const L = (sinT * sinA - sinP * cosT * cosA) * cosB
- sinP * cosT * sinA
- sinT * cosA
- cosP * cosT * sinB
if (L > 0 && ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
}
}
}
function drawHelix(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K2 = 6.0
const scale = Math.min(cols * aspect, rows) * 0.5
const K1 = scale
const B = t * 0.0006
const cosB = Math.cos(B), sinB = Math.sin(B)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.707, lz = -0.707
const helixR = 1.2, helixH = 3.8, turns = 3
function plot(px: number, py: number, pz: number, intensity: number) {
const rx = px * cosB + pz * sinB
const rz = -px * sinB + pz * cosB
const zDist = K2 - rz
if (zDist <= 0) return
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * py * ooz * aspect)
if (xp >= 0 && xp < cols && yp >= 0 && yp < rows && ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(intensity * (chars.length - 1)), chars.length - 1)]
}
}
const pStep = 0.025
for (let phi = 0; phi < turns * 2 * Math.PI; phi += pStep) {
const y = (phi / (turns * 2 * Math.PI) - 0.5) * helixH * 2
// Strand 1
const x1 = helixR * Math.cos(phi), z1 = helixR * Math.sin(phi)
plot(x1, y, z1, Math.max(0.15, Math.cos(phi) * lx + Math.sin(phi) * lz * 0.5 + 0.55))
// Strand 2 (π offset)
const x2 = helixR * Math.cos(phi + Math.PI), z2 = helixR * Math.sin(phi + Math.PI)
plot(x2, y, z2, Math.max(0.15, Math.cos(phi + Math.PI) * lx + Math.sin(phi + Math.PI) * lz * 0.5 + 0.55))
// Rungs every half-turn
if (phi % (Math.PI * 0.5) < pStep * 1.5) {
for (let s = 0; s <= 14; s++) {
const f = s / 14
plot(x1 + (x2 - x1) * f, y, z1 + (z2 - z1) * f, 0.45)
}
}
}
}
function drawTrefoilKnot(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0008, B = t * 0.0005
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K2 = 5.0
const screenScale = Math.min(cols * aspect, rows) * 0.85
const K1 = screenScale * K2 / (K2 + 3.5)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const tubeR = 0.28
const steps = 400
const tubeSteps = 20
const lx = 0.577, ly = 0.577, lz = -0.577
const EPS = 0.02
function knotPt(th: number): [number, number, number] {
return [
(2 + Math.cos(3 * th)) * Math.cos(2 * th),
(2 + Math.cos(3 * th)) * Math.sin(2 * th),
Math.sin(3 * th) * 2,
]
}
function rotYX(px: number, py: number, pz: number): [number, number, number] {
const rx1 = px * cosA + pz * sinA
const ry1 = py
const rz1 = -px * sinA + pz * cosA
return [rx1, ry1 * cosB - rz1 * sinB, ry1 * sinB + rz1 * cosB]
}
for (let i = 0; i < steps; i++) {
const th = (i / steps) * 2 * Math.PI
const [px, py, pz] = knotPt(th)
const [px2, py2, pz2] = knotPt(th + EPS)
let tx = px2 - px, ty = py2 - py, tz = pz2 - pz
const tlen = Math.sqrt(tx * tx + ty * ty + tz * tz)
tx /= tlen; ty /= tlen; tz /= tlen
let upx = 0, upy = 0, upz = 1
let dot = tx * upx + ty * upy + tz * upz
let nx = upx - dot * tx, ny = upy - dot * ty, nz = upz - dot * tz
let nlen = Math.sqrt(nx * nx + ny * ny + nz * nz)
if (nlen < 0.001) {
upx = 0; upy = 1; upz = 0
dot = tx * upx + ty * upy + tz * upz
nx = upx - dot * tx; ny = upy - dot * ty; nz = upz - dot * tz
nlen = Math.sqrt(nx * nx + ny * ny + nz * nz)
}
nx /= nlen; ny /= nlen; nz /= nlen
const bx = ty * nz - tz * ny, by = tz * nx - tx * nz, bz = tx * ny - ty * nx
for (let j = 0; j < tubeSteps; j++) {
const u = (j / tubeSteps) * 2 * Math.PI
const cu = Math.cos(u), su = Math.sin(u)
const qx = px + tubeR * (cu * nx + su * bx)
const qy = py + tubeR * (cu * ny + su * by)
const qz = pz + tubeR * (cu * nz + su * bz)
const [rx, ry, rz] = rotYX(qx, qy, qz)
const zDist = K2 - rz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
const snx = cu * nx + su * bx, sny = cu * ny + su * by, snz = cu * nz + su * bz
const [rnx, rny, rnz] = rotYX(snx, sny, snz)
const L = Math.max(0, rnx * lx + rny * ly + rnz * lz)
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
const intensity = 0.15 + L * 0.85
grid[yp][xp] = chars[Math.min(Math.floor(intensity * (chars.length - 1)), chars.length - 1)]
}
}
}
}
function drawGeodesicDome(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0004, B = t * 0.00025
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K2 = 5.0
const screenScale = Math.min(cols * aspect, rows) * 0.85
const K1 = screenScale * K2 / (K2 + 1.05)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.577, ly = 0.577, lz = -0.577
function norm3(v: [number, number, number]): [number, number, number] {
const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
return [v[0] / len, v[1] / len, v[2] / len]
}
function rotYX(px: number, py: number, pz: number): [number, number, number] {
const rx1 = px * cosA + pz * sinA
const ry1 = py
const rz1 = -px * sinA + pz * cosA
return [rx1, ry1 * cosB - rz1 * sinB, ry1 * sinB + rz1 * cosB]
}
const phi = (1 + Math.sqrt(5)) / 2
const rawV: [number, number, number][] = [
[0, 1, phi], [0, -1, phi], [0, 1, -phi], [0, -1, -phi],
[1, phi, 0], [-1, phi, 0], [1, -phi, 0], [-1, -phi, 0],
[phi, 0, 1], [-phi, 0, 1], [phi, 0, -1], [-phi, 0, -1],
]
const faces: [number, number, number][] = [
[0,1,8],[0,8,4],[0,4,5],[0,5,9],[0,9,1],
[1,6,8],[8,10,4],[4,2,5],[5,11,9],[9,7,1],
[6,10,8],[10,2,4],[2,11,5],[11,7,9],[7,6,1],
[3,6,7],[3,10,6],[3,2,10],[3,11,2],[3,7,11],
]
const freq = 3
function plotEdge(v1: [number, number, number], v2: [number, number, number]) {
if (v1[1] < -0.3 && v2[1] < -0.3) return
for (let s = 0; s <= 28; s++) {
const f = s / 28
const px = v1[0] + f * (v2[0] - v1[0])
const py = v1[1] + f * (v2[1] - v1[1])
const pz = v1[2] + f * (v2[2] - v1[2])
const [rx, ry, rz] = rotYX(px, py, pz)
const zDist = K2 - rz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
const [rnx, rny, rnz] = rotYX(px, py, pz)
const L = Math.max(0.25, rnx * lx + rny * ly + rnz * lz)
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
}
}
for (const [ai, bi, ci] of faces) {
const va = norm3(rawV[ai]), vb = norm3(rawV[bi]), vc = norm3(rawV[ci])
const pts: [number, number, number][][] = []
for (let i = 0; i <= freq; i++) {
pts[i] = []
for (let j = 0; j <= freq - i; j++) {
const k = freq - i - j
pts[i][j] = norm3([(i * va[0] + j * vb[0] + k * vc[0]) / freq,
(i * va[1] + j * vb[1] + k * vc[1]) / freq,
(i * va[2] + j * vb[2] + k * vc[2]) / freq])
}
}
for (let i = 0; i <= freq; i++) {
for (let j = 0; j <= freq - i; j++) {
if (j + 1 <= freq - i) plotEdge(pts[i][j], pts[i][j + 1])
if (i + 1 <= freq && j <= freq - (i + 1)) plotEdge(pts[i][j], pts[i + 1][j])
if (i + 1 <= freq && j >= 1) plotEdge(pts[i][j], pts[i + 1][j - 1])
}
}
}
}
function drawSaturn(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0007, B = t * 0.0004
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K2 = 5.0
const screenScale = Math.min(cols * aspect, rows) * 0.85
const K1 = screenScale * K2 / (K2 + 2.5)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.577, ly = 0.577, lz = -0.577
const TILT = 0.47
const sinTilt = Math.sin(TILT), cosTilt = Math.cos(TILT)
function rotYX(px: number, py: number, pz: number): [number, number, number] {
const rx1 = px * cosA + pz * sinA
const ry1 = py
const rz1 = -px * sinA + pz * cosA
return [rx1, ry1 * cosB - rz1 * sinB, ry1 * sinB + rz1 * cosB]
}
function plotPoint(px: number, py: number, pz: number, nx: number, ny: number, nz: number) {
const [rx, ry, rz] = rotYX(px, py, pz)
const zDist = K2 - rz
if (zDist <= 0) return
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) return
const [rnx, rny, rnz] = rotYX(nx, ny, nz)
const L = Math.max(0, rnx * lx + rny * ly + rnz * lz)
if (L > 0 && ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
}
// Planet sphere — tighter sampling for denser coverage
for (let theta = 0; theta < 2 * Math.PI; theta += 0.04) {
const cosT = Math.cos(theta), sinT = Math.sin(theta)
for (let phi = 0; phi < 2 * Math.PI; phi += 0.02) {
const cosP = Math.cos(phi), sinP = Math.sin(phi)
const px = cosT * cosP, py = cosT * sinP, pz = sinT
plotPoint(px, py, pz, px, py, pz)
}
}
// Rings — solid swept disk sampled at many radii (not just sparse bands)
const R_inner = 1.35, R_outer = 2.45
const ringSamples = 40
for (let ri = 0; ri <= ringSamples; ri++) {
const rFrac = ri / ringSamples
// Cassini division gap
if (rFrac > 0.42 && rFrac < 0.58) continue
const r = R_inner + (R_outer - R_inner) * rFrac
// Brightness: bright B ring (inner), dark C ring hint, Cassini gap, bright A ring (outer)
const ringBrightness = rFrac < 0.42
? 0.35 + rFrac * 1.1 // B ring — bright, brighter toward gap
: 0.25 + (1 - rFrac) * 0.9 // A ring — bright at inner edge, fades outward
for (let theta = 0; theta < 2 * Math.PI; theta += 0.01) {
const cosT = Math.cos(theta), sinT = Math.sin(theta)
const px = r * cosT
const py = -r * sinT * sinTilt
const pz = r * sinT * cosTilt
const [rx, ry, rz] = rotYX(px, py, pz)
const zDist = K2 - rz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(ringBrightness * (chars.length - 1)), chars.length - 1)]
}
}
}
}
function drawHyperboloid(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0006, B = t * 0.0003
const cosA = Math.cos(A), sinA = Math.sin(A)
const cosB = Math.cos(B), sinB = Math.sin(B)
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const H = 1.7
const K2 = 6.0
const maxExtent = Math.sqrt(1 + H * H) + 0.1
const screenScale = Math.min(cols * aspect, rows) * 0.85
const K1 = screenScale * K2 / (K2 + maxExtent)
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.577, ly = 0.577, lz = -0.577
const nRulings = 32
const nPoints = 60
function rotYX(px: number, py: number, pz: number): [number, number, number] {
const rx1 = px * cosA + pz * sinA
const ry1 = py
const rz1 = -px * sinA + pz * cosA
return [rx1, ry1 * cosB - rz1 * sinB, ry1 * sinB + rz1 * cosB]
}
function plotRulingPoint(px: number, py: number, pz: number) {
const [rx, ry, rz] = rotYX(px, py, pz)
const zDist = K2 - rz
if (zDist <= 0) return
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) return
// Outward normal of hyperboloid x²+z²-y²=1 at (x,y,z) is (x,-y,z)/|(x,-y,z)|
const nm = Math.sqrt(px * px + py * py + pz * pz)
const [rnx, rny, rnz] = rotYX(px / nm, -py / nm, pz / nm)
const L = Math.max(0.12, rnx * lx + rny * ly + rnz * lz)
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(L * (chars.length - 1)), chars.length - 1)]
}
}
// Two families of straight-line rulings on x²+z²-y²=1
// Family A: P(s,v) = (cos(s)+v·sin(s), v, sin(s)-v·cos(s))
// Family B: P(s,v) = (cos(s)-v·sin(s), v, sin(s)+v·cos(s))
for (let k = 0; k < nRulings; k++) {
const s = (k / nRulings) * 2 * Math.PI
const cosS = Math.cos(s), sinS = Math.sin(s)
for (let family = 0; family < 2; family++) {
const dir = family === 0 ? 1 : -1
for (let j = 0; j <= nPoints; j++) {
const v = -H + (2 * H * j) / nPoints
plotRulingPoint(cosS + v * dir * sinS, v, sinS - v * dir * cosS)
}
}
}
// Horizontal rings at multiple levels for structural definition
for (let j = 0; j <= 80; j++) {
const theta = (j / 80) * 2 * Math.PI
for (const vy of [-H, -H * 0.75, -H * 0.5, -H * 0.25, 0, H * 0.25, H * 0.5, H * 0.75, H]) {
const r = Math.sqrt(1 + vy * vy)
plotRulingPoint(r * Math.cos(theta), vy, r * Math.sin(theta))
}
}
}
function drawDNA(grid: string[][], cols: number, rows: number, t: number, chars: string[]): void {
const A = t * 0.0005
const cosA = Math.cos(A), sinA = Math.sin(A)
const cx = cols / 2, cy = rows / 2
const aspect = 0.5
const K2 = 6.0
const scale = Math.min(cols * aspect, rows) * 0.52
const K1 = scale
const zbuf: number[][] = Array.from({ length: rows }, () => Array(cols).fill(-Infinity))
const lx = 0.707, lz = -0.707
const helixR = 1.0
const helixH = 4.2
const turns = 4.0
const tubeR = 0.22
const bpPerTurn = 10
const totalSteps = Math.round(turns * bpPerTurn * 6)
// B-DNA: strands are ~150° apart (minor groove ~120°, major groove ~240°)
const strandOffset = (150 / 180) * Math.PI
function rotY(px: number, py: number, pz: number): [number, number, number] {
return [px * cosA + pz * sinA, py, -px * sinA + pz * cosA]
}
function plotTubeSection(px: number, py: number, pz: number,
tx: number, ty: number, tz: number, r: number, baseBrightness: number) {
let upx = 0, upy = 0, upz = 1
let dot = tx * upx + ty * upy + tz * upz
let nx = upx - dot * tx, ny = upy - dot * ty, nz = upz - dot * tz
let nlen = Math.sqrt(nx * nx + ny * ny + nz * nz)
if (nlen < 0.001) {
upx = 0; upy = 1; upz = 0
dot = tx * upx + ty * upy + tz * upz
nx = upx - dot * tx; ny = upy - dot * ty; nz = upz - dot * tz
nlen = Math.sqrt(nx * nx + ny * ny + nz * nz)
}
nx /= nlen; ny /= nlen; nz /= nlen
const bx = ty * nz - tz * ny, by = tz * nx - tx * nz, bz = tx * ny - ty * nx
for (let j = 0; j < 16; j++) {
const u = (j / 16) * 2 * Math.PI
const cu = Math.cos(u), su = Math.sin(u)
const qx = px + r * (cu * nx + su * bx)
const qy = py + r * (cu * ny + su * by)
const qz = pz + r * (cu * nz + su * bz)
const [rx, ry, rz] = rotY(qx, qy, qz)
const zDist = K2 - rz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * rx * ooz)
const yp = Math.round(cy - K1 * ry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
const snx = cu * nx + su * bx, snz = cu * nz + su * bz
const [rnx,, rnz] = rotY(snx, 0, snz)
const L = Math.max(0, rnx * lx + rnz * lz)
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
const intensity = Math.max(0.12, Math.min(1, baseBrightness * (0.3 + L * 0.7)))
grid[yp][xp] = chars[Math.min(Math.floor(intensity * (chars.length - 1)), chars.length - 1)]
}
}
}
const stepsPerBP = 6
const totalAngle = turns * 2 * Math.PI
for (let i = 0; i < totalSteps; i++) {
const f = i / totalSteps
const y = -helixH + f * helixH * 2
const angle = f * totalAngle
const da = totalAngle / totalSteps
// Tangent direction (same for both strands)
const txRaw = -helixR * Math.sin(angle) * da
const tyRaw = (helixH * 2) / totalSteps
const tzRaw = helixR * Math.cos(angle) * da
const tlen = Math.sqrt(txRaw * txRaw + tyRaw * tyRaw + tzRaw * tzRaw)
const tx = txRaw / tlen, ty = tyRaw / tlen, tz = tzRaw / tlen
// Strand 1
const x1 = helixR * Math.cos(angle), z1 = helixR * Math.sin(angle)
plotTubeSection(x1, y, z1, tx, ty, tz, tubeR, 1.0)
// Strand 2 (B-DNA: 150° offset)
const x2 = helixR * Math.cos(angle + strandOffset), z2 = helixR * Math.sin(angle + strandOffset)
plotTubeSection(x2, y, z2, tx, ty, tz, tubeR, 0.72)
// Base-pair rungs every stepsPerBP
if (i % stepsPerBP === 0) {
for (let s = 0; s <= 10; s++) {
const sf = s / 10
const rx = x1 + sf * (x2 - x1)
const rz = z1 + sf * (z2 - z1)
const [vrx, vry, vrz] = rotY(rx, y, rz)
const zDist = K2 - vrz
if (zDist <= 0) continue
const ooz = 1 / zDist
const xp = Math.round(cx + K1 * vrx * ooz)
const yp = Math.round(cy - K1 * vry * ooz * aspect)
if (xp < 0 || xp >= cols || yp < 0 || yp >= rows) continue
if (ooz > zbuf[yp][xp]) {
zbuf[yp][xp] = ooz
grid[yp][xp] = chars[Math.min(Math.floor(0.42 * (chars.length - 1)), chars.length - 1)]
}
}
}
}
}
// ============================================================================
// Component factory
// ============================================================================
type DrawFn = (grid: string[][], cols: number, rows: number, t: number, chars: string[], extra?: ColState[]) => void
function makeAsciiComponent(drawFn: DrawFn, defaultCharset: AsciiCharset = 'classic') {
return React.forwardRef<HTMLPreElement, AsciiShapeProps>(
(
{
size = 'md',
charset = defaultCharset,
color,
speed = 'normal',
animated = true,
multicolor = false,
className,
...props
},
ref
) => {
const { cols, rows } = SIZE_MAP[size]
const chars = CHARSETS[charset]
const speedMul = SPEED_MAP[speed]
const matrixStateRef = React.useRef<ColState[]>(makeMatrixState(cols))
const preRef = React.useRef<HTMLPreElement | null>(null)
const spanRefs = React.useRef<(HTMLSpanElement | null)[]>([])
// Compute first frame for initial paint only — never triggers re-renders
const initialLines = React.useMemo(() => {
matrixStateRef.current = makeMatrixState(cols)
const g = makeGrid(cols, rows)
drawFn(g, cols, rows, 0, chars, matrixStateRef.current)
return gridToLines(g)
}, [cols, rows, chars])
React.useEffect(() => {
const pre = preRef.current
if (!pre) return
function writeLines(lines: string[]) {
if (multicolor) {
spanRefs.current.forEach((span, i) => {
if (span) span.textContent = lines[i] ?? ''
})
} else {
pre!.textContent = lines.join('\n')
}
}
if (!animated) {
writeLines(initialLines)
return
}
const startTime = performance.now()
let rafId = 0
let onScreen = true
// One frame, without scheduling the next.
function draw(now: number) {
const t = (now - startTime) * speedMul
const g = makeGrid(cols, rows)
drawFn(g, cols, rows, t, chars, matrixStateRef.current)
writeLines(gridToLines(g))
}
function frame(now: number) {
draw(now)
rafId = requestAnimationFrame(frame)
}
// A rAF loop that rewrites textContent every frame is invisible to a
// CSS reduced-motion query, keeps allocating a cols×rows grid while
// scrolled off-screen, and keeps ticking in a background tab. The
// canvas effects already centralise all three concerns in
// canvas-effect-core; this brings the ASCII loop up to the same bar.
function shouldAnimate() {
return onScreen && !document.hidden && !prefersReducedMotion()
}
function syncLoop() {
if (shouldAnimate()) {
if (!rafId) rafId = requestAnimationFrame(frame)
} else if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
}
// Always paint one frame so a paused/reduced-motion shape isn't blank.
draw(performance.now())
syncLoop()
const observer =
typeof IntersectionObserver !== 'undefined'
? new IntersectionObserver((entries) => {
onScreen = entries.some((e) => e.isIntersecting)
syncLoop()
})
: null
observer?.observe(pre)
document.addEventListener('visibilitychange', syncLoop)
const unsubscribeMotion = onReducedMotionChange(syncLoop)
return () => {
if (rafId) cancelAnimationFrame(rafId)
observer?.disconnect()
document.removeEventListener('visibilitychange', syncLoop)
unsubscribeMotion()
}
// Keyed on the public props: cols/rows/chars/speedMul/initialLines are
// all derived from them, and listing the derived values would restart
// the loop on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [size, charset, speed, animated, multicolor])
function combineRef(el: HTMLPreElement | null) {
preRef.current = el
if (typeof ref === 'function') ref(el)
else if (ref) (ref as React.MutableRefObject<HTMLPreElement | null>).current = el
}
return (
<pre
aria-hidden="true"
ref={combineRef}
className={cn(
'inline-block border-3 border-foreground shadow-[4px_4px_0px_hsl(var(--shadow-color))] bg-background overflow-hidden',
'font-mono text-xs leading-none tracking-tight select-none p-1',
className
)}
style={multicolor ? undefined : { color: color || 'currentColor' }}
{...props}
>
{multicolor
? initialLines.map((line, i) => (
<React.Fragment key={i}>
<span
ref={el => { spanRefs.current[i] = el }}
style={{ color: MULTICOLOR_PALETTE[i % MULTICOLOR_PALETTE.length] }}
>{line}</span>
{i < initialLines.length - 1 && '\n'}
</React.Fragment>
))
: initialLines.join('\n')}
</pre>
)
}
)
}
// ============================================================================
// Named exports — 17 ASCII shape components
// ============================================================================
export const AsciiSpiral = makeAsciiComponent(drawSpiral, 'classic')
AsciiSpiral.displayName = 'AsciiSpiral'
export const AsciiRose = makeAsciiComponent(drawRose, 'braille')
AsciiRose.displayName = 'AsciiRose'
export const AsciiWave = makeAsciiComponent(drawWave, 'classic')
AsciiWave.displayName = 'AsciiWave'
export const AsciiVortex = makeAsciiComponent(drawVortex, 'blocks')
AsciiVortex.displayName = 'AsciiVortex'
export const AsciiPulse = makeAsciiComponent(drawPulse, 'dots')
AsciiPulse.displayName = 'AsciiPulse'
export const AsciiMatrix = makeAsciiComponent(
(g, cols, rows, t, chars, state) => drawMatrix(g, cols, rows, t, chars, state!), 'classic'
)
AsciiMatrix.displayName = 'AsciiMatrix'
export const AsciiGrid = makeAsciiComponent(drawGrid, 'line')
AsciiGrid.displayName = 'AsciiGrid'
export const AsciiTorus = makeAsciiComponent(drawTorus, 'blocks')
AsciiTorus.displayName = 'AsciiTorus'
export const AsciiSphere = makeAsciiComponent(drawSphere, 'classic')
AsciiSphere.displayName = 'AsciiSphere'
export const AsciiCube = makeAsciiComponent(drawCube, 'blocks')
AsciiCube.displayName = 'AsciiCube'
export const AsciiHelix = makeAsciiComponent(drawHelix, 'braille')
AsciiHelix.displayName = 'AsciiHelix'
export const AsciiDonut = makeAsciiComponent(drawDonut, 'classic')
AsciiDonut.displayName = 'AsciiDonut'
export const AsciiTrefoilKnot = makeAsciiComponent(drawTrefoilKnot, 'blocks')
AsciiTrefoilKnot.displayName = 'AsciiTrefoilKnot'
export const AsciiGeodesicDome = makeAsciiComponent(drawGeodesicDome, 'classic')
AsciiGeodesicDome.displayName = 'AsciiGeodesicDome'
export const AsciiSaturn = makeAsciiComponent(drawSaturn, 'blocks')
AsciiSaturn.displayName = 'AsciiSaturn'
export const AsciiHyperboloid = makeAsciiComponent(drawHyperboloid, 'classic')
AsciiHyperboloid.displayName = 'AsciiHyperboloid'
export const AsciiDNA = makeAsciiComponent(drawDNA, 'braille')
AsciiDNA.displayName = 'AsciiDNA'