Hover or focus to pop each character up and back with a bounce.
export function letterPop(el) {
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const _duration = _reducedMotion ? 0.01 : 0.4
const _stagger = _reducedMotion ? 0 : 0.03
const split = splitChars(el)
const easing = 'linear(0.0000,0.0991,0.1932,0.2823,0.3667,0.4463,0.5213,0.5919,0.6581,0.7200,0.7779,0.8317,0.8817,0.9279,0.9704,1.0094,1.0450,1.0773,1.1064,1.1324,1.1555,1.1758,1.1933,1.2082,1.2207,1.2308,1.2386,1.2443,1.2480,1.2498,1.2498,1.2481,1.2449,1.2403,1.2343,1.2272,1.2189,1.2097,1.1997,1.1889,1.1775,1.1656,1.1534,1.1409,1.1282,1.1155,1.1029,1.0904,1.0784,1.0667,1.0556,1.0452,1.0355,1.0268,1.0191,1.0125,1.0072,1.0033,1.0008,1.0000)'
let anims = []
const animateTo = (transform) => {
const froms = split.chars.map((c) => getComputedStyle(c).transform)
anims.forEach((a) => a.cancel())
anims = split.chars.map((c, i) =>
c.animate([{ transform: froms[i] }, { transform }], {
duration: _duration * 1000,
delay: i * _stagger * 1000,
easing,
fill: 'forwards',
}),
)
}
const enter = () => animateTo('scale(1.3) translateY(-6px)')
const leave = () => animateTo('none')
const ons = [['pointerenter', enter], ['pointerleave', leave], ['focus', enter], ['blur', leave]]
ons.forEach(([e, fn]) => el.addEventListener(e, fn))
return () => {
anims.forEach((a) => a.cancel())
ons.forEach(([e, fn]) => el.removeEventListener(e, fn))
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;
}