# Navbar Scroll Progress — React Navbar Component

> A reading navbar with a spring-smoothed progress line, a minutes-left counter and a title that swaps in once the headline scrolls away.

- Category: [Navbars](https://pixelpapa.com/components/navbars.md)
- Tags: navbar, scroll, progress, reading, blog
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/navbars/navbar-scroll-progress

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/navbar-scroll-progress.json"
```

The file is written to `components/pixelpapa/navbar-scroll-progress.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowUp, Bookmark } from "lucide-react";
import { AnimatePresence, motion, useMotionValueEvent, useScroll, useSpring, useTransform } from "motion/react";
import { useRef, useState, type RefObject } from "react";
import { cn } from "@/lib/utils";

type NavbarScrollProgressProps = {
  brand?: string;
  /** Shown in the bar once the reader is past the page headline. */
  title?: string;
  /** Estimated reading time in minutes. */
  readMinutes?: number;
  /** Element that scrolls. Defaults to the window. */
  scrollContainer?: RefObject<HTMLElement | null>;
  className?: string;
};

export function NavbarScrollProgress({
  brand = "Margins",
  title = "Why we stopped shipping on Fridays",
  readMinutes = 6,
  scrollContainer,
  className,
}: NavbarScrollProgressProps) {
  const { scrollY, scrollYProgress } = useScroll({ container: scrollContainer });
  const smooth = useSpring(scrollYProgress, { stiffness: 140, damping: 26, restDelta: 0.001 });
  const ring = useTransform(smooth, [0, 1], [88, 0]);
  const [reading, setReading] = useState(false);
  const [left, setLeft] = useState(readMinutes);

  useMotionValueEvent(scrollY, "change", (y) => setReading(y > 150));
  useMotionValueEvent(scrollYProgress, "change", (p) => setLeft(Math.ceil((1 - p) * readMinutes)));

  function toTop() {
    const el = scrollContainer?.current;
    if (el) el.scrollTo({ top: 0, behavior: "smooth" });
    else window.scrollTo({ top: 0, behavior: "smooth" });
  }

  return (
    <header className={cn("sticky top-0 z-40 border-b border-border bg-background/85 backdrop-blur-lg", className)}>
      <div className="mx-auto flex h-14 max-w-3xl items-center gap-4 px-6">
        <a href="#" className="shrink-0 font-serif text-xl text-foreground italic">
          {brand}
        </a>
        <span className="h-4 w-px bg-border" />
        <div className="relative h-5 min-w-0 flex-1 overflow-hidden text-sm">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={reading ? "title" : "nav"}
              initial={{ y: reading ? 18 : -18, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: reading ? 18 : -18, opacity: 0 }}
              transition={{ type: "spring", stiffness: 300, damping: 30 }}
              className="truncate leading-5"
            >
              {reading ? (
                <span className="font-medium text-foreground">{title}</span>
              ) : (
                <span className="flex gap-5 text-muted-foreground">
                  <a href="#" className="hover:text-foreground">Essays</a>
                  <a href="#" className="hover:text-foreground">Field notes</a>
                  <a href="#" className="hover:text-foreground">About</a>
                </span>
              )}
            </motion.p>
          </AnimatePresence>
        </div>
        <span className="shrink-0 text-xs text-muted-foreground tabular-nums" aria-live="off">
          {left > 0 ? `${left} min left` : "Finished"}
        </span>
        <button type="button" aria-label="Save for later" className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground">
          <Bookmark className="size-4" />
        </button>
        <button
          type="button"
          aria-label="Back to top"
          onClick={toTop}
          className={cn(
            "relative flex size-8 shrink-0 items-center justify-center rounded-full text-foreground transition-all duration-300",
            reading ? "scale-100 opacity-100" : "pointer-events-none scale-75 opacity-0",
          )}
        >
          <svg viewBox="0 0 32 32" className="absolute inset-0 -rotate-90" aria-hidden>
            <circle cx="16" cy="16" r="14" fill="none" strokeWidth="2" className="stroke-border" />
            <motion.circle
              cx="16"
              cy="16"
              r="14"
              fill="none"
              strokeWidth="2"
              strokeLinecap="round"
              strokeDasharray={88}
              style={{ strokeDashoffset: ring }}
              className="stroke-primary"
            />
          </svg>
          <ArrowUp className="size-3.5" />
        </button>
      </div>
      <motion.div
        role="progressbar"
        aria-label="Reading progress"
        style={{ scaleX: smooth }}
        className="absolute inset-x-0 -bottom-px h-0.5 origin-left bg-primary"
      />
    </header>
  );
}

