Characters flip up from a backward tilt into place.
export function charRotateX(el) {
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const _duration = _reducedMotion ? 0.01 : 0.6
const _stagger = _reducedMotion ? 0 : 0.03
const _delay = _reducedMotion ? 0 : 0
el.style.perspective = '800px'
const split = splitChars(el)
const easing = 'linear(0.0000,0.0778,0.1521,0.2228,0.2901,0.3540,0.4146,0.4720,0.5264,0.5776,0.6259,0.6713,0.7139,0.7538,0.7910,0.8256,0.8578,0.8875,0.9149,0.9400,0.9630,0.9838,1.0027,1.0196,1.0346,1.0478,1.0594,1.0693,1.0777,1.0846,1.0901,1.0943,1.0973,1.0991,1.0998,1.0996,1.0984,1.0964,1.0936,1.0902,1.0861,1.0816,1.0765,1.0712,1.0655,1.0596,1.0537,1.0476,1.0416,1.0357,1.0300,1.0245,1.0194,1.0147,1.0106,1.0070,1.0040,1.0018,1.0005,1.0000)'
const anims = split.chars.map((c, i) => {
c.style.transformOrigin = '50% 50% -60px'
return c.animate(
[
{ transform: 'rotateX(-90deg)', opacity: 0 },
{ transform: 'rotateX(0deg)', opacity: 1 },
],
{
duration: _duration * 1000,
delay: (_delay + i * _stagger) * 1000,
easing,
fill: 'backwards',
},
)
})
return () => {
anims.forEach((a) => a.cancel())
split.revert()
el.style.perspective = ''
}
}
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;
}