Web InnoventixFreeCode

Progress Scrollbar

Original · free

scroll progress indicator

byWeb InnoventixReact + Tailwind
scbprogressscrollbars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scb-progress.json
scb-progress.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import {
  motion,
  useMotionValueEvent,
  useReducedMotion,
  useScroll,
  useSpring,
} from "motion/react";

type Section = {
  id: string;
  nav: string;
  title: string;
  body: string[];
};

const SECTIONS: Section[] = [
  {
    id: "promise",
    nav: "The promise",
    title: "A progress bar is a promise",
    body: [
      "Before a reader commits a single sentence of attention, a progress indicator has already answered the question they were too polite to ask: how long is this going to take? That quiet signal sets the contract for everything below it. Get it wrong and every scroll feels longer than it really is.",
      "The best indicators are honest to a fault. They move at the speed the content actually reads, they never jump backward, and they reach one hundred percent at the last meaningful word — not at the footer, not at the newsletter box, not three screens of related links later.",
    ],
  },
  {
    id: "shape",
    nav: "Linear or radial",
    title: "Choosing a shape",
    body: [
      "A thin line across the top of the viewport is the workhorse. It reads instantly, costs almost no space, and maps cleanly onto the mental model of a page as a single vertical column. Reach for it when reading is the entire job.",
      "A radial ring earns its place when the indicator also has to be a control. Wrap progress around a back-to-top button and two elements collapse into one, while the arc gives the eye something to complete. The trade is precision: nobody reads sixty-one percent off a ring, so pair it with a number whenever the exact figure matters.",
    ],
  },
  {
    id: "placement",
    nav: "Placement",
    title: "Where the eye forgives it",
    body: [
      "Anchor a linear bar to the very top edge and it dissolves into the browser chrome — present, but never demanding. Drop the radial control into the bottom-right corner, where thumbs already live on touch devices and where a returning glance costs nothing.",
      "Avoid the middle of anything. A progress element that floats over body copy competes with the very words it is measuring, and the reader ends up losing both.",
    ],
  },
  {
    id: "motion",
    nav: "Motion",
    title: "Motion with manners",
    body: [
      "Scroll position is jittery by nature, so smooth it. A light spring between the raw scroll value and the rendered bar removes the twitch without adding lag you can feel. Overshoot is the enemy here — a bar that bounces past its value reads as a bug, not a flourish.",
      "And when a reader has asked their system for less motion, listen. Snap straight to the value, drop the shimmer, keep the meaning intact. Respecting that setting is not a downgrade; it is the same information delivered with less noise.",
    ],
  },
  {
    id: "number",
    nav: "The number",
    title: "What the number should mean",
    body: [
      "Decide what one hundred percent represents and then hold the line. If it counts the article, stop counting at the article. Readers calibrate against the figure within the first few screens, and a percentage that lies once is never trusted again.",
      "The most generous thing an indicator can do is finish early and honestly, letting the reader feel the ending a beat before they arrive. Certainty at the close is worth far more than precision in the middle.",
    ],
  },
];

