Original

Ascii

ASCII art sewn in by falling threads, with a feather that trails the pointer through it.

strings (default)
scramble

Installation

npx shadcn@latest add @doffy/ascii

Or, without the registry in components.json:

npx shadcn@latest add https://ui.doffy.com/r/ascii.json

Usage

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

Show 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 }