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