# Never Drive Animation From Scroll Events

**NEVER** · **ID:** `animations-no-scroll-event-animation` · **Category:** animations
**Source:** [@Ibelick](https://www.ui-skills.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-no-scroll-event-animation

> NEVER: Drive animation from `scroll` events, `scrollY`, or `scrollTop`. Scroll is composited off the main thread, so a scroll listener is a notification that scrolling ALREADY happened — the animation is permanently one frame late and freezes outright during any long task while the page keeps scrolling under it. Throttling or rAF-wrapping the handler only makes the lag cheaper; move the timeline off the main thread instead (`animations-scroll-driven-css`).

A scroll listener that writes styles always renders one frame late and couples the effect to main-thread health

Scroll is composited off the main thread; a `scroll` listener is a notification that scrolling already happened. By the time the handler reads `scrollY` and writes a style, the browser has painted that scroll position — so the animation is permanently one frame behind, and it drifts further under load. Worse, the effect now inherits the health of the main thread: any long task (hydration, a JSON parse, a third-party script) freezes the animation while the page keeps scrolling underneath it. ibelick states the same ban twice, once as a never-pattern and once under scroll: "do not poll scroll position for animation". The fix is not to throttle or rAF-wrap the handler — that just makes the lag cheaper. It is to move the timeline itself off the main thread (see animations-scroll-driven-css).

## Bad — do not do this

`animations-no-scroll-event-animation-bad`

```tsx
import { useRef, type UIEvent } from 'react';

/**
 * BAD: the parallax hero is driven by a scroll listener.
 * The handler runs AFTER the browser has already painted that scroll offset,
 * so the effect is permanently one frame late — and it dies entirely whenever
 * the main thread is busy.
 */
export function NoScrollEventAnimationBad() {
  const heroRef = useRef<HTMLDivElement>(null);

  const handleScroll = (event: UIEvent<HTMLDivElement>) => {
    const top = event.currentTarget.scrollTop; // layout read, every single tick
    const hero = heroRef.current;
    if (!hero) return;
    // Style writes on the main thread, after paint
    hero.style.transform = `translateY(${top * 0.35}px)`;
    hero.style.opacity = String(Math.max(0, 1 - top / 220));
  };

  const blockMainThread = () => {
    const end = performance.now() + 500;
    while (performance.now() < end) {
      // Simulates a long task: hydration, a big JSON parse, a third-party script
    }
  };

  return (
    <div className="space-y-3">
      <button
        type="button"
        onClick={blockMainThread}
        className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Block the main thread for 500ms
      </button>

      <div
        onScroll={handleScroll}
        className="h-48 overflow-y-auto rounded-lg border border-border bg-muted"
      >
        <div className="sticky top-0 h-24 overflow-hidden bg-card">
          <div ref={heroRef} className="p-4">
            <p className="text-sm font-semibold text-foreground">Parallax hero</p>
            <p className="text-xs text-muted-foreground">position written by a scroll handler</p>
          </div>
        </div>
        <div className="h-96 p-4 text-xs text-muted-foreground">Scroll me, then hit the button while scrolling.</div>
      </div>

      <p className="text-xs text-destructive">
        Scroll and it already lags a frame behind. Scroll while the main thread is blocked and the hero
        freezes solid while the content keeps moving underneath it.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-no-scroll-event-animation-good`

```tsx
/**
 * GOOD: the same parallax hero on a CSS view timeline.
 * The compositor advances the animation, so it stays locked to the scroll position
 * even while the main thread is blocked. No JS runs during the scroll at all.
 */
export function NoScrollEventAnimationGood() {
  const blockMainThread = () => {
    const end = performance.now() + 500;
    while (performance.now() < end) {
      // Same 500ms long task — the animation does not care
    }
  };

  return (
    <div className="space-y-3">
      <style>{`
        @keyframes hero-parallax {
          to { transform: translateY(70px); opacity: 0; }
        }
        .sdt-scroller { scroll-timeline: --hero block; }
        .sdt-hero {
          animation: hero-parallax linear both;
          animation-timeline: --hero;
          animation-range: 0 220px;
        }
        @media (prefers-reduced-motion: reduce) {
          .sdt-hero { animation: none; }
        }
      `}</style>

      <button
        type="button"
        onClick={blockMainThread}
        className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Block the main thread for 500ms
      </button>

      <div className="sdt-scroller h-48 overflow-y-auto rounded-lg border border-border bg-muted">
        <div className="sticky top-0 h-24 overflow-hidden bg-card">
          <div className="sdt-hero p-4">
            <p className="text-sm font-semibold text-foreground">Parallax hero</p>
            <p className="text-xs text-muted-foreground">driven by scroll-timeline, zero JS</p>
          </div>
        </div>
        <div className="h-96 p-4 text-xs text-muted-foreground">Scroll me, then hit the button while scrolling.</div>
      </div>

      <p className="text-xs text-success">
        The timeline lives on the compositor: the hero tracks the scrollbar exactly, and the 500ms block
        does not stutter it. Where scroll timelines are unsupported the layout is simply static — still readable.
      </p>
    </div>
  );
}
```

## References

- [ibelick — fixing-motion-performance SKILL.md](https://raw.githubusercontent.com/ibelick/ui-skills/main/skills/fixing-motion-performance/SKILL.md)
- [MDN — CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations)
