Retour au catalogue

Process Sticky Steps

Layout 2 colonnes : sidebar gauche sticky avec numero d'etape actif en crossfade (AnimatePresence) et barre de progression scroll-driven (scaleY). Droite : etapes qui se revelent au whileInView. Style Apple/Linear.

processcomplex Both Responsive a11y
minimalelegantcorporatesaasagencyuniversalsplitsticky
Theme

How to build a sticky sidebar process section in React

A sticky-sidebar process section in React uses useScroll and useTransform from Framer Motion to tie scroll progress to a scaleY progress bar, and useMotionValueEvent to derive the active step index. The sidebar stays fixed while the step cards scroll past on the right.

  • Stack: React 18 + Framer Motion 11 + CSS custom properties, ~108 lines, zero icon dependency.
  • Core Framer Motion APIs: useScroll, useTransform, useMotionValueEvent, AnimatePresence.
  • The active step number and title crossfade via AnimatePresence in mode='wait', preventing overlap during transitions.
  • Dot indicators animate width (6px to 20px) to signal the active step without text.
  • The sticky sidebar collapses naturally on small screens, consider switching to a single-column layout below md breakpoint.

This process section pairs a sticky left sidebar with a scrollable column of step cards. As the user scrolls, the sidebar tracks progress with a scaleY bar and swaps the active step number via a smooth crossfade. The result reads like an Apple or Linear-style 'how it works' walkthrough: structured, calm, and attention-directing.

Anatomy

The outer section holds a full-width container split into a 220px left sidebar and a flex-column right column. The sidebar is sticky (top: 30vh) and contains a progress track (2px wide, accent-colored scaleY fill), an animated step counter, an animated step title, and a row of dot indicators. The right column renders one StepCard per step, each with a decorative dot-grid card, a horizontal rule, a title, a description paragraph, and an indented detail callout.

How it works

A containerRef wraps the two-column grid. useScroll targets that ref with offset ['start 0.6', 'end 0.6'], so scrollYProgress goes from 0 to 1 as the section scrolls past the 60% viewport mark. useTransform maps that 0-1 value to a scaleY motion value for the progress bar fill. useMotionValueEvent listens to scrollYProgress on every change and computes the active step index as Math.floor(v * steps.length), clamped to valid bounds. AnimatePresence mode='wait' then crossfades the number and title in the sidebar. Each StepCard uses whileInView with a viewport margin of -15% so it enters only when well inside the viewport.

How to build it in React

  1. Set up the scroll container and derive scrollYProgress

    Attach a ref to the two-column grid div and pass it to useScroll. The offset tuple tells Framer Motion when to start and end the progress range, 'start 0.6' fires when the top of the container crosses 60% of the viewport height.

    const containerRef = useRef<HTMLDivElement>(null);
    const { scrollYProgress } = useScroll({
      target: containerRef,
      offset: ["start 0.6", "end 0.6"],
    });
  2. Map progress to the scaleY fill and the active step index

    useTransform converts the 0-1 progress value into a scaleY for the accent bar. useMotionValueEvent fires on every scroll tick and computes which step is active with a simple floor + clamp. Storing activeStep in useState re-renders the sidebar cleanly without affecting the scroll listener.

    const scaleY = useTransform(scrollYProgress, [0, 1], [0, 1]);
    const [activeStep, setActiveStep] = useState(0);
    
    useMotionValueEvent(scrollYProgress, "change", (v) => {
      setActiveStep(Math.max(0, Math.min(Math.floor(v * steps.length), steps.length - 1)));
    });
  3. Crossfade the step counter with AnimatePresence

    Wrap the animated number and title each in their own AnimatePresence with mode='wait'. Give each motion.span a key equal to activeStep so Framer Motion unmounts the old element before mounting the new one. The exit animation (y: -20) and enter animation (y: 20 to 0) create a smooth ticker feel.

    <AnimatePresence mode="wait">
      <motion.span
        key={activeStep}
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: -20 }}
        transition={{ duration: 0.35 }}
      >
        {steps[activeStep]?.number}
      </motion.span>
    </AnimatePresence>
  4. Animate step cards into view with whileInView

    Each StepCard wraps its content in a motion.div with initial={{ opacity: 0, y: 40 }} and whileInView={{ opacity: 1, y: 0 }}. The viewport margin of -15% ensures the card only enters once it is genuinely inside the visible area, not right at the edge. Set once: true so the animation does not replay on scroll back.

    <motion.div
      initial={{ opacity: 0, y: 40 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, margin: "-15%" }}
      transition={{ duration: 0.65, ease: [0.16, 1, 0.3, 1] }}
    >

When to use it

Use this section when you have 3 to 5 discrete steps that benefit from a paced reveal, onboarding flows, service methodologies, product workflows. It performs best in the middle of a page after a features section, giving context for how something actually works. Skip it when steps have no natural order, when you have more than 5 steps (the sidebar number loses meaning), or when your users are likely on mobile-first (the two-column layout needs careful responsive handling).

Used by

  • Linear, Uses scroll-synced sidebars and sticky feature callouts to walk through its issue tracking workflow on the marketing site.
  • Stripe, Multiple product pages use a sticky left column with a scrolable right panel to explain multi-step integration processes.
  • Notion, The 'How it works' section on its homepage uses a sticky step counter that advances as you scroll through feature explanations.
  • Loom, Scroll-driven step reveal with a persistent progress indicator in the sidebar to guide prospects through the product value proposition.

FAQ

How does useMotionValueEvent differ from useEffect for tracking scrollYProgress?

useEffect would require creating a derived state via subscribe, which triggers React re-renders through a subscription callback. useMotionValueEvent is a first-class hook that attaches directly to the motion value's change event, runs outside React's render cycle when possible, and avoids stale closure issues.

Why use scaleY instead of height to animate the progress bar?

Animating transform: scaleY runs on the GPU compositor thread and avoids layout recalculations. Animating height forces the browser to recalculate layout on every frame, which is noticeably more expensive during fast scrolling.

What happens if there are only 2 steps or more than 5?

With 2 steps the sidebar feels sparse and the sticky trick loses impact, a horizontal timeline works better. Above 5 steps the scrollable right column becomes very long and the sidebar number (01 to 08, say) stops conveying meaningful progress. Four steps is the sweet spot for this layout.

How should I handle this on mobile?

The sticky two-column grid breaks on small viewports because 220px + gap + content exceeds the screen width. Switch to a single-column stacked layout below the md breakpoint (768px): remove the sticky positioning, hide the sidebar or collapse it into a simple step counter above each card.

"use client";

import { useRef, useState } from "react";
import { motion, useScroll, useTransform, useMotionValueEvent, AnimatePresence } from "framer-motion";

interface Step { number: string; title: string; description: string; detail: string; icon: string }
interface ProcessStickyStepsProps { eyebrow?: string; title?: string; subtitle?: string; steps?: Step[] }

const E: [number, number, number, number] = [0.16, 1, 0.3, 1];
const muted = "var(--color-foreground-muted)";
const fg = "var(--color-foreground)";
const accent = "var(--color-accent)";
const border = "var(--color-border)";

export default function ProcessStickySteps({
  eyebrow = "How it works",
  title = "A process built for clarity",
  subtitle = "Each step is designed to move you closer to the result with full transparency.",
  steps = [],
}: ProcessStickyStepsProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [activeStep, setActiveStep] = useState(0);

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Sticky Scroll Process Section, Framer Motion Tutorial