Scroll Scale In

scrollPartial

Text scales up to full size as it scrolls to the center of the screen.

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 scrollScaleIn(el) {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}

  el.style.transform = 'scale(0.6)'
  el.style.transformOrigin = '50% 50%'
  const easing = 'linear'
  const cancel = scrollScrub(el, [{ transform: 'scale(0.6)' }, { transform: 'scale(1)' }], { easing }, el, 0.85, 0.3)
  return () => {
    cancel()
    el.style.transform = ''
    el.style.transformOrigin = ''
  }
}

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