Characters tumble upright one after another as you scroll.
No scrub smoothing in Safari before 26 — falls back to IntersectionObserver.
export function scrollRotateIn(el) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}
el.setAttribute('aria-label', el.textContent ?? '')
const split = splitChars(el)
const easing = 'linear(0.0000,0.0750,0.1461,0.2135,0.2772,0.3374,0.3942,0.4476,0.4978,0.5448,0.5889,0.6299,0.6682,0.7037,0.7366,0.7670,0.7949,0.8205,0.8439,0.8651,0.8844,0.9017,0.9172,0.9309,0.9431,0.9537,0.9630,0.9709,0.9776,0.9831,0.9877,0.9914,0.9942,0.9964,0.9979,0.9989,0.9995,0.9999,1.0000,1.0000)'
const n = split.chars.length
const spanPercent = Math.min(100, (100 / n) * 4)
const cancels = split.chars.map((c, i) => {
c.style.transform = 'rotate(40deg) translateY(60%)'
c.style.opacity = '0'
const startPercent = (i / Math.max(1, n - 1)) * (100 - spanPercent)
return scrollScrub(
c,
[
{ transform: 'rotate(40deg) translateY(60%)', opacity: 0 },
{ transform: 'none', opacity: 1 },
],
{ easing },
el,
0.85,
0.25,
startPercent,
startPercent + spanPercent,
)
})
return () => {
cancels.forEach((cancel) => 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')
},
}
}
function scrollScrub(target, keyframes, options, subject, startVh, endVh, windowStart = 0, windowEnd = 100) {
const container = subject.closest('[data-scroll-demo]')
const vh = (container ? container.clientHeight : window.innerHeight) || document.documentElement.clientHeight
const h = subject.getBoundingClientRect().height || 1
const coverAt = (vhFraction) => Math.min(100, Math.max(0, (((1 - vhFraction) * vh) / (vh + h)) * 100))
const coverStart = coverAt(startVh)
const coverEnd = coverAt(endVh)
const span = coverEnd - coverStart
const rangeStartPercent = coverStart + (windowStart / 100) * span
const rangeEndPercent = coverStart + (windowEnd / 100) * span
const ViewTimelineCtor = window.ViewTimeline
if (ViewTimelineCtor) {
const timeline = new ViewTimelineCtor({ subject, axis: 'block' })
const anim = target.animate(keyframes, {
fill: 'both',
...options,
timeline,
rangeStart: `cover ${rangeStartPercent}%`,
rangeEnd: `cover ${rangeEndPercent}%`,
})
return () => anim.cancel()
}
const anim = target.animate(keyframes, { ...options, duration: 1000, fill: 'both' })
anim.pause()
const update = (rect) => {
const containerTop = container ? container.getBoundingClientRect().top : 0
const relativeTop = rect.top - containerTop
const rawPercent = ((vh - relativeTop) / (vh + rect.height)) * 100
const windowed = (rawPercent - rangeStartPercent) / (rangeEndPercent - rangeStartPercent)
anim.currentTime = Math.min(1, Math.max(0, windowed)) * 1000
}
const observer = new IntersectionObserver(([entry]) => update(entry.boundingClientRect), {
root: container,
threshold: Array.from({ length: 41 }, (_, i) => i / 40),
})
observer.observe(subject)
return () => {
observer.disconnect()
anim.cancel()
}
}