Reading Progress Bar with Section Reveals

An article reader in a fixed-height frame. A sticky header carries a progress bar that fills as the frame scrolls, the title of the section being read, a percentage and the minutes left. Each section fades up out of a soft blur as it enters, a chart draws its own line, and on wide screens a side list tracks your place. On phones the side list is hidden and the article fills the frame. It scrolls itself once on load so the effect is visible straight away.

Code

import { useEffect, useRef, useState } from "react";
import type { ReactNode, RefObject } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

const sections = [
  {
    id: "clay",
    title: "The clay remembers",
    paragraphs: [
      "Every pot keeps a record of how it was handled. A wall pulled too fast will slump in the kiln three weeks later, long after the potter has forgotten the afternoon it was thrown.",
      "At Emberfold we wedge each batch by hand and let it rest for nine days. It is slower than a pug mill and it shows in the finished rim.",
    ],
  },
  {
    id: "fire",
    title: "Fire as a collaborator",
    paragraphs: [
      "A wood kiln is not a switch. Ash travels with the flame, lands on the shoulder of a jar and melts into a glaze nobody mixed.",
      "We stoke in shifts of four hours, feeding split oak every six minutes once the chamber glows orange.",
    ],
  },
  {
    id: "cone",
    title: "Reading the cone",
    paragraphs: [
      "Small clay cones sit behind a spy hole and bend when the heat has done its work. Cone ten folding over is the signal to stop feeding and start waiting.",
      "The curve below is our last autumn firing, logged by hand every half hour.",
    ],
  },
  {
    id: "cooling",
    title: "What survives the cooling",
    paragraphs: [
      "The door stays bricked for three days. Open it early and the glaze crazes with a sound like ice on a pond.",
      "Roughly one piece in six does not make it to the shelf. The rest carry the whole season on their surface, and that is the part we sell.",
    ],
  },
];

const stats = [
  { value: "1,280°C", label: "Peak temperature" },
  { value: "38 hrs", label: "Continuous firing" },
  { value: "4 cords", label: "Split oak burned" },
];

const READ_MINUTES = 7;
const DEMO_SECONDS = 10;

function easeDemo(t: number) {
  if (t < 0.1) return (5 * t * t) / 0.9;
  if (t > 0.9) return 1 - (5 * (1 - t) * (1 - t)) / 0.9;
  return (t - 0.05) / 0.9;
}

/** Scrolls the frame slowly once so the preview shows the effect. Stops for good on any input. */
function useDemoScroll(ref: RefObject<HTMLDivElement>, enabled: boolean, seconds: number) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    let raf = 0;
    let last = 0;
    let t = 0;
    let done = false;
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    const tick = (now: number) => {
      if (done) return;
      const dt = last ? Math.min(now - last, 50) : 0;
      last = now;
      t = Math.min(1, t + dt / (seconds * 1000));
      el.scrollTop = easeDemo(t) * (el.scrollHeight - el.clientHeight);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(
      ([entry]) => {
        cancelAnimationFrame(raf);
        last = 0;
        if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
      },
      { threshold: 0.25 }
    );
    const stop = () => {
      done = true;
      cancelAnimationFrame(raf);
    };
    io.observe(el);
    inputs.forEach((name) => el.addEventListener(name, stop, { passive: true }));
    return () => {
      stop();
      io.disconnect();
      inputs.forEach((name) => el.removeEventListener(name, stop));
    };
  }, [ref, enabled, seconds]);
}

function Reveal({ root, delay = 0, className, children }: { root: RefObject<HTMLDivElement>; delay?: number; className?: string; children: ReactNode }) {
  const reduced = useReducedMotion();
  return (
    <motion.div
      className={className}
      initial={reduced ? false : { opacity: 0, y: 30, filter: "blur(8px)" }}
      whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      viewport={{ root, once: true, amount: 0.3 }}
      transition={{ type: "spring", stiffness: 80, damping: 18, delay }}
    >
      {children}
    </motion.div>
  );
}

