Each word drifts upward at its own speed as the page scrolls.
No scrub smoothing in Safari before 26 — falls back to IntersectionObserver.
export function scrollParallaxWords(el) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}
const split = splitChars(el)
const easing = 'linear'
// GSAP's trigger here is start: 'top bottom', end: 'bottom top' — the
// subject's entire time anywhere on screen, i.e. the full 'cover' range.
// startVh: 1 is cover 0% exactly; endVh: -10 is an extreme past cover
// 100% that scrollScrub clamps back down to it.
const cancels = split.words.map((w, i) =>
scrollScrub(w, [{ transform: 'translateY(0%)' }, { transform: `translateY(${-(i + 1) * 35}%)` }], { easing }, el, 1, -10),
)
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()
}
}