Text sharpens into focus as it scrolls toward the center of the screen.
No scrub smoothing in Safari before 26 — falls back to IntersectionObserver.
export function scrollBlurFocus(el) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}
el.style.filter = 'blur(10px)'
el.style.opacity = '0.4'
const easing = 'linear'
const cancel = scrollScrub(
el,
[
{ filter: 'blur(10px)', opacity: 0.4 },
{ filter: 'blur(0px)', opacity: 1 },
],
{ easing },
el,
0.85,
0.35,
)
return () => {
cancel()
el.style.filter = ''
el.style.opacity = ''
}
}
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()
}
}