← All components

ReadingProgress

An 8px glass bar that fills as you read, and scrubs the page when you drag it.

Scroll this box, then drag the bar

Where churn comes from

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

The annual offer

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

What it would cost

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

What we changed

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

The idea

A press on its own does nothing, because the bar sits over the text you are reading and a stray tap that threw you two thousand pixels down would be the worst thing it could do. It takes a few pixels of movement to start a scrub; a scrub that ends near a heading snaps onto it; the label names the section under the pointer. And scrolling never re-renders it — the fill is a clip written straight to a custom property.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ReadingProgress — usage11 lines
import { ReadingProgress } from "mihirmodi-ui";

// Floating over a long page, above a bottom dock.
<ReadingProgress
  sections={headings.map((h) => ({ id: h.id, label: h.text }))}
  bottom={76}
/>

// Or reporting on a box that scrolls. It renders where you put it.
<div ref={box} className="relative h-96 overflow-y-auto">{article}</div>
<ReadingProgress container={box} sections={sections} />

Props

PropNotes
sections{ id, label }[]What a scrub snaps to and the label names.
containerRefObject<HTMLElement>Report on a scrolling element rather than the page. The bar then renders in place; the element must be positioned.
bottomnumberFloating only: distance from the viewport's bottom. Clear your dock with it.
linenumberWhere a section counts as reached. Match your contents rail.
minScrollnumberBelow this much scrollable distance it does not render at all.
--rp-fill-from / --rp-fill-toCSS variablesThe fill's ramp. Ink by default; redeclare for colour.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/ReadingProgress.tsx282 lines
"use client";

import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "motion/react";
import { listenScroll, offsetTopIn, scrollHeightOf, scrollToTop, scrollTopOf, viewportHeightOf } from "../lib/scroll.js";

export type ProgressSection = { id: string; label: string };

export type ReadingProgressProps = {
  /** The page's sections. A scrub that lands near one snaps to it, and the label names the one under the pointer. */
  sections: ProgressSection[];
  /**
   * A scrolling element to report on instead of the page. The bar then renders
   * where you put it rather than floating over the viewport, and the element
   * must be positioned so sections can be measured inside it.
   */
  container?: RefObject<HTMLElement | null>;
  /** Distance from the viewport's bottom edge, when floating. Clear your dock with it. */
  bottom?: number;
  /** Where a section counts as reached — match your contents rail's reading line. */
  line?: number;
  /** Under this much scrollable distance there is nothing worth reporting, and the bar does not render. */
  minScroll?: number;
  width?: number;
  className?: string;
};

const SNAP_PX = 10;
const DRAG_PX = 3;
const SHOW_AT = 96;
const HIDE_AT = 64;

type Mark = ProgressSection & { at: number };
const clamp = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
const markAt = (marks: Mark[], at: number) => {
  let found: Mark | undefined;
  for (const m of marks) {
    if (m.at > at + 0.002) break;
    found = m;
  }
  return found;
};

/**
 * How far down a long page you are, as an 8px glass bar that fills with ink —
 * and a control: drag it and the page follows.
 *
 * A press on its own does nothing. The bar sits over the text you are reading,
 * and a stray tap that threw you two thousand pixels down would be the worst
 * thing it could do, so it takes a few pixels of movement to start a scrub. A
 * scrub that finishes near a heading snaps onto it exactly, and the label above
 * names the section under the pointer. The keyboard gets a `slider` that steps
 * section by section, with Home and End for the ends.
 *
 * Scrolling never re-renders it. The fill is a clip written straight to the
 * track as a custom property and the ARIA value is an attribute React does not
 * render, so there is nothing in the tree for a scroll position to invalidate.
 * It arrives after a little scrolling and leaves near the top, on two
 * thresholds rather than one, so a 1px jitter cannot make it flicker.
 */
