Hover or focus to scramble the text before it decodes back.
export function charScramble(el) {
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const _duration = _reducedMotion ? 0.35 : 0.5
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const randChar = () => CHARS[Math.floor(Math.random() * CHARS.length)]
const text = el.textContent ?? ''
const chars = [...text]
const points = [0, 1]
const revealDelay = 0.2
const durationMs = _duration * 1000
let raf = 0
let start = null
let running = false
const tick = (now) => {
if (!running) return
if (start === null) start = now
const elapsed = now - start
const t = Math.min(1, elapsed / durationMs)
const progress = easeAt(points, t)
const revealT = Math.max(0, (progress - revealDelay) / (1 - revealDelay))
const revealCount = Math.floor(revealT * chars.length)
el.textContent = chars.map((c, i) => (i < revealCount || c === ' ' ? c : randChar())).join('')
if (t < 1) {
raf = requestAnimationFrame(tick)
} else {
el.textContent = text
running = false
}
}
const enter = () => {
cancelAnimationFrame(raf)
running = true
start = null
raf = requestAnimationFrame(tick)
}
const leave = () => {
running = false
cancelAnimationFrame(raf)
el.textContent = text
}
const ons = [['pointerenter', enter], ['pointerleave', leave], ['focus', enter], ['blur', leave]]
ons.forEach(([e, fn]) => el.addEventListener(e, fn))
return () => {
running = false
cancelAnimationFrame(raf)
ons.forEach(([e, fn]) => el.removeEventListener(e, fn))
el.textContent = text
}
}
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
}