CSS view() timelines beat IntersectionObserver

Scroll fade used a client Intersection Observer hook. I replaced it with CSS animation-timeline view() and deleted 40 lines of JavaScript fallback.

Sander Korf2 min read
cssnextjstailwindperformance

This was scroll fade on a Next.js marketing site

Next.js monorepo. Shared @repo/components package. AnimatedSection wrapped below-the-fold blocks on home, portfolio, resume. Fade in on scroll. Once. Stay visible. Simple brief.

What failure looks like

Two bad outcomes. First: sections stuck at opacity-0 because Intersection Observer never fired. LinkedIn in-app browser, old WebViews, privacy modes. I had a 500ms fallback timer checking getBoundingClientRect. Forty-three lines of client JavaScript just to un-hide content.

Second: every animated block shipped 'use client', motion/react, and a hook. Server HTML arrived invisible. Hydration flipped visibility. Extra JS for an effect CSS can own now.

JavaScript scroll triggers

Commit 3896359 deleted use-in-view-with-fallback.ts and stripped 'use client' from AnimatedSection.

The old component used useInView from motion with once: true, amount: 0.2, and a top margin. When isInView flipped, Tailwind classes toggled from translate-y-4 opacity-0 to visible. The fallback hook ran a timeout, measured the viewport, and force-showed elements stuck off the observer. It worked. It was heavy.

CSS scroll-driven animation

The new component is a server-friendly div with one class.

export function AnimatedSection({ children, className }: AnimatedSectionProps) {
	return <div className={cn('animated-section', className)}>{children}</div>;
}

Animation lives in packages/components/src/styles/base.css:

@keyframes animated-section-in {
	from {
		opacity: 0;
		transform: translateY(1rem);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}
 
.animated-section {
	animation: animated-section-in 700ms ease-out both;
	animation-timeline: view();
	animation-range: entry 0% cover 20%;
}

animation-timeline: view() ties playback to scroll. The element fades as it enters the viewport. No observer. No React state. No client bundle for this wrapper.

Fallbacks that don't punish users

Browsers without view timelines get immediate visibility:

@supports not (animation-timeline: view()) {
	.animated-section {
		opacity: 1;
		transform: none;
		animation: none;
	}
}

prefers-reduced-motion: reduce kills the animation the same way. Content stays readable. Motion becomes a progressive enhancement, not a gate.

I lost the LinkedIn-specific rect hack. Good. If the platform can't scroll, invisible content is worse than no animation.

Reach for scroll-driven CSS before you import an observer library. Delete the hook when the stylesheet can do the job.


Happy coding! Sander