export function ReadingProgress({
  sections,
  container,
  bottom = 24,
  line = 112,
  minScroll = 480,
  width = 274,
  className = "",
}: ReadingProgressProps) {
  const reduce = useReducedMotion();
  const pillRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const tipRef = useRef<HTMLDivElement>(null);
  const [geo, setGeo] = useState<{ max: number; marks: Mark[] }>({ max: 0, marks: [] });
  const [shown, setShown] = useState(false);
  const [tip, setTip] = useState("");
  const pressedAt = useRef<number | null>(null);
  const dragging = useRef(false);

  const key = sections.map((s) => `${s.id} ${s.label}`).join("|");
  const latest = useRef(sections);
  latest.current = sections;

  useEffect(() => {
    const measure = () => {
      const max = scrollHeightOf(container) - viewportHeightOf(container);
      const marks: Mark[] =
        max > 0
          ? latest.current.flatMap((s) => {
              const el = document.getElementById(s.id);
              return el ? [{ ...s, at: clamp((offsetTopIn(el, container) - line) / max) }] : [];
            })
          : [];
      setGeo((prev) =>
        prev.max === max && prev.marks.length === marks.length && prev.marks.every((m, i) => m.id === marks[i].id && Math.abs(m.at - marks[i].at) < 0.0005)
          ? prev
          : { max, marks }
      );
    };
    measure();
    window.addEventListener("resize", measure);
    // Images landing and disclosures opening change the height with no resize event.
    const ro = new ResizeObserver(measure);
    ro.observe(container?.current ?? document.body);
    if (container?.current?.firstElementChild) ro.observe(container.current.firstElementChild);
    return () => {
      window.removeEventListener("resize", measure);
      ro.disconnect();
    };
  }, [key, container, line]);

  const { max, marks } = geo;
  const live = max >= minScroll;

  useEffect(() => {
    if (!live) {
      setShown(false);
      return;
    }
    let ticking = false;
    const update = () => {
      ticking = false;
      const y = scrollTopOf(container);
      const at = clamp(y / max);
      trackRef.current?.style.setProperty("--rp", `${at * 100}%`);
      const pill = pillRef.current;
      if (pill) {
        const pct = Math.round(at * 100);
        const here = markAt(marks, at);
        pill.setAttribute("aria-valuenow", String(pct));
        pill.setAttribute("aria-valuetext", here ? `${pct}%, ${here.label}` : `${pct}%`);
      }
      setShown((prev) => (prev ? y > HIDE_AT : y > SHOW_AT));
    };
    const onScroll = () => {
      if (!ticking) {
        ticking = true;
        requestAnimationFrame(update);
      }
    };
    update();
    return listenScroll(container, onScroll);
  }, [live, max, marks, container]);

  // Instant during a drag: a scrubber that eases toward where your finger was
  // lags behind where it is, which reads as the control being broken.
  const seek = useCallback(
    (clientX: number) => {
      const track = trackRef.current;
      if (!track || max <= 0) return;
      const rect = track.getBoundingClientRect();
      const x = clientX - rect.left;
      const near = marks.find((m) => Math.abs(m.at * rect.width - x) <= SNAP_PX);
      scrollToTop(container, (near ? near.at : clamp(x / rect.width)) * max, "auto");
    },
    [marks, max, container]
  );

  const park = useCallback((at: number, label: string) => {
    const track = trackRef.current;
    const el = tipRef.current;
    if (track && el) {
      const w = track.clientWidth;
      el.style.left = `${Math.min(Math.max(at * w, 28), w - 28)}px`;
    }
    setTip((prev) => (prev === label ? prev : label));
  }, []);

  const hint = useCallback(
    (clientX: number) => {
      const track = trackRef.current;
      if (!track) return;
      const rect = track.getBoundingClientRect();
      const at = clamp((clientX - rect.left) / rect.width);
      park(at, markAt(marks, at)?.label ?? "");
    },
    [marks, park]
  );

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (max <= 0) return;
    const at = clamp(scrollTopOf(container) / max);
    const go = (to: number) => {
      e.preventDefault();
      const target = clamp(to);
      scrollToTop(container, target * max, reduce ? "auto" : "smooth");
      park(target, markAt(marks, target)?.label ?? "");
    };
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        return go(marks.find((m) => m.at > at + 0.004)?.at ?? at + 0.05);
      case "ArrowLeft":
      case "ArrowUp": {
        const back = [...marks].reverse().find((m) => m.at < at - 0.004);
        return go(back ? back.at : at - 0.05);
      }
      case "Home":
        return go(0);
      case "End":
        return go(1);
    }
  };

  if (!live) return null;

  const bar = (
    <div
      data-shown={shown}
      className={`reading-progress pointer-events-none flex justify-center ${container ? "" : "fixed inset-x-0 z-40 px-4"} ${className}`}
      style={container ? undefined : { bottom }}
    >
      <div className="relative w-full" style={{ maxWidth: width }}>
        <div
          ref={tipRef}
          aria-hidden
          className={`pointer-events-none absolute bottom-full mb-2 max-w-[240px] -translate-x-1/2 truncate whitespace-nowrap rounded-md bg-ink px-2 py-1 text-[11px] leading-none text-background-100 shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)] transition-opacity duration-150 ease-out motion-reduce:transition-none ${
            tip ? "opacity-100" : "opacity-0"
          }`}
        >
          {tip}
        </div>
        <div
          ref={pillRef}
          role="slider"
          tabIndex={shown ? 0 : -1}
          aria-label="Reading progress"
          aria-orientation="horizontal"
          aria-valuemin={0}
          aria-valuemax={100}
          onKeyDown={onKeyDown}
          onPointerDown={(e) => {
            e.preventDefault();
            try {
              e.currentTarget.setPointerCapture(e.pointerId);
            } catch {}
            pressedAt.current = e.clientX;
            hint(e.clientX);
          }}
          onPointerMove={(e) => {
            const from = pressedAt.current;
            if (from !== null && !dragging.current && Math.abs(e.clientX - from) >= DRAG_PX) dragging.current = true;
            if (dragging.current) seek(e.clientX);
            hint(e.clientX);
          }}
          onPointerUp={(e) => {
            pressedAt.current = null;
            dragging.current = false;
            try {
              e.currentTarget.releasePointerCapture(e.pointerId);
            } catch {}
            if (e.pointerType !== "mouse") setTip("");
          }}
          onPointerCancel={() => {
            pressedAt.current = null;
            dragging.current = false;
            setTip("");
          }}
          onPointerLeave={() => {
            if (!dragging.current) setTip("");
          }}
          onBlur={() => setTip("")}
          // The bar is 8px; the box you can grab is 28. The padding is the
          // difference — a line this thin is a miss on a thumb.
          className={`flex items-center py-2.5 ${shown ? "pointer-events-auto" : "pointer-events-none"}`}
        >
          <div className="rp-bar glass-nav dock-shadow h-2 w-full overflow-hidden rounded-xl border-[0.5px] border-gray-alpha-500">
            <div ref={trackRef} className="relative h-full w-full">
              <div className="rp-fill" />
            </div>
          </div>
        </div>
      </div>
    </div>
  );

  // Floating, it portals to the body: an ancestor animating its opacity (a
  // route fade) is a backdrop root, and the blur would go weak for the length
  // of every navigation.
  return container ? bar : createPortal(bar, document.body);
}
packages/ui/src/lib/scroll.ts46 lines
import type { RefObject } from "react";

