Skip to main content

September 2, 2026

CSS view() timelines vs IntersectionObserver

Below-the-fold blocks on my Next.js site faded in with a client observer. Scroll timelines do the fade now, and older browsers just show the content.

Sander Korf4 min read

Below-the-fold blocks on my Next.js personal site were supposed to fade in once and stay put. Home, portfolio, and resume wrap those sections in a shared AnimatedSection from @repo/components. A reader scrolling on a normal phone sees headings and cards slide up as they enter the viewport. In LinkedIn's in-app browser the same blocks never left opacity-0. Intersection Observer simply did not fire reliably there, so I added a 500ms fallback that called getBoundingClientRect and un-hid anything still invisible.

That fallback was an admission. The page shipped hidden HTML and prayed a callback would arrive. When the callback did not, a timer guessed. Every wrapped block also pulled in 'use client', motion/react, and useInView, so the server sent invisible markup and hydration flipped visibility afterward. I was maintaining JavaScript to reproduce what scroll-driven CSS can own on its own now.

When the observer never fired

The old wrapper leaned on motion's useInView:

useInView(ref, { once: true, amount: 0.2, margin: '0px 0px -10% 0px' });

isInView toggled Tailwind classes from translate-y-4 opacity-0 to visible. LinkedIn in-app left portfolio headings stuck at zero opacity while the rest of the page looked fine. The rect hack ran on a timer because I could not trust the observer on that WebView. Privacy modes and older embedded browsers showed the same symptom less often, but often enough that the wrapper carried special-case logic forever.

Every page that imported AnimatedSection inherited that risk. Portfolio case studies and resume timeline blocks all started invisible in HTML. Crawlers and readers without JavaScript saw empty sections until something client-side ran. That was acceptable when I thought the fade was polish. It stopped being acceptable when the fade became the gate to readable text.

The wrapper is a class now

The replacement is a server-friendly div with one class. Children compose the same way. The class name is the whole API.

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

I kept the component so call sites did not scatter a magic string. Behavior moved into the stylesheet. Import AnimatedSection on a new page and you inherit the fade without opting into a client boundary.

View timelines live in the stylesheet

Animation lives in packages/components/src/styles/base.css. Playback follows scroll, not a React boolean.

@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 progress to the element's journey through the viewport. animation-range: entry 0% cover 20% mirrors the old amount: 0.2: start on entry, finish as the block covers a slice of the view. both fills forwards so content stays visible after the range completes.

I did not add a JavaScript feature detect next to this CSS. One @supports block in the same file handles unsupported engines. A hook would have reintroduced the client import I was trying to delete.

Home and portfolio no longer pay for motion in the shared bundle on first load. Resume sections that used to wait on hydration now render readable text in the initial HTML. That mattered more on slow networks than on my laptop, where the observer path always looked fine until I opened the same URL inside LinkedIn.

Show content when the browser cannot animate

Engines without view timelines should not hide content first. That was the observer failure mode: default invisible, hope for a callback.

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

prefers-reduced-motion: reduce disables the animation the same way. Text stays readable. Motion becomes enhancement, not a gate you must pass to read the page.

I dropped the LinkedIn rect timer after this shipped. If a platform cannot scroll predictably, invisible copy is worse than a static block. The @supports rule encodes that trade. Scroll-driven CSS is not universal yet; admitting that in CSS beats a 500ms guess in React.

Tailwind still wraps the div. The animation does not live in @apply chains because the timeline properties are easier to read in plain CSS beside the keyframes. Call sites only pass layout classes. They do not pass initial or whileInView props anymore, which removed a whole category of prop drift between pages.

When you need enter-once fades on marketing sections, try view timelines before you import an observer library. Delete the hook once the stylesheet can carry the behavior. If a WebView skips view timelines, your reader still sees the copy. That was the bar I missed while I was optimizing for motion on Chrome desktop.


Happy coding! Sander