const paragraphs = [
  "For three years our release calendar had a hole in it shaped like a Friday afternoon. Nobody wrote the rule down. It grew out of one bad weekend in 2022 and a pager that would not stop.",
  "The trouble with unwritten rules is that they hide the reason they exist. New engineers learned the ritual without the story, and the ritual slowly turned into superstition: no deploys after lunch, then no deploys on Thursday either.",
  "So we measured it. Across 1,840 releases, Friday changes were not riskier than Tuesday ones. What predicted an incident was the size of the diff and whether its author was still online an hour later.",
  "We replaced the calendar rule with two smaller ones. Ship small enough to read in a sitting. Stay for the first hour. The Friday hole closed on its own, and on-call pages fell by a third.",
  "The lesson was less about deploys than about folklore. A team's habits are a kind of cache, and every cache needs an expiry.",
];

export default function NavbarScrollProgressDemo() {
  const scroller = useRef<HTMLDivElement>(null);

  return (
    <div ref={scroller} className="relative h-[440px] w-full overflow-y-auto bg-background [scrollbar-width:thin]">
      <NavbarScrollProgress scrollContainer={scroller} />
      <article className="mx-auto max-w-2xl px-6 pt-12 pb-20">
        <p className="text-xs tracking-[0.18em] text-primary uppercase">Engineering culture · 6 min read</p>
        <h1 className="mt-4 font-serif text-5xl leading-[1.05] text-balance text-foreground">
          Why we stopped shipping on Fridays, and why we started again
        </h1>
        <p className="mt-5 flex items-center gap-3 text-sm text-muted-foreground">
          <span className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
            TA
          </span>
          Tomasz Adeyemi · 4 February
        </p>
        <div className="mt-10 space-y-6 text-lg leading-relaxed text-foreground/80">
          {paragraphs.map((p, i) => (
            <p key={i} className={cn(i === 0 && "first-letter:float-left first-letter:mt-1 first-letter:mr-2.5 first-letter:font-serif first-letter:text-6xl first-letter:leading-[0.85] first-letter:text-foreground")}>
              {p}
            </p>
          ))}
        </div>
      </article>
    </div>
  );
}
```

## AI prompt

```text
Add the "Navbar Scroll Progress" component from PixelPapa UI to this project.

What it is: A reading navbar with a spring-smoothed progress line, a minutes-left counter and a title that swaps in once the headline scrolls away.
Category: Navbars. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/navbar-scroll-progress.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/navbar-scroll-progress.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/navbar-scroll-progress" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More navbars

- [Navbar Announcement Bar](https://pixelpapa.com/components/navbars/navbar-announcement-bar.md) (Pro): A navbar topped by a dismissible strip that ticks through announcements and folds away with a spring when closed.
- [Navbar Centered Logo](https://pixelpapa.com/components/navbars/navbar-centered-logo.md) (Pro): An editorial storefront header with a serif wordmark between two link groups; labels roll on hover and the bag count pops when it changes.
- [Navbar Command Search](https://pixelpapa.com/components/navbars/navbar-command-search.md) (Pro): A docs navbar whose compact search field widens on focus or ⌘K into a filterable result list with arrow-key navigation.
- [Navbar Dynamic Island](https://pixelpapa.com/components/navbars/navbar-dynamic-island.md) (Pro): A compact black pill of links that swells on a spring into a live notification, drains a timer line, then shrinks back to navigation.
- [Navbar Floating Pill](https://pixelpapa.com/components/navbars/navbar-floating-pill.md) (Pro): A floating pill navbar that condenses as you scroll: it narrows on a spring, drops its release chip and swaps the CTA label for an arrow.
- [Navbar Glass Blur](https://pixelpapa.com/components/navbars/navbar-glass-blur.md): A frosted floating navbar that blurs and saturates whatever scrolls beneath it, with a specular highlight that tracks the pointer along the glass.
