# Prefer Scroll and View Timelines

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

> SHOULD: Use a CSS Scroll or View Timeline (`animation-timeline: view()`/`scroll()`) for scroll-linked motion — the compositor advances it, so it stays locked to the scrollbar even while the main thread is busy. This is still input-driven motion (`animations-input-driven`): the scroll IS the input. IntersectionObserver is a one-shot trigger, not a timeline — it cannot scrub, ignores scroll speed, and snaps on scroll-up; keep it for visibility and pausing. Chromium + Firefox 144+ only (Safari flagged), so treat it as an enhancement and make the un-animated state readable.

Scroll-linked motion belongs on a CSS timeline, not on an observer that toggles a class once and cannot scrub

This is the constructive half of the ban on scroll listeners. `animation-timeline: view()` binds an animation's progress to an element's position in the scrollport instead of to time, and the compositor advances it — so it stays locked to the scrollbar even while the main thread is busy. The common alternative, IntersectionObserver plus a class toggle, is not the same thing: it is a one-shot trigger. It fires at a single threshold, it cannot scrub, it plays at its own duration regardless of how fast you scrolled, and on scroll-up it either does nothing or snaps back. ibelick keeps IntersectionObserver for what it is actually good at — "use IntersectionObserver for visibility and pausing". Support is Chromium and Firefox 144+; Safari is still behind a flag, so treat the animation as an enhancement and make the un-animated state the readable one.

## Rule snippet

```tsx
.reveal { animation: fade-up linear both; animation-timeline: view(); animation-range: entry 0% cover 40%; }
```

## Bad — do not do this

`animations-scroll-driven-css-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * BAD: reveal-on-scroll via IntersectionObserver + a class toggle.
 * It is a one-shot trigger pretending to be a scroll-linked animation: it cannot
 * scrub, it plays at its own fixed duration however fast you scrolled, and on the
 * way back up it either snaps or does nothing.
 */
export function ScrollDrivenCssBad() {
  const [revealed, setRevealed] = useState<Set<number>>(new Set());
  const refs = useRef<(HTMLLIElement | null)[]>([]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting) continue;
          const index = Number((entry.target as HTMLElement).dataset.index);
          // Fires once, at one threshold. There is no "40% through" here.
          setRevealed((prev) => new Set(prev).add(index));
        }
      },
      { threshold: 0.5 }
    );
    for (const node of refs.current) if (node) observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return (
    <div className="space-y-3">
      <div className="h-48 overflow-y-auto rounded-lg border border-border bg-muted p-4">
        <p className="pb-40 text-xs text-muted-foreground">Scroll down, then back up.</p>
        <ul className="space-y-6 pb-8">
          {[0, 1, 2, 3].map((i) => (
            <li
              key={i}
              data-index={i}
              ref={(node) => {
                refs.current[i] = node;
              }}
              className="rounded-md border border-border bg-card p-3 text-sm text-foreground transition-[opacity,transform] duration-500"
              style={{
                opacity: revealed.has(i) ? 1 : 0,
                transform: revealed.has(i) ? 'none' : 'translateY(16px)',
              }}
            >
              Card {i + 1}
            </li>
          ))}
        </ul>
      </div>
      <p className="text-xs text-destructive">
        Scroll fast and the cards still fade at their own 500ms pace, arriving late. Scroll back up and they
        stay stuck on — the effect has no reverse, because a class toggle has no timeline.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-scroll-driven-css-good`

```tsx
/**
 * GOOD: the same reveal on a view timeline.
 * Progress is bound to the card's position in the scrollport, not to a clock — so it
 * scrubs in both directions, tracks the scrollbar exactly, and needs no JS at all.
 */
export function ScrollDrivenCssGood() {
  return (
    <div className="space-y-3">
      <style>{`
        @keyframes sdc-fade-up {
          from { opacity: 0; transform: translateY(16px); }
          to   { opacity: 1; transform: none; }
        }
        .sdc-card {
          animation: sdc-fade-up linear both;
          animation-timeline: view();
          animation-range: entry 0% cover 40%;
        }
        @supports not (animation-timeline: view()) {
          /* No timeline support: show the content, do not hide it behind an animation. */
          .sdc-card { animation: none; }
        }
        @media (prefers-reduced-motion: reduce) {
          .sdc-card { animation: none; }
        }
      `}</style>

      <div className="h-48 overflow-y-auto rounded-lg border border-border bg-muted p-4">
        <p className="pb-40 text-xs text-muted-foreground">Scroll down, then back up.</p>
        <ul className="space-y-6 pb-8">
          {[0, 1, 2, 3].map((i) => (
            <li
              key={i}
              className="sdc-card rounded-md border border-border bg-card p-3 text-sm text-foreground"
            >
              Card {i + 1}
            </li>
          ))}
        </ul>
      </div>
      <p className="text-xs text-success">
        Each card is scrubbed by its own entry into view: halfway there is a real halfway state, and scrolling
        back up reverses it. Unsupported browsers fall back to the plain, fully visible layout via @supports.
      </p>
    </div>
  );
}
```

## References

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