Original
Ascii
ASCII art sewn in by falling threads, with a feather that trails the pointer through it.
}=#<+-^&#[<#^^%
]? ? $<$
#%&/!$#/#&}]#]\ /
[ > < _
} <!^!\>+}/-!_<*
^ ?/ > &+
?/}[=_!]>}?#?++Installation
npx shadcn@latest add @doffy/asciiOr, without the registry in components.json:
npx shadcn@latest add https://ui.doffy.com/r/ascii.jsonUsage
import { Ascii } from "@/components/ui/ascii"
const cube = String.raw`
.+------------+
.' | .'|
+------------+' |
| | | |
| +--------|---+
| .' | .'
+------------+'`.slice(1)
export default function AsciiDemo() {
return (
<div className="flex flex-wrap items-end justify-center gap-12">
<figure className="flex flex-col items-center gap-4">
<Ascii art={cube} label="A wireframe cube" reveal interactive className="text-base text-brand" />
<figcaption className="text-xs text-muted-foreground">strings (default)</figcaption>
</figure>
<figure className="flex flex-col items-center gap-4">
<Ascii art={cube} label="A wireframe cube" effect="scramble" reveal interactive className="text-base" />
<figcaption className="text-xs text-muted-foreground">scramble</figcaption>
</figure>
</div>
)
}
Source
ShowHide ascii.tsx
"use client"
import * as React from "react"
import { cn } from "cn"
const NOISE = "!<>-_\\/[]{}=+*^?#%@$&"
// Monospace cells are ~0.6 as wide as they are tall.
const CELL_ASPECT = 0.6
// Share of the reveal during which a cell shows a falling thread before settling.
const THREAD = 0.14
// How far the feather's barbs sweep off the quill, toward the tip.
const BARB_ANGLE = (50 * Math.PI) / 180
// Repeats as "fuffuffuffu…" from any starting point. Fuffuffu.
const QUILL = "fuf"
type AsciiEffect = "strings" | "scramble"
type AsciiProps = Omit<React.ComponentProps<"pre">, "children"> & {
/** Multiline ASCII art. */
art: string
/** Accessible description of the art. */
label: string
/**
* How characters animate. `strings` sews the art in with falling threads and
* draws a feather through it under the pointer. `scramble` uses random noise.
*/
effect?: AsciiEffect
/** Animate the art in on mount. Pass a number to set the duration in ms. */
reveal?: boolean | number
/** React to the pointer. */
interactive?: boolean
/** Pointer reach, in text rows. The feather is twice this long. */
radius?: number
}
// Deterministic per-cell noise so the server render and first client render match.
function hash(i: number) {
let x = (i + 1) * 2654435761
x ^= x >>> 16
x = Math.imul(x, 2246822507)
x ^= x >>> 13
return (x >>> 0) / 4294967295
}
// The line character that best follows (dx, dy), in screen space (y down).
function lineGlyph(dx: number, dy: number) {
const angle = Math.abs((Math.atan2(dy, dx) * 180) / Math.PI)
const fold = angle > 90 ? 180 - angle : angle
if (fold < 22.5) return "-"
if (fold > 67.5) return "|"
return dx * dy < 0 ? "/" : "\\"
}
const noise = () => NOISE[Math.floor(Math.random() * NOISE.length)]
function Ascii({
art,
label,
effect = "strings",
reveal = false,
interactive = false,
radius = 7,
className,
...props
}: AsciiProps) {
const ref = React.useRef<HTMLPreElement>(null)
const duration = typeof reveal === "number" ? reveal : 1600
// The reveal's first frame, rendered on the server: blank for strings, noise for scramble.
const initial = React.useMemo(() => {
if (!reveal) return art
let i = 0
return Array.from(art, (ch) => {
if (ch === "\n") return ch
const k = i++
if (ch === " " || effect === "strings") return " "
return NOISE[Math.floor(hash(k) * NOISE.length)]
}).join("")
}, [art, reveal, effect])
React.useEffect(() => {
const el = ref.current
if (!el || (!reveal && !interactive)) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
el.textContent = art
return
}
const lines = art.split("\n")
const rows = lines.length
const cols = Math.max(...lines.map((line) => line.length))
const cells = lines.map((line) => line.padEnd(cols, " "))
const heat = new Float32Array(rows * cols)
const feather: string[] = new Array<string>(rows * cols).fill("")
const revealAt = new Float32Array(rows * cols)
if (reveal) {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const i = r * cols + c
// Sweep left to right with jitter; strings also fall down each column.
revealAt[i] =
effect === "strings"
? duration * (THREAD + (c / cols) * 0.5 + (r / rows) * 0.22 + hash(i) * 0.14)
: duration * ((c / cols) * 0.65 + hash(i) * 0.35)
}
}
}
const start = performance.now()
let frame = 0
let last = 0
let pointer: { r: number; c: number } | null = null
// Smoothed pointer velocity and heading, in row units. The feather trails behind.
let vx = 1
let vy = 0
let headingX = 1
let headingY = 0
// A feather whose quill starts at the pointer and whose tip trails behind it.
// Barbs sweep back toward the tip; the quill itself is written in QUILL.
const stampFeather = (at: { r: number; c: number }) => {
const ax = -headingX
const ay = -headingY
const nx = -ay
const ny = ax
const length = radius * 2
const vane = radius * 0.5
const horizontal = Math.abs(ax) / CELL_ASPECT >= Math.abs(ay)
const quill = horizontal ? 0.5 : 0.3
const cos = Math.cos(BARB_ANGLE)
const sin = Math.sin(BARB_ANGLE)
const reachC = (length + 1) / CELL_ASPECT
const r0 = Math.max(0, Math.floor(at.r - length - 1))
const r1 = Math.min(rows - 1, Math.ceil(at.r + length + 1))
const c0 = Math.max(0, Math.floor(at.c - reachC))
const c1 = Math.min(cols - 1, Math.ceil(at.c + reachC))
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const x = (c + 0.5 - at.c) * CELL_ASPECT
const y = r + 0.5 - at.r
const u = x * ax + y * ay // along the quill, 0 at the pointer
const w = x * nx + y * ny // across it
if (u < -1 || u > length) continue
const i = r * cols + c
if (Math.abs(w) < quill) {
// Index by grid position so the letters stay put as the quill slides over them.
feather[i] = QUILL[(horizontal ? c : r) % QUILL.length]!
} else {
const edge = vane * Math.sin((Math.PI * Math.max(u, 0)) / length) ** 0.7
if (Math.abs(w) > edge) continue
const side = Math.sign(w)
feather[i] = lineGlyph(ax * cos + side * nx * sin, ay * cos + side * ny * sin)
}
heat[i] = 1
}
}
}
const draw = (now: number) => {
frame = 0
// ~30fps is plenty for character animation and halves the DOM work.
if (now - last < 32) {
frame = requestAnimationFrame(draw)
return
}
last = now
const t = now - start
let active = reveal ? t < duration : false
if (pointer && effect === "strings") {
stampFeather(pointer)
} else if (pointer) {
const reachC = radius / CELL_ASPECT
const r0 = Math.max(0, Math.floor(pointer.r - radius))
const r1 = Math.min(rows - 1, Math.ceil(pointer.r + radius))
const c0 = Math.max(0, Math.floor(pointer.c - reachC))
const c1 = Math.min(cols - 1, Math.ceil(pointer.c + reachC))
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const d = Math.hypot((c + 0.5 - pointer.c) * CELL_ASPECT, r + 0.5 - pointer.r)
if (d < radius) {
const i = r * cols + c
heat[i] = Math.max(heat[i]!, 1 - d / radius)
}
}
}
}
let out = ""
for (let r = 0; r < rows; r++) {
const line = cells[r]!
for (let c = 0; c < cols; c++) {
const i = r * cols + c
const ch = line[c]!
let h = heat[i]!
if (h > 0.01) {
heat[i] = h *= 0.86
active = true
} else if (h) heat[i] = h = 0
if (ch === " ") {
out += " "
} else if (t < revealAt[i]!) {
if (effect === "scramble") out += noise()
else out += t >= revealAt[i]! - duration * THREAD ? "|" : " "
} else if (effect === "strings" && h > 0.15) {
out += feather[i]
} else if (effect === "scramble" && Math.random() < h) {
out += noise()
} else {
out += ch
}
}
if (r < rows - 1) out += "\n"
}
el.textContent = out
if (active || pointer) frame = requestAnimationFrame(draw)
}
const wake = () => {
if (!frame) frame = requestAnimationFrame(draw)
}
const onMove = (event: PointerEvent) => {
const rect = el.getBoundingClientRect()
const next = {
c: ((event.clientX - rect.left) / rect.width) * cols,
r: ((event.clientY - rect.top) / rect.height) * rows,
}
if (pointer) {
vx = vx * 0.7 + (next.c - pointer.c) * CELL_ASPECT * 0.3
vy = vy * 0.7 + (next.r - pointer.r) * 0.3
const speed = Math.hypot(vx, vy)
if (speed > 0.05) {
headingX = vx / speed
headingY = vy / speed
}
}
pointer = next
wake()
}
const onLeave = () => {
pointer = null
}
if (reveal) wake()
if (interactive) {
el.addEventListener("pointermove", onMove)
el.addEventListener("pointerleave", onLeave)
}
return () => {
cancelAnimationFrame(frame)
el.removeEventListener("pointermove", onMove)
el.removeEventListener("pointerleave", onLeave)
el.textContent = art
}
}, [art, effect, reveal, duration, interactive, radius])
return (
<pre
ref={ref}
role="img"
aria-label={label}
data-slot="ascii"
suppressHydrationWarning
className={cn(
"m-0 w-fit font-mono leading-none whitespace-pre select-none",
className
)}
{...props}
>
{initial}
</pre>
)
}
export { Ascii }
export type { AsciiEffect, AsciiProps }