# Reading progress by chapter

> An article with a header that shows which chapter you are in and one bar per chapter that fills as you read.

- Category: [Text & scroll](https://library.aimran.es/components/text-scroll)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/reading-progress-toc
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## Where to use it

Long reads lose people halfway unless they can see the end coming. This reading progress bar splits into one segment per chapter, so a case study, a long blog post or a documentation page shows both where you are and what is left. Tap a bar to jump to that chapter, and the header can show minutes remaining.

It pays off on texts of five minutes or more; on a short post it is just decoration. Each chapter needs a clear title, since that is what the header shows. Hide the minutes if your estimate would be too rough to trust.

## Code

### React (Motion)

```tsx
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useScroll,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";

interface ReadingProgressProps {
  accent?: string;
  /** Muestra los minutos de lectura que quedan. */
  showTime?: boolean;
  /** true: caja con scroll propio. false: usa el scroll de la página (la cabecera se queda pegada arriba). */
  contained?: boolean;
}

const CHAPTERS = [
  {
    "title": "The brief",
    "body": [
      "We inherited a booking app with four years of patches piled on top. It worked, but nobody really knew why. The goal was clear: booking a table should take less effort than calling the restaurant.",
      "We didn’t open Figma the first week. We sat in three restaurants, wrote down every question the servers had, and timed how long it took people to find a single button."
    ]
  },
  {
    "title": "Sketches",
    "body": [
      "We sketched more than forty screens in pencil. Most of them ended up in the trash, and that was exactly the point: fail fast and cheap.",
      "One simple idea survived. A single screen with the day, the time and the party size, plus a big button that said exactly what would happen next."
    ]
  },
  {
    "title": "The details",
    "body": [
      "We tuned the timing of every transition until you stopped noticing it. We rewrote the tone of our error messages three times so they’d sound like help, not a scolding.",
      "We also spent a whole afternoon testing the app one-handed, standing up, on a bus. If it didn’t work there, it didn’t work anywhere."
    ]
  },
  {
    "title": "Launch",
    "body": [
      "We launched in two cities first. In the first week, completed bookings rose thirty-eight percent and calls to restaurants dropped by half.",
      "The best part wasn’t the numbers but a message from a server: “I can finally put my phone down and look after my tables.” That’s when we called the project done."
    ]
  }
];

function Segment({
  index,
  title,
  position,
  accent,
  current,
  onJump,
}: {
  index: number;
  title: string;
  position: MotionValue<number>;
  accent: string;
  current: boolean;
  onJump: (i: number) => void;
}) {
  const fill = useTransform(position, [index, index + 1], [0, 1]);
  return (
    <button
      type="button"
      onClick={() => onJump(index)}
      aria-label={"Go to «" + title + "»"}
      aria-current={current ? "true" : undefined}
      className="group flex-1 py-2"
    >
      <span className="block h-[3px] overflow-hidden rounded-full bg-[#2a251d]/10 transition-colors group-hover:bg-[#2a251d]/20 dark:bg-white/10 dark:group-hover:bg-white/20">
        <motion.span className="block h-full origin-left rounded-full" style={{ scaleX: fill, background: accent }} />
      </span>
    </button>
  );
}

export default function ReadingProgress({
  accent = "#c2410c",
  showTime = true,
  contained = true,
}: ReadingProgressProps) {
  const scroller = useRef<HTMLDivElement>(null);
  const header = useRef<HTMLElement>(null);
  const refs = useRef<(HTMLElement | null)[]>([]);
  const raw = useMotionValue(0);
  const position = useSpring(raw, { stiffness: 260, damping: 40, restDelta: 0.001 });
  const [active, setActive] = useState(0);
  const [done, setDone] = useState(0);
  const { scrollY } = useScroll(contained ? { container: scroller } : {});

  const measure = useCallback(() => {
    const root = contained ? scroller.current : null;
    const top = root ? root.getBoundingClientRect().top : 0;
    const height = root ? root.clientHeight : window.innerHeight;
    const line = top + (header.current?.offsetHeight ?? 0) + height * 0.3;
    let p = 0;
    for (const el of refs.current) {
      if (!el) continue;
      const r = el.getBoundingClientRect();
      p += Math.min(1, Math.max(0, (line - r.top) / r.height));
    }
    const end = root
      ? root.scrollTop + root.clientHeight >= root.scrollHeight - 4
      : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
    if (end) p = CHAPTERS.length;
    raw.set(p);
    setActive(Math.min(CHAPTERS.length - 1, Math.floor(p)));
    setDone(p / CHAPTERS.length);
  }, [contained, raw]);

  useMotionValueEvent(scrollY, "change", measure);
  useEffect(() => measure(), [measure]);

  const jump = (i: number) => {
    const el = refs.current[i];
    if (!el) return;
    const offset = (header.current?.offsetHeight ?? 0) + 16;
    const box = scroller.current;
    if (contained && box) {
      const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset;
      box.scrollTo({ top, behavior: "smooth" });
    } else {
      window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior: "smooth" });
    }
  };

  const minutes = Math.ceil(4 * (1 - done));
  const vars = { "--accent": accent } as CSSProperties;

  const content = (
    <div style={vars} className="relative bg-[#fbf8f1] text-[#2a251d] dark:bg-[#15130f] dark:text-[#ece5d8]">
      <header ref={header} className="sticky top-0 z-10 bg-[#fbf8f1]/85 px-5 pb-1.5 pt-4 backdrop-blur-md dark:bg-[#15130f]/85">
        <div className="flex items-center justify-between gap-3">
          <div className="relative h-5 flex-1 overflow-hidden">
            <AnimatePresence initial={false}>
              <motion.p
                key={active}
                initial={{ y: 20, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -20, opacity: 0 }}
                transition={{ type: "spring", stiffness: 380, damping: 34 }}
                className="absolute inset-0 truncate text-[14px] font-semibold tracking-[-0.01em]"
              >
                <span className="mr-2 tabular-nums" style={{ color: accent }}>
                  {String(active + 1).padStart(2, "0")}
                </span>
                {CHAPTERS[active]?.title}
              </motion.p>
            </AnimatePresence>
          </div>
          {showTime ? (
            <span className="shrink-0 text-[12px] tabular-nums text-[#8c826f]">{minutes > 0 ? minutes + " min" : "Read"}</span>
          ) : null}
        </div>
        <nav aria-label="Chapters" className="mt-1 flex gap-1.5">
          {CHAPTERS.map((c, i) => (
            <Segment
              key={c.title}
              index={i}
              title={c.title}
              position={position}
              accent={accent}
              current={i === active}
              onJump={jump}
            />
          ))}
        </nav>
      </header>
      <article className="mx-auto max-w-prose px-5 pb-16 pt-6">
        <p className="text-[12px] font-semibold uppercase tracking-[0.16em]" style={{ color: accent }}>
          Case study
        </p>
        <h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
          How we redesigned a booking app in six weeks
        </h2>
        <p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min read</p>
        {CHAPTERS.map((c, i) => (
          <section
            key={c.title}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className="mt-10 font-serif"
          >
            <h3 className="text-[22px] font-semibold tracking-[-0.01em]">{c.title}</h3>
            {c.body.map((p, j) => (
              <p
                key={j}
                className={
                  i === 0 && j === 0
                    ? "mt-3 text-[17px] leading-[1.7] first-letter:float-left first-letter:mr-2 first-letter:text-[3.2em] first-letter:font-semibold first-letter:leading-[0.85] first-letter:text-[var(--accent)]"
                    : "mt-3 text-[17px] leading-[1.7]"
                }
              >
                {p}
              </p>
            ))}
          </section>
        ))}
      </article>
    </div>
  );

  if (!contained) return content;
  return (
    <div
      ref={scroller}
      className="relative h-[30rem] w-full overflow-y-auto rounded-3xl bg-[#fbf8f1] [scrollbar-width:none] dark:bg-[#15130f]"
    >
      {content}
    </div>
  );
}
```

## Prompt for AI assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Reading progress by chapter” block (Text & scroll) from AIMRAN Library to my project.

## What it is
An article with a header that shows which chapter you are in and one bar per chapter that fills as you read.

## Chosen settings
- Accent color: #c2410c
- Minutes left: yes

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useScroll,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";

interface ReadingProgressProps {
  accent?: string;
  /** Muestra los minutos de lectura que quedan. */
  showTime?: boolean;
  /** true: caja con scroll propio. false: usa el scroll de la página (la cabecera se queda pegada arriba). */
  contained?: boolean;
}

const CHAPTERS = [
  {
    "title": "The brief",
    "body": [
      "We inherited a booking app with four years of patches piled on top. It worked, but nobody really knew why. The goal was clear: booking a table should take less effort than calling the restaurant.",
      "We didn’t open Figma the first week. We sat in three restaurants, wrote down every question the servers had, and timed how long it took people to find a single button."
    ]
  },
  {
    "title": "Sketches",
    "body": [
      "We sketched more than forty screens in pencil. Most of them ended up in the trash, and that was exactly the point: fail fast and cheap.",
      "One simple idea survived. A single screen with the day, the time and the party size, plus a big button that said exactly what would happen next."
    ]
  },
  {
    "title": "The details",
    "body": [
      "We tuned the timing of every transition until you stopped noticing it. We rewrote the tone of our error messages three times so they’d sound like help, not a scolding.",
      "We also spent a whole afternoon testing the app one-handed, standing up, on a bus. If it didn’t work there, it didn’t work anywhere."
    ]
  },
  {
    "title": "Launch",
    "body": [
      "We launched in two cities first. In the first week, completed bookings rose thirty-eight percent and calls to restaurants dropped by half.",
      "The best part wasn’t the numbers but a message from a server: “I can finally put my phone down and look after my tables.” That’s when we called the project done."
    ]
  }
];

function Segment({
  index,
  title,
  position,
  accent,
  current,
  onJump,
}: {
  index: number;
  title: string;
  position: MotionValue<number>;
  accent: string;
  current: boolean;
  onJump: (i: number) => void;
}) {
  const fill = useTransform(position, [index, index + 1], [0, 1]);
  return (
    <button
      type="button"
      onClick={() => onJump(index)}
      aria-label={"Go to «" + title + "»"}
      aria-current={current ? "true" : undefined}
      className="group flex-1 py-2"
    >
      <span className="block h-[3px] overflow-hidden rounded-full bg-[#2a251d]/10 transition-colors group-hover:bg-[#2a251d]/20 dark:bg-white/10 dark:group-hover:bg-white/20">
        <motion.span className="block h-full origin-left rounded-full" style={{ scaleX: fill, background: accent }} />
      </span>
    </button>
  );
}

export default function ReadingProgress({
  accent = "#c2410c",
  showTime = true,
  contained = true,
}: ReadingProgressProps) {
  const scroller = useRef<HTMLDivElement>(null);
  const header = useRef<HTMLElement>(null);
  const refs = useRef<(HTMLElement | null)[]>([]);
  const raw = useMotionValue(0);
  const position = useSpring(raw, { stiffness: 260, damping: 40, restDelta: 0.001 });
  const [active, setActive] = useState(0);
  const [done, setDone] = useState(0);
  const { scrollY } = useScroll(contained ? { container: scroller } : {});

  const measure = useCallback(() => {
    const root = contained ? scroller.current : null;
    const top = root ? root.getBoundingClientRect().top : 0;
    const height = root ? root.clientHeight : window.innerHeight;
    const line = top + (header.current?.offsetHeight ?? 0) + height * 0.3;
    let p = 0;
    for (const el of refs.current) {
      if (!el) continue;
      const r = el.getBoundingClientRect();
      p += Math.min(1, Math.max(0, (line - r.top) / r.height));
    }
    const end = root
      ? root.scrollTop + root.clientHeight >= root.scrollHeight - 4
      : window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
    if (end) p = CHAPTERS.length;
    raw.set(p);
    setActive(Math.min(CHAPTERS.length - 1, Math.floor(p)));
    setDone(p / CHAPTERS.length);
  }, [contained, raw]);

  useMotionValueEvent(scrollY, "change", measure);
  useEffect(() => measure(), [measure]);

  const jump = (i: number) => {
    const el = refs.current[i];
    if (!el) return;
    const offset = (header.current?.offsetHeight ?? 0) + 16;
    const box = scroller.current;
    if (contained && box) {
      const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset;
      box.scrollTo({ top, behavior: "smooth" });
    } else {
      window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior: "smooth" });
    }
  };

  const minutes = Math.ceil(4 * (1 - done));
  const vars = { "--accent": accent } as CSSProperties;

  const content = (
    <div style={vars} className="relative bg-[#fbf8f1] text-[#2a251d] dark:bg-[#15130f] dark:text-[#ece5d8]">
      <header ref={header} className="sticky top-0 z-10 bg-[#fbf8f1]/85 px-5 pb-1.5 pt-4 backdrop-blur-md dark:bg-[#15130f]/85">
        <div className="flex items-center justify-between gap-3">
          <div className="relative h-5 flex-1 overflow-hidden">
            <AnimatePresence initial={false}>
              <motion.p
                key={active}
                initial={{ y: 20, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -20, opacity: 0 }}
                transition={{ type: "spring", stiffness: 380, damping: 34 }}
                className="absolute inset-0 truncate text-[14px] font-semibold tracking-[-0.01em]"
              >
                <span className="mr-2 tabular-nums" style={{ color: accent }}>
                  {String(active + 1).padStart(2, "0")}
                </span>
                {CHAPTERS[active]?.title}
              </motion.p>
            </AnimatePresence>
          </div>
          {showTime ? (
            <span className="shrink-0 text-[12px] tabular-nums text-[#8c826f]">{minutes > 0 ? minutes + " min" : "Read"}</span>
          ) : null}
        </div>
        <nav aria-label="Chapters" className="mt-1 flex gap-1.5">
          {CHAPTERS.map((c, i) => (
            <Segment
              key={c.title}
              index={i}
              title={c.title}
              position={position}
              accent={accent}
              current={i === active}
              onJump={jump}
            />
          ))}
        </nav>
      </header>
      <article className="mx-auto max-w-prose px-5 pb-16 pt-6">
        <p className="text-[12px] font-semibold uppercase tracking-[0.16em]" style={{ color: accent }}>
          Case study
        </p>
        <h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
          How we redesigned a booking app in six weeks
        </h2>
        <p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min read</p>
        {CHAPTERS.map((c, i) => (
          <section
            key={c.title}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className="mt-10 font-serif"
          >
            <h3 className="text-[22px] font-semibold tracking-[-0.01em]">{c.title}</h3>
            {c.body.map((p, j) => (
              <p
                key={j}
                className={
                  i === 0 && j === 0
                    ? "mt-3 text-[17px] leading-[1.7] first-letter:float-left first-letter:mr-2 first-letter:text-[3.2em] first-letter:font-semibold first-letter:leading-[0.85] first-letter:text-[var(--accent)]"
                    : "mt-3 text-[17px] leading-[1.7]"
                }
              >
                {p}
              </p>
            ))}
          </section>
        ))}
      </article>
    </div>
  );

  if (!contained) return content;
  return (
    <div
      ref={scroller}
      className="relative h-[30rem] w-full overflow-y-auto rounded-3xl bg-[#fbf8f1] [scrollbar-width:none] dark:bg-[#15130f]"
    >
      {content}
    </div>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
