Add scroll & motion
Static pages feel flat, and you want elements to reveal as the user scrolls, a scroll-progress indicator, and subtle micro-interactions — without tanking frame rate or making people motion-sick. This is about adding polish that reads as intentional, not a fireworks show.
Motion is a spice, not the meal. In 2026 the right default is to reach for browser-native primitives first — CSS scroll-driven animations (`animation-timeline: view()`) and the View Transitions API handle most reveals and page transitions with zero JavaScript and graceful degradation. Pull in a library like Motion (the renamed Framer Motion) only when you need real React choreography, or GSAP + ScrollTrigger for complex pinned timelines. Whatever you choose, obey two rules: animate only compositor-friendly properties (transform and opacity, never width/top/margin), and treat `prefers-reduced-motion` as a first-class path rather than an afterthought. Pick 2-3 signature moments and leave the rest still.
Step by step
- 1
Decide what actually needs to move, then pick a stack
List the 2-3 moments worth animating (hero reveal, section fade-ins, a progress bar). Use native CSS scroll-driven animations for simple reveals, Motion for React component choreography, and GSAP + ScrollTrigger only for complex pinned/scrubbed timelines. Don't install a library for something CSS does for free.
- 2
Set a global reduced-motion guard first
Before writing any animation, wrap your app (or motion tree) in `<MotionConfig reducedMotion="user">`, or add a global `@media (prefers-reduced-motion: reduce) { *, ::before, ::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }`. Building this in from the start is far easier than retrofitting it.
- 3
Build reveal-on-scroll with whileInView / IntersectionObserver
Fade and slide sections in as they enter the viewport using Motion's `whileInView` with `viewport={{ once: true }}` so it fires a single time. Under the hood this is IntersectionObserver — never a scroll event listener firing on every pixel.
- 4
Add scroll-linked effects with useScroll
Wire a top progress bar or parallax to `useScroll()` and smooth it with `useSpring`. Bind the value to `scaleX` or `y` (transform), not `width` or `top`, so the compositor does the work off the main thread.
- 5
Animate the right properties and verify performance
Restrict animations to `transform` and `opacity`. Open Chrome DevTools > Rendering, enable 'Paint flashing' and check the Performance panel — if you see green repaint rectangles on every frame or long main-thread tasks, you're animating a layout property. Fix it before moving on.
- 6
(Optional) Add smooth scroll with Lenis, carefully
If you want inertia-style smooth scrolling, Lenis is the clean choice, but it's optional and opinionated. Test that anchor links, keyboard PageUp/Down, browser find, and trackpads still behave. Skip full scroll-jacking — it frustrates users and breaks accessibility.
- 7
Test reduced motion, mobile, and keyboard, then ship
Toggle 'Reduce motion' in your OS and confirm the page is fully usable with animations disabled. Check on a real phone (touch + smaller viewport) and navigate by keyboard only. Then ship.
Starter snippet
"use client";
import { motion, useScroll, useSpring, MotionConfig } from "motion/react";
import type { ReactNode } from "react";
// Reveal children as they scroll into view, plus a top scroll-progress bar.
export function ScrollReveal({ children }: { children: ReactNode }) {
const { scrollYProgress } = useScroll();
// Spring-smooth the raw 0→1 progress so the bar glides instead of snapping.
const progress = useSpring(scrollYProgress, { stiffness: 120, damping: 30 });
return (
// reducedMotion="user" makes every child respect the OS setting automatically.
<MotionConfig reducedMotion="user">
<motion.div
style={{ scaleX: progress, transformOrigin: "0%" }}
className="fixed inset-x-0 top-0 z-50 h-1 bg-indigo-500"
/>
<motion.section
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15% 0px" }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.section>
</MotionConfig>
);
}✕ Watch out for
- Animating layout properties (width, height, top, margin) instead of transform/opacity. These force the browser to re-layout and repaint every frame, producing jank; only transform and opacity run cheaply on the compositor.
- Ignoring prefers-reduced-motion. Big parallax and scroll-scrubbing can trigger nausea and vestibular issues — an accessibility failure, not a nice-to-have. Provide a still, usable version.
- Scroll-jacking with smooth-scroll libraries that hijack native scrolling and break anchor links, keyboard navigation, browser find, and trackpad momentum.
- Over-animating. When everything moves, nothing feels intentional and perceived performance drops. Budget motion to a few signature moments.
- Using whileInView without once:true (animations re-fire on every scroll pass) or forgetting elements already in view on load never trigger their reveal, leaving them stuck invisible.
✓ Pro tips
- When prompting an AI agent, be explicit: 'Animate only transform and opacity, and wrap the tree in MotionConfig reducedMotion="user".' Then verify by opening DevTools > Rendering > Paint flashing — if the animated element repaints every frame, the agent animated a layout property and you should send it back to fix it.
- Pin the library and check the import path. Framer Motion was renamed to `motion` and now imports from `motion/react`; agents trained on older data still write `import { motion } from "framer-motion"`. Confirm the import matches what's actually in your package.json.
- Try native CSS first for simple reveals: `animation-timeline: view()` gives you scroll-triggered animation with zero JavaScript and it degrades gracefully in older browsers. Reach for a JS library only when you need coordinated, stateful choreography.
- Budget your motion — choose 2-3 hero moments and keep everything else still. Restraint is what separates polished from amateur.