proof/docs/ascii
ascii

ascii

Renders a word as an animated field of characters, plus box-drawing tables and section rules that fit any width. The headers on this site are built with it.

  • 2.5 KB gzipped
  • 0 dependencies
  • 1 file

Try it

click the field to drop a ripple

Install

Download the file into your project and import it. There is no package and no build step, so the code is yours to change.

curl -O /proof/r/ascii.js

Usage

example.js
import { field, table, rules } from './ascii.js'

field(document.querySelector('pre.hero'), { word: 'proof', rows: 20 })

table(document.querySelector('pre.index'), [
  ['ink', 'brush ink on any element', 'after Rough.js', '/docs/ink'],
])

rules() // fills every [data-rule] with ──[ name ]────

API

field(pre, options)

optiontypedefaultwhat it does
wordstring'proof'The word drawn in characters.
rowsnumber16Height of the field, in lines.
rampstring' .·:-=+*#%@'Characters from empty to dense.
faceCSS font'800 100px Martian Mono'The font the word is drawn with. The size is replaced.

Returns

set(word)Changes the word and redraws the field.
destroy()Stops the animation and the resize and visibility observers.

Also exported

table(pre, rows, { min }?)A box-drawing table. Rows are [name, description, aside, href]. The aside column drops on narrow screens, and min sets the narrowest middle column. Returns { destroy }.
rules(root?)Fills each [data-rule] element with a rule that fits its width. Call it once per page. Returns { destroy }.

Notes

  • Use a monospace font on the pre. The field measures one character to know its grid.
  • The field pauses off screen and stays still for readers who ask for reduced motion.

Source

The whole library. Read it, copy it, change it.

ascii.js (2.5 KB gzipped)download
// ascii.js from proof, by Alok Ranjan (https://alokranjan.me). MIT license.
const RAMP = ' .·:-=+*#%@'

function cell(pre) {
  const probe = document.createElement('span')
  probe.textContent = 'M'.repeat(20)
  probe.style.cssText = 'position:absolute;visibility:hidden;white-space:pre'
  pre.append(probe)
  const r = probe.getBoundingClientRect()
  probe.remove()
  return { w: r.width / 20, h: parseFloat(getComputedStyle(pre).lineHeight) || r.height }
}

