Characters pop in from nothing in a random order.
export function scalePop(el) {
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const _duration = _reducedMotion ? 0.01 : 0.5
const _stagger = _reducedMotion ? 0 : 0.03
const _delay = _reducedMotion ? 0 : 0
const split = splitChars(el)
const easing = 'linear(0.0000,0.0827,0.1616,0.2365,0.3077,0.3753,0.4392,0.4997,0.5567,0.6105,0.6610,0.7083,0.7526,0.7939,0.8324,0.8680,0.9010,0.9313,0.9591,0.9844,1.0074,1.0281,1.0467,1.0631,1.0775,1.0901,1.1007,1.1097,1.1170,1.1227,1.1269,1.1298,1.1313,1.1317,1.1309,1.1290,1.1262,1.1225,1.1181,1.1130,1.1072,1.1010,1.0943,1.0873,1.0800,1.0725,1.0650,1.0575,1.0501,1.0428,1.0359,1.0293,1.0231,1.0175,1.0125,1.0083,1.0048,1.0022,1.0006,1.0000)'
const order = split.chars.map((_, i) => i)
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[order[i], order[j]] = [order[j], order[i]]
}
const anims = split.chars.map((c, i) =>
c.animate(
[
{ transform: 'scale(0)', opacity: 0 },
{ transform: 'scale(1)', opacity: 1 },
],
{
duration: _duration * 1000,
delay: (_delay + order[i] * _stagger) * 1000,
easing,
fill: 'backwards',
},
),
)
return () => {
anims.forEach((a) => a.cancel())
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')
},
}
}
/* CSS */
.k-word {
display: inline-block;
white-space: nowrap;
}
.k-char {
display: inline-block;
}