export default function ReadingProgressBarWithSectionReveals() {
  const reduced = useReducedMotion();
  const frame = useRef<HTMLDivElement>(null);
  const sectionEls = useRef<(HTMLElement | null)[]>([]);
  const progress = useMotionValue(0);
  const bar = useSpring(progress, { stiffness: 140, damping: 26, mass: 0.4 });
  const [percent, setPercent] = useState(0);
  const [active, setActive] = useState(-1);

  useDemoScroll(frame, !reduced, DEMO_SECONDS);

  useEffect(() => {
    const el = frame.current;
    if (!el) return;
    let raf = 0;
    const measure = () => {
      raf = 0;
      const max = el.scrollHeight - el.clientHeight;
      const p = max > 0 ? el.scrollTop / max : 0;
      progress.set(p);
      setPercent(Math.round(p * 100));
      const top = el.getBoundingClientRect().top;
      let current = -1;
      sectionEls.current.forEach((node, i) => {
        if (node && node.getBoundingClientRect().top - top <= el.clientHeight * 0.45) current = i;
      });
      setActive(current);
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(measure);
    };
    measure();
    el.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      cancelAnimationFrame(raf);
      el.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [progress]);

  const jump = (i: number) => {
    const el = frame.current;
    const node = sectionEls.current[i];
    if (!el || !node) return;
    const top = node.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop - 84;
    el.scrollTo({ top, behavior: reduced ? "auto" : "smooth" });
  };

  const minutesLeft = Math.max(0, Math.ceil((1 - percent / 100) * READ_MINUTES));
  const heading = active >= 0 ? sections[active].title : "Notes from a wood-fired season";

  return (
    <section className="bg-stone-200 px-4 py-10 sm:py-14">
      <div className="mx-auto max-w-5xl overflow-hidden rounded-2xl bg-stone-50 shadow-xl shadow-stone-900/10 ring-1 ring-stone-900/10">
        <div
          ref={frame}
          tabIndex={0}
          role="region"
          aria-label="Article with reading progress"
          className="relative h-[620px] overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-orange-600"
        >
          <header className="sticky top-0 z-20 bg-stone-50/90 backdrop-blur">
            <div className="flex h-14 items-center gap-3 px-4 sm:px-6">
              <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-stone-900 font-serif text-sm font-semibold text-stone-50">E</span>
              <div className="relative h-5 min-w-0 flex-1 overflow-hidden">
                <AnimatePresence initial={false}>
                  <motion.p
                    key={heading}
                    className="absolute inset-0 truncate text-sm font-medium text-stone-800"
                    initial={reduced ? false : { y: 18, opacity: 0 }}
                    animate={{ y: 0, opacity: 1 }}
                    exit={reduced ? { opacity: 0 } : { y: -18, opacity: 0 }}
                    transition={{ type: "spring", stiffness: 260, damping: 26 }}
                  >
                    {heading}
                  </motion.p>
                </AnimatePresence>
              </div>
              <p className="hidden shrink-0 text-xs text-stone-600 sm:block">{minutesLeft === 0 ? "Finished" : `${minutesLeft} min left`}</p>
              <p className="w-11 shrink-0 text-right text-sm font-semibold tabular-nums text-orange-700" aria-hidden="true">
                {percent}%
              </p>
            </div>
            <div className="h-[3px] bg-stone-200" role="progressbar" aria-label="Reading progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
              <motion.div className="h-full origin-left rounded-r-full bg-gradient-to-r from-orange-500 to-orange-700" style={{ scaleX: bar }} />
            </div>
          </header>

          <div className="mx-auto grid max-w-4xl gap-10 px-5 pb-24 pt-10 sm:px-8 lg:grid-cols-[minmax(0,1fr)_190px]">
            <article className="min-w-0">
              <p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700">Emberfold Journal · Issue 14</p>
              <h2 className="mt-4 font-serif text-4xl font-semibold leading-[1.05] tracking-tight text-stone-900 sm:text-5xl">Why slow glazes last: notes from a wood-fired season</h2>
              <div className="mt-6 flex items-center gap-3">
                <span className="flex h-9 w-9 items-center justify-center rounded-full bg-orange-600 text-xs font-semibold text-white">IM</span>
                <p className="text-sm text-stone-600">
                  <span className="font-medium text-stone-900">Ines Marchetti</span> · {READ_MINUTES} minute read
                </p>
              </div>
              <p className="mt-8 font-serif text-xl leading-relaxed text-stone-700">
                We fire twice a year. Each firing takes a week to load, a day and a half to burn and three days to cool. Here is what that patience buys.
              </p>

              {sections.map((s, i) => (
                <section
                  key={s.id}
                  ref={(node) => {
                    sectionEls.current[i] = node;
                  }}
                  aria-labelledby={`reading-${s.id}`}
                  className="mt-16"
                >
                  <Reveal root={frame}>
                    <div className="flex items-center gap-4">
                      <span className="font-serif text-sm italic text-orange-700">0{i + 1}</span>
                      <span className="h-px flex-1 bg-stone-300" />
                    </div>
                    <h3 id={`reading-${s.id}`} className="mt-4 font-serif text-3xl font-semibold tracking-tight text-stone-900">
                      {s.title}
                    </h3>
                  </Reveal>
                  {s.paragraphs.map((text, k) => (
                    <Reveal key={k} root={frame} delay={0.08 * (k + 1)}>
                      <p className="mt-5 text-base leading-7 text-stone-700">{text}</p>
                    </Reveal>
                  ))}

                  {i === 0 && (
                    <Reveal root={frame} delay={0.1}>
                      <blockquote className="mt-8 border-l-2 border-orange-600 pl-5 font-serif text-2xl italic leading-snug text-stone-900">
                        You cannot hurry clay. You can only be late to it.
                      </blockquote>
                    </Reveal>
                  )}

                  {i === 1 && (
                    <dl className="mt-8 grid grid-cols-3 gap-3">
                      {stats.map((stat, k) => (
                        <Reveal key={stat.label} root={frame} delay={0.1 * k} className="rounded-xl bg-white p-4 ring-1 ring-stone-200">
                          <dt className="text-xs text-stone-600">{stat.label}</dt>
                          <dd className="mt-1 font-serif text-xl font-semibold text-stone-900 sm:text-2xl">{stat.value}</dd>
                        </Reveal>
                      ))}
                    </dl>
                  )}

                  {i === 2 && (
                    <Reveal root={frame} delay={0.1}>
                      <figure className="mt-8 rounded-xl bg-stone-900 p-5">
                        <svg viewBox="0 0 560 190" className="w-full" role="img" aria-label="Kiln temperature rising to 1,280 degrees over 38 hours, then cooling">
                          {[40, 90, 140].map((y) => (
                            <line key={y} x1="0" x2="560" y1={y} y2={y} className="stroke-stone-700" strokeDasharray="3 6" />
                          ))}
                          <motion.path
                            d="M4 176 C 70 168, 110 120, 170 96 S 270 34, 330 22 S 380 20, 400 40 S 470 130, 556 160"
                            fill="none"
                            className="stroke-orange-500"
                            strokeWidth="3"
                            strokeLinecap="round"
                            initial={reduced ? false : { pathLength: 0 }}
                            whileInView={{ pathLength: 1 }}
                            viewport={{ root: frame, once: true, amount: 0.6 }}
                            transition={{ duration: 1.8, ease: [0.3, 0, 0.2, 1], delay: 0.2 }}
                          />
                          <motion.circle
                            cx="345"
                            cy="20"
                            r="6"
                            className="fill-orange-300"
                            initial={reduced ? false : { scale: 0, opacity: 0 }}
                            whileInView={{ scale: 1, opacity: 1 }}
                            viewport={{ root: frame, once: true, amount: 1 }}
                            transition={{ type: "spring", stiffness: 300, damping: 14, delay: 1.3 }}
                          />
                        </svg>
                        <figcaption className="mt-3 flex justify-between text-xs text-stone-300">
                          <span>Light the firebox</span>
                          <span className="text-orange-300">Cone 10 down</span>
                          <span>Door bricked</span>
                        </figcaption>
                      </figure>
                    </Reveal>
                  )}
                </section>
              ))}

              <Reveal root={frame} className="mt-16 rounded-2xl bg-orange-600 p-6 sm:p-8">
                <p className="font-serif text-2xl font-semibold text-white">The autumn kiln opens on 18 October.</p>
                <p className="mt-2 text-sm text-orange-50">Subscribers see the shelf a day before everyone else.</p>
                <a href="#" className="mt-5 inline-flex h-11 items-center rounded-full bg-stone-900 px-5 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-orange-600">
                  Join the opening list
                </a>
              </Reveal>
            </article>

            <aside className="hidden lg:block">
              <nav aria-label="In this article" className="sticky top-24">
                <p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-600">In this piece</p>
                <ul className="relative mt-4 border-l border-stone-300">
                  <motion.span
                    aria-hidden="true"
                    className="absolute -left-px top-0 h-10 w-0.5 bg-orange-600"
                    animate={{ y: Math.max(0, active) * 40, opacity: active < 0 ? 0 : 1 }}
                    transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 300, damping: 30 }}
                  />
                  {sections.map((s, i) => (
                    <li key={s.id}>
                      <button
                        type="button"
                        onClick={() => jump(i)}
                        aria-current={active === i ? "location" : undefined}
                        className={`flex h-10 w-full items-center pl-4 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 ${active === i ? "font-semibold text-stone-900" : "text-stone-600 hover:text-stone-900"}`}
                      >
                        {s.title}
                      </button>
                    </li>
                  ))}
                </ul>
              </nav>
            </aside>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/reading-progress-bar-with-section-reveals.json

When to use it

How to customize it

Accessibility

Questions

How does a scroll progress bar work in React?

A scroll listener on the frame divides `scrollTop` by `scrollHeight - clientHeight` to get a number from 0 to 1. That number is stored in a motion value, smoothed with a spring and applied as `scaleX` on a bar whose transform origin is the left edge. Scaling is cheaper than changing width because it does not trigger layout.

How do I make it track the whole page instead of a frame?

Listen on `window` instead of the frame, read `window.scrollY` and `document.documentElement.scrollHeight - window.innerHeight`, remove the fixed height and `overflow-y-auto`, and change the header from `sticky` inside the frame to `sticky top-0` on the page. Remove the `root` option from the reveals so they use the viewport.

How do the sections reveal on scroll?

Each block is a motion element with `whileInView`. Its `viewport` option is given the frame as `root`, so visibility is measured against the frame and not the browser window. `once: true` means a block stays visible after its first appearance.

How is the current section title chosen?

On each scroll the component finds the last section whose top edge has passed a line 45% down the frame. That index picks the title in the header, which slides in with a short spring, and moves the marker in the side list.

Does it need Framer Motion?

Yes, for the spring on the bar, the reveals and the title swap. The progress number itself is plain DOM maths, so you could replace the motion parts with CSS transitions if you want no dependency.

Related: Self-Drawing Vertical Timeline with Pop-In Items, Sticky Scroll Storytelling with Pinned Visual, Floating Navbar That Shrinks to a Pill on Scroll.

More scroll effects or all free UI components. Start for free to have a full site built around it.

More in Scroll Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap