Scroll Fade Stagger

scrollPartial

Characters rise and fade in one after another as you scroll.

I Love Bangladesh

Scroll inside this box
0%100%
17/120
Align
Ease

No scrub smoothing in Safari before 26 — falls back to IntersectionObserver.

export function scrollFadeStagger(el) {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}

  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 = 'translateY(120%)'
    c.style.opacity = '0'
    const startPercent = (i / Math.max(1, n - 1)) * (100 - spanPercent)
    return scrollScrub(
      c,
      [
        { transform: 'translateY(120%)', 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

  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()
  }
}