Each character decodes from scrambled noise, one after another.
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, '&').replace(/</g, '<').replace(/>/g, '>')
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;
}