Decode Matrix

kinetic

Each character decodes from scrambled noise, one after another.

I Love Bangladesh

17/120
Align
Ease
Duration
s
0.13
Stagger
s
00.2
Delay
s
02
export function decodeMatrix(el) {
  const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  const _duration = _reducedMotion ? 0.35 : 0.4
  const _stagger = _reducedMotion ? 0 : 0.05
  const _delay = _reducedMotion ? 0 : 0

  const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  const randChar = () => CHARS[Math.floor(Math.random() * CHARS.length)]
  el.classList.add('k-mono')
  const split = splitChars(el)
  const finals = split.chars.map((c) => c.textContent ?? '')
  const locked = finals.map((f) => f.trim() === '')
  const points = [0, 1]
  const revealDelay = 0.1
  const durationMs = _duration * 1000
  const delayMs = _delay * 1000
  let raf = 0
  let start = null
  let cancelled = false

  const tick = (now) => {
    if (cancelled) return
    if (start === null) start = now
    const elapsed = now - start
    let allLocked = true
    split.chars.forEach((c, i) => {
      if (locked[i]) return
      const localElapsed = elapsed - i * _stagger * 1000 - delayMs
      if (localElapsed < 0) {
        allLocked = false
        return
      }
      const t = Math.min(1, localElapsed / durationMs)
      const progress = easeAt(points, t)
      const revealT = Math.max(0, (progress - revealDelay) / (1 - revealDelay))
      if (revealT >= 1) {
        c.textContent = finals[i]
        locked[i] = true
      } else {
        allLocked = false
        c.textContent = randChar()
      }
    })
    if (!allLocked) {
      raf = requestAnimationFrame(tick)
    } else {
    }
  }
  raf = requestAnimationFrame(tick)

  return () => {
    cancelled = true
    cancelAnimationFrame(raf)
    el.classList.remove('k-mono')
    split.revert()
  }
}

function splitChars(el) {
  const text = el.textContent ?? ''

  if (el.children.length > 0) {
    return { chars: [el], words: [el], revert: () => {} }
  }

  const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

  const segmenter =
    typeof Intl !== 'undefined' && 'Segmenter' in Intl
      ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
      : null
  const toChars = (s) => (segmenter ? Array.from(segmenter.segment(s), (seg) => seg.segment) : Array.from(s))

  const tokens = text.match(/\S+|\s+/g) ?? []
  el.setAttribute('aria-label', text)
  el.innerHTML = tokens
    .map((token) => {
      if (/^\s+$/.test(token)) return token
      const chars = toChars(token)
        .map((c) => `<span class="k-char">${escape(c)}</span>`)
        .join('')
      return `<span class="k-word" aria-hidden="true">${chars}</span>`
    })
    .join('')

  return {
    chars: [...el.querySelectorAll('.k-char')],
    words: [...el.querySelectorAll('.k-word')],
    revert: () => {
      el.textContent = text
      el.removeAttribute('aria-label')
    },
  }
}

function easeAt(points, t) {
  const clamped = t < 0 ? 0 : t > 1 ? 1 : t
  const scaled = clamped * (points.length - 1)
  const i = Math.floor(scaled)
  const frac = scaled - i
  const a = points[i]
  const b = points[Math.min(i + 1, points.length - 1)]
  return a + (b - a) * frac
}

/* CSS */
.k-mono {
  font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
}

.k-char {
  display: inline-block;
}