// renders a word as a live character field: the word is the dense core, a slow wave is the haze, the pointer drops ripples
export function field(pre, { word = 'proof', face = '800 100px Martian Mono', ramp = RAMP, rows = 16 } = {}) {
  const still = matchMedia('(prefers-reduced-motion: reduce)').matches
  const mask = document.createElement('canvas').getContext('2d', { willReadFrequently: true })
  let cols = 0, data = null, px = -1, py = -1, drops = [], raf = 0, seen = true, last = 0

  function layout() {
    const { w } = cell(pre)
    cols = Math.max(20, Math.floor(pre.clientWidth / w))
    mask.canvas.width = cols
    mask.canvas.height = rows
    mask.fillStyle = '#000'
    mask.fillRect(0, 0, cols, rows)
    mask.fillStyle = '#fff'
    mask.textBaseline = 'middle'
    mask.textAlign = 'left'
    // characters are about twice as tall as wide, so the word is drawn squashed to come out right
    let size = rows * 2.3
    mask.font = face.replace(/\d+px/, size + 'px')
    // on narrow screens shrink the word to fit instead of cropping it
    const fits = (cols - 2) / mask.measureText(word).width
    if (fits < 1) mask.font = face.replace(/\d+px/, (size *= fits) + 'px')
    mask.save()
    mask.scale(1, 0.5)
    const wide = mask.measureText(word).width
    mask.fillText(word, Math.max(1, (cols - wide) * 0.06), rows)
    mask.restore()
    data = mask.getImageData(0, 0, cols, rows).data
  }

  // the only function that schedules itself, so there is never more than one loop
  function loop(t) {
    raf = seen && !still ? requestAnimationFrame(loop) : 0
    if (t - last < 40) return
    last = t
    paint(t)
  }

  function paint(t) {
    const T = t / 1000
    drops = drops.filter(d => T - d.t < 2.4)
    let out = ''
    for (let y = 0; y < rows; y++) {
      for (let x = 0; x < cols; x++) {
        const core = data[(y * cols + x) * 4] / 255
        let v = core * 0.92
        const haze = (Math.sin(x * 0.11 + T * 0.8) + Math.sin(y * 0.45 - T * 0.6 + x * 0.05)) * 0.5
        v += haze > 0.55 ? (haze - 0.55) * 0.35 : 0
        for (const d of drops) {
          const dist = Math.hypot((x - d.x) * 0.5, y - d.y), age = T - d.t
          v += Math.cos((dist - age * 14) * 0.9) * Math.max(0, 1 - Math.abs(dist - age * 14) / 3) * (1 - age / 2.4) * 0.5
        }
        if (px >= 0) v += Math.max(0, 1 - Math.hypot((x - px) * 0.5, y - py) / 5) * 0.35
        out += ramp[Math.max(0, Math.min(ramp.length - 1, Math.round(v * (ramp.length - 1))))]
      }
      out += '\n'
    }
    pre.textContent = out
  }

  const off = new AbortController(), on = { signal: off.signal }
  let gone = false
  const at = e => { const r = pre.getBoundingClientRect(), { w, h } = cell(pre); return [(e.clientX - r.left) / w, (e.clientY - r.top) / h] }
  pre.addEventListener('pointermove', e => { [px, py] = at(e) }, on)
  pre.addEventListener('pointerleave', () => { px = py = -1 }, on)
  pre.addEventListener('pointerdown', e => { const [x, y] = at(e); drops.push({ x, y, t: performance.now() / 1000 }) }, on)
  const redraw = () => { if (!gone) { layout(); paint(performance.now()) } }
  const ro = new ResizeObserver(redraw)
  const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen && !raf && !still) raf = requestAnimationFrame(loop) })
  ro.observe(pre)
  io.observe(pre)
  redraw()
  // canvas falls back to a system font until the web font loads, so redraw once it arrives
  document.fonts?.load(face).then(redraw)
  return {
    set(next) { word = next; redraw() },
    destroy() { gone = true; off.abort(); ro.disconnect(); io.disconnect(); cancelAnimationFrame(raf); raf = 0; seen = false },
  }
}

// box-drawing table sized to whatever width it gets; the middle column absorbs the slack
export function table(pre, rows, { min = 6 } = {}) {
  const draw = () => {
    const { w } = cell(pre)
    const cols = Math.max(30, Math.floor(pre.clientWidth / w))
    const a = Math.max(...rows.map(r => r[0].length)) + 2
    const c = Math.min(Math.max(...rows.map(r => r[2].length)) + 2, Math.floor(cols * 0.32))
    const narrow = cols < 56
    const b = narrow ? cols - a - 3 : cols - a - c - 4
    const fit = (s, n) => (s.length > n - 2 ? s.slice(0, n - 3) + '…' : s).padEnd(n - 2)
    const rule = (l, m, r) => l + '─'.repeat(a) + m + '─'.repeat(Math.max(min, b)) + (narrow ? '' : m + '─'.repeat(c)) + r
    pre.replaceChildren()
    const add = (txt, href) => { if (href) { const link = document.createElement('a'); link.href = href; link.textContent = txt; pre.append(link) } else pre.append(txt) }
    add(rule('┌', '┬', '┐') + '\n')
    rows.forEach((r, i) => {
      add('│ ')
      add(fit(r[0], a), r[3])
      add(' │ ' + fit(r[1], Math.max(min, b)) + (narrow ? ' │' : ' │ ' + fit(r[2], c) + ' │') + '\n')
      add(i < rows.length - 1 ? rule('├', '┼', '┤') + '\n' : rule('└', '┴', '┘'))
    })
  }
  const ro = new ResizeObserver(draw)
  ro.observe(pre)
  draw()
  return { destroy() { ro.disconnect() } }
}

// a rule like ──[ ink ]────── that always fills its line
export function rules(root = document) {
  const els = [...root.querySelectorAll('[data-rule]')]
  const draw = () => els.forEach(el => {
    const { w } = cell(el)
    const n = Math.floor(el.clientWidth / w)
    const head = `──[ ${el.dataset.rule} ]`
    el.textContent = head + '─'.repeat(Math.max(0, n - head.length))
  })
  const ro = new ResizeObserver(draw)
  ro.observe(document.body)
  draw()
  return { destroy() { ro.disconnect() } }
}