Scroll-Linked Animations in Next.js with Framer Motion
Scroll-linked animations — where UI elements move in direct response to the user's scroll position — are one of the most effective ways to make a static portfolio feel alive. Here's how I built the main animations in this portfolio using Framer Motion, and the non-obvious bugs I hit along the way.
The scroll-linked horizontal carousel
The projects section scrolls horizontally as you scroll down the page. The implementation uses Framer Motion's useScroll and useTransform hooks.
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});
const smoothProgress = useSpring(scrollYProgress, {
stiffness: 110,
damping: 38,
mass: 0.32,
});
const x = useTransform(smoothProgress, [0, 1], [0, -maxScroll]);
The key pieces:
containerRefis a tall div (height: calc(100vh + maxScroll)) that creates the vertical scroll spacetrackRefis the card strip,position: sticky, translating horizontally as you scrollmaxScrollis computed by measuringtrack.scrollWidth - parent.clientWidth— the exact distance the strip needs to traveluseSpringwrapsscrollYProgressto add momentum, so the strip doesn't feel mechanical
The measurement problem: maxScroll must be recalculated on resize. I use a ResizeObserver on both the track and container, plus a window.resize listener as a fallback.
useLayoutEffect(() => {
const measure = () => {
const track = trackRef.current;
const parent = track?.parentElement;
if (!track || !parent) return;
setMaxScroll(Math.max(0, track.scrollWidth - parent.clientWidth));
};
measure();
const ro = new ResizeObserver(measure);
if (trackRef.current) ro.observe(trackRef.current);
window.addEventListener("resize", measure);
return () => { ro.disconnect(); window.removeEventListener("resize", measure); };
}, []);
The scroll position bug
When users clicked a project card and navigated to the detail page, the new page appeared scrolled to the bottom — inheriting the scroll position from the portfolio page's scroll animation.
I tried:
useEffect(() => window.scrollTo(0, 0), [])on the detail page — ran too late, after first paintuseLayoutEffect— still too late with Next.js hydration- An inline
<script>tag — same issue
The fix that actually worked: an onClick handler on the <Link> that resets scroll position before navigation starts:
<Link
href={`/projects/${project.slug}`}
onClick={() => {
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
}}
>
Setting scroll synchronously in the click handler runs before Next.js begins the page transition, so the new page starts from the top.
Respecting reduced motion
Both the carousel and animations check useReducedMotion(). When the user has that system preference set, the scroll-linked carousel falls back to a plain horizontal scroll container:
const reduceMotion = useReducedMotion();
return reduceMotion ? <StaticStrip /> : <ScrollLinkedStrip />;
This is not optional for accessibility — it's a system preference users set for medical reasons (vestibular disorders, epilepsy). Always respect it.
Typewriter effect
The hero typewriter cycles through subtitle lines using a simple state machine:
type Phase = "typing" | "pausing" | "deleting";
Each phase has its own interval speed: 65ms per character while typing, 1800ms pause at full text, 35ms per character while deleting. The next line starts after the current one finishes deleting.
The only non-obvious thing: cancel the interval in the cleanup function. React StrictMode double-invokes effects in development, and a leaked interval produces a typewriter that oscillates between two lines simultaneously.
Framer Motion makes the individual pieces easy. The hard part is the measurement, the resize handling, and understanding exactly where in the React/Next.js lifecycle your code runs. Get those right and the animations take care of themselves.