/** Something that scrolls: an element, or — when there is no ref — the window. */
export type Scroller = RefObject<HTMLElement | null> | undefined;

export const scrollerEl = (root: Scroller): HTMLElement | null => root?.current ?? null;

export const scrollTopOf = (root: Scroller) => scrollerEl(root)?.scrollTop ?? window.scrollY;

export const viewportHeightOf = (root: Scroller) => scrollerEl(root)?.clientHeight ?? window.innerHeight;

export const scrollHeightOf = (root: Scroller) => scrollerEl(root)?.scrollHeight ?? document.documentElement.scrollHeight;

/**
 * Where an element sits inside the scroller, from the layout box rather than
 * the painted one. `getBoundingClientRect` would be simpler and is wrong
 * whenever something is mid-way through a reveal transform: it reports where the
 * element is drawn, not where it will rest, and every jump lands short.
 *
 * Inside an element scroller, that element must be positioned, so the offset
 * chain stops at it.
 */
export function offsetTopIn(el: HTMLElement, root: Scroller): number {
  const stop = scrollerEl(root);
  let top = 0;
  for (let node: HTMLElement | null = el; node && node !== stop; node = node.offsetParent as HTMLElement | null) {
    top += node.offsetTop;
  }
  return top;
}

export function scrollToTop(root: Scroller, top: number, behavior: ScrollBehavior) {
  const el = scrollerEl(root);
  if (el) el.scrollTo({ top, behavior });
  else window.scrollTo({ top, behavior });
}

export function listenScroll(root: Scroller, fn: () => void) {
  const target: HTMLElement | Window = scrollerEl(root) ?? window;
  target.addEventListener("scroll", fn, { passive: true });
  window.addEventListener("resize", fn);
  return () => {
    target.removeEventListener("scroll", fn);
    window.removeEventListener("resize", fn);
  };
}