export default function ScbProgress() {
  const reduce = useReducedMotion();
  const rawId = useId();
  const gradId = `scbprog-grad-${rawId.replace(/:/g, "")}`;

  const scrollRef = useRef<HTMLDivElement>(null);
  const [pct, setPct] = useState(0);
  const [active, setActive] = useState<string>(SECTIONS[0].id);

  const { scrollYProgress } = useScroll({ container: scrollRef });
  const smooth = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 30,
    mass: 0.4,
  });
  const progress = reduce ? scrollYProgress : smooth;

  useMotionValueEvent(progress, "change", (v) => {
    setPct(Math.min(100, Math.max(0, Math.round(v * 100))));
  });

  useEffect(() => {
    const container = scrollRef.current;
    if (!container) return;
    const els = Array.from(
      container.querySelectorAll<HTMLElement>("[data-scb-section]")
    );
    const io = new IntersectionObserver(
      (entries) => {
        const visible = entries.filter((e) => e.isIntersecting);
        if (visible.length === 0) return;
        visible.sort(
          (a, b) => a.boundingClientRect.top - b.boundingClientRect.top
        );
        const id = visible[0].target.getAttribute("data-scb-id");
        if (id) setActive(id);
      },
      { root: container, rootMargin: "0px 0px -68% 0px", threshold: 0 }
    );
    els.forEach((el) => io.observe(el));
    return () => io.disconnect();
  }, []);

  const goTo = (id: string) => {
    const container = scrollRef.current;
    if (!container) return;
    const el = container.querySelector<HTMLElement>(`[data-scb-id="${id}"]`);
    if (!el) return;
    const header = container.querySelector<HTMLElement>("[data-scb-header]");
    const offset = (header?.offsetHeight ?? 0) + 12;
    container.scrollTo({
      top: Math.max(0, el.offsetTop - offset),
      behavior: reduce ? "instant" : "smooth",
    });
  };

  const goTop = () => {
    scrollRef.current?.scrollTo({
      top: 0,
      behavior: reduce ? "instant" : "smooth",
    });
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-50">
      <style>{`
        @keyframes scbprog-sheen {
          0%   { transform: translateX(-120%); }
          100% { transform: translateX(320%); }
        }
        @keyframes scbprog-pulse {
          0%, 100% { opacity: 0.35; transform: scale(0.85); }
          50%      { opacity: 0.9;  transform: scale(1.05); }
        }
        @media (prefers-reduced-motion: reduce) {
          .scbprog-sheen, .scbprog-pulse { animation: none !important; }
        }
      `}</style>

      {/* ambient backdrop */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 opacity-70 [mask-image:radial-gradient(70%_60%_at_50%_0%,black,transparent)]"
      >
        <div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute right-[12%] top-10 h-56 w-56 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
      </div>

      <div className="relative mx-auto w-full max-w-4xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/60 dark:text-slate-400">
            <span
              aria-hidden="true"
              className="h-1.5 w-1.5 rounded-full bg-indigo-500"
            />
            Scroll progress
          </span>
          <h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
            A reading indicator that keeps its word
          </h2>
          <p className="mt-3 max-w-2xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Scroll inside the panel below. One smoothed value drives a top bar, a
            live percentage, an active section index, and the radial control in
            the corner.
          </p>
        </header>

        <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
          {/* linear progress bar */}
          <div
            role="progressbar"
            aria-label="Reading progress"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={pct}
            className="absolute inset-x-0 top-0 z-20 h-[4px] bg-slate-200/80 dark:bg-slate-800"
          >
            <motion.div
              style={{ scaleX: progress }}
              className="relative h-full origin-left overflow-hidden bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500"
            >
              <span
                aria-hidden="true"
                className="scbprog-sheen absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/70 to-transparent"
                style={{ animation: "scbprog-sheen 2.6s ease-in-out infinite" }}
              />
            </motion.div>
          </div>

          <div className="grid md:grid-cols-[196px_1fr]">
            {/* section index */}
            <nav
              aria-label="Article sections"
              className="hidden border-r border-slate-200 bg-slate-50/60 p-4 pt-7 md:block dark:border-slate-800 dark:bg-slate-950/30"
            >
              <p className="mb-3 px-2 text-[0.68rem] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
                Contents
              </p>
              <ul className="space-y-1">
                {SECTIONS.map((s) => {
                  const isActive = s.id === active;
                  return (
                    <li key={s.id}>
                      <button
                        type="button"
                        onClick={() => goTo(s.id)}
                        aria-current={isActive ? "true" : undefined}
                        className={`group flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                          isActive
                            ? "bg-white font-medium text-indigo-700 shadow-sm dark:bg-slate-800/80 dark:text-indigo-300"
                            : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200"
                        }`}
                      >
                        <span
                          aria-hidden="true"
                          className={`h-1.5 w-1.5 shrink-0 rounded-full transition-all ${
                            isActive
                              ? "scale-125 bg-indigo-500"
                              : "bg-slate-300 group-hover:bg-slate-400 dark:bg-slate-600"
                          }`}
                        />
                        {s.nav}
                      </button>
                    </li>
                  );
                })}
              </ul>
            </nav>

            {/* scroll container */}
            <div
              ref={scrollRef}
              tabIndex={0}
              aria-label="Article, scroll to read"
              className="scbprog-scroll relative h-[26rem] overflow-y-auto scroll-smooth focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:h-[30rem]"
            >
              {/* sticky header inside scroller */}
              <div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white/85 px-5 py-3 backdrop-blur dark:border-slate-800/80 dark:bg-slate-900/85">
                <div className="min-w-0">
                  <p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
                    Field Notes on Progress
                  </p>
                  <p className="text-xs text-slate-500 dark:text-slate-400">
                    Interface essay · 4 min read
                  </p>
                </div>
                <span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-semibold tabular-nums text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                  <span
                    aria-hidden="true"
                    className="scbprog-pulse h-1.5 w-1.5 rounded-full bg-indigo-500"
                    style={{ animation: "scbprog-pulse 2s ease-in-out infinite" }}
                  />
                  {pct}
                  <span className="text-indigo-400 dark:text-indigo-400/80">
                    %
                  </span>
                </span>
              </div>

              <article className="px-5 pb-16 pt-2 sm:px-8">
                {SECTIONS.map((s, i) => (
                  <section
                    key={s.id}
                    data-scb-section=""
                    data-scb-id={s.id}
                    className="scroll-mt-4 border-b border-slate-100 py-7 last:border-none dark:border-slate-800/60"
                  >
                    <div className="mb-3 flex items-baseline gap-3">
                      <span className="font-mono text-xs font-medium tabular-nums text-indigo-500 dark:text-indigo-400">
                        {String(i + 1).padStart(2, "0")}
                      </span>
                      <h3 className="text-xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
                        {s.title}
                      </h3>
                    </div>
                    {s.body.map((p, j) => (
                      <p
                        key={j}
                        className="mt-3 text-[0.95rem] leading-7 text-slate-600 dark:text-slate-300"
                      >
                        {p}
                      </p>
                    ))}
                  </section>
                ))}
                <p className="pt-4 text-sm font-medium text-slate-400 dark:text-slate-500">
                  You have reached the end — and the bar should agree.
                </p>
              </article>

              {/* radial control */}
              <motion.button
                type="button"
                onClick={goTop}
                aria-label={`Scroll back to top — ${pct}% read`}
                whileHover={reduce ? undefined : { y: -2 }}
                whileTap={reduce ? undefined : { scale: 0.92 }}
                className="sticky bottom-4 z-10 ml-auto mr-4 flex h-14 w-14 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-700 shadow-lg shadow-slate-900/10 backdrop-blur transition-colors hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800/90 dark:text-slate-200 dark:shadow-black/40 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900"
              >
                <svg
                  viewBox="0 0 48 48"
                  className="absolute inset-0 h-full w-full -rotate-90"
                  aria-hidden="true"
                >
                  <defs>
                    <linearGradient id={gradId} x1="0" y1="0" x2="1" y2="1">
                      <stop offset="0%" stopColor="#6366f1" />
                      <stop offset="100%" stopColor="#d946ef" />
                    </linearGradient>
                  </defs>
                  <circle
                    cx="24"
                    cy="24"
                    r="21"
                    fill="none"
                    strokeWidth="3"
                    className="stroke-slate-200 dark:stroke-slate-700"
                  />
                  <motion.circle
                    cx="24"
                    cy="24"
                    r="21"
                    fill="none"
                    stroke={`url(#${gradId})`}
                    strokeWidth="3"
                    strokeLinecap="round"
                    pathLength={1}
                    style={{ pathLength: progress }}
                  />
                </svg>
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="relative h-5 w-5"
                  aria-hidden="true"
                >
                  <path d="M12 19V5" />
                  <path d="m5 12 7-7 7 7" />
                </svg>
              </motion.button>
            </div>
          </div>
        </div>

        <p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
          Tip: click a section in the index, or the ring to return to the top.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →