Retour au catalogue

Video Scroll Play

Progression simulee au scroll avec barre circulaire SVG qui se remplit. Le contenu change selon la progression.

videocomplex Both Responsive a11y
minimalcorporateuniversalsaasagencystickysplit
Theme

How to build a scroll-driven step progress section in React

A scroll-driven step section in React pins a viewport with `position: sticky` across a tall scrollable container, maps the scroll offset to a circular SVG stroke via Framer Motion's useScroll and useTransform, and activates each step card when the scroll fraction reaches the corresponding threshold.

  • Stack: React 18 + Framer Motion 11 + Tailwind v4, ~270 lines, zero extra dependencies.
  • Core API: useScroll (target + offset), useTransform, motion.circle with strokeDashoffset.
  • The container height scales with step count (100vh per step, minimum 200vh) so scroll distance maps cleanly to progress.
  • Accessible: step content is always in the DOM; the ring and markers are decorative SVG with no missing ARIA.
  • Works on mobile, scroll is available on all devices, no pointer required.

Video Scroll Play is a sticky React section that turns the scroll bar into a progress indicator. As the user scrolls through a tall container, a circular SVG ring fills from 0 to 100% and each step card lights up in sequence. It is a popular pattern for onboarding flows and "how it works" product explainers where reading speed should not dictate pacing.

Anatomy

The outer `<section>` is the scroll target; its height is computed from the step count so each step occupies one viewport height. Inside, a `position: sticky` div locks to the top of the viewport at all times and fills the full screen. That sticky div holds a two-column grid: the left column contains the title, subtitle, and a list of step cards; the right column is a 280px SVG that renders a decorative outer ring, a track circle, and an animated progress arc. Step markers (8px dots on the outer ring) shift from border to accent color as the scroll advances.

How it works

useScroll receives the section ref and an offset of `['start start', 'end end']`, producing a scrollYProgress from 0 to 1 that covers the full container. useTransform maps that value to strokeDashoffset: the circle circumference (2πr with r=54) becomes the starting dashoffset and decrements to 0 as the page scrolls. A second useTransform converts the same 0-1 range to a 0-100 percentage displayed at the ring center. Step highlighting uses another useTransform that maps the scroll fraction linearly to a step index, then each card checks whether Math.round(currentIndex) equals its own index to toggle opacity, border color, and background.

How to build it in React

  1. Set up the scroll container and sticky viewport

    Give the outer section a height proportional to step count so each step gets its own scroll window. Nest a `position: sticky; top: 0; height: 100vh` div inside, this is the only element the user ever sees.

    const sectionRef = useRef<HTMLElement>(null);
    // 100vh per step, min 200vh
    const sectionHeight = `${Math.max(200, steps.length * 100)}vh`;
  2. Wire scroll progress to the SVG arc

    Pass the section ref to useScroll with offset `['start start', 'end end']`. Convert scrollYProgress to strokeDashoffset using the full circumference as the starting value. Apply the motion value directly on a motion.circle element.

    const { scrollYProgress } = useScroll({
      target: sectionRef,
      offset: ["start start", "end end"],
    });
    const circumference = 2 * Math.PI * 54;
    const progressOffset = useTransform(
      scrollYProgress,
      [0, 1],
      [circumference, 0]
    );
    // In JSX:
    // <motion.circle style={{ strokeDashoffset: progressOffset }} ... />
  3. Map scroll fraction to the active step

    useTransform can map an array of input values to an array of output values. Feed it evenly spaced fractions (one per step) and matching step indices. Each card then derives its active state with Math.round.

    const currentStepIndex = useTransform(
      scrollYProgress,
      steps.map((_, i) => i / steps.length),
      steps.map((_, i) => i),
    );
    // Per card:
    const isActive = useTransform(currentStepIndex, (v) => Math.round(v) === i);
  4. Animate step cards with derived motion values

    Pass isActive (a MotionValue<boolean>) through further useTransform calls to get opacity, border color, and background color. Apply them as inline motion styles on each card, no re-renders, all handled in the animation thread.

    <motion.div
      style={{
        opacity: useTransform(isActive, (v) => (v ? 1 : 0.35)),
        borderLeftColor: useTransform(isActive, (v) =>
          v ? "var(--color-accent)" : "var(--color-border)"
        ),
      }}
    >

When to use it

Reach for this pattern on product landing pages that need to explain a multi-step process without wall-of-text, onboarding flows, SaaS feature explanations, installation guides. It works best with 3 to 5 steps; fewer steps waste the scroll real estate, more steps make the container uncomfortably tall on mobile. Skip it on conversion-focused pages where scroll-to-CTA friction matters, and on pages where users arrive mid-scroll from anchor links (the progress ring will be in an unexpected state).

Used by

  • Stripe, Uses sticky scroll sections with step-by-step progression to explain payment flows and developer onboarding.
  • Notion, Employs scroll-locked step sequences on its product pages to walk through workspace setup.
  • Linear, Scroll-driven feature walkthroughs that highlight each capability as the user scrolls through the marketing section.
  • Lottie by LottieFiles, Scroll-controlled animations tied to page progress to demonstrate how their animation format works step by step.

FAQ

Why is the section height set to steps.length × 100vh?

Each step needs an equivalent scroll range to be in focus. With 100vh per step, scrolling through one viewport height transitions from one step to the next. Use fewer vh per step for faster pacing, more for slower reading.

Can I replace the circular ring with a linear progress bar?

Yes. Replace the SVG with a div, bind its width or scaleX to the same scrollYProgress motion value using useTransform, and the rest of the logic stays identical.

Does it work on mobile?

The scroll interaction works on all devices including touch screens, useScroll tracks touch-initiated scroll the same as mouse scroll. The two-column grid collapses to a single column via the responsive Tailwind class.

How do I add an actual video instead of the progress ring?

Map scrollYProgress to the currentTime property of an HTML video element via a useEffect that reads the motion value. Scrub-on-scroll video is a separate technique: you need a preloaded video and requestAnimationFrame to sync the playhead without causing layout thrash.

"use client";

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

interface ScrollStep {
  title: string;
  description: string;
}

interface VideoScrollPlayProps {
  title?: string;
  subtitle?: string;
  steps?: ScrollStep[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

export default function VideoScrollPlay({
  title = "Comment ca fonctionne",
  subtitle = "Scrollez pour decouvrir",
  steps = [],

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Reviews

React Scroll-Driven Progress Section, Framer Motion Tutorial