## Reading Progress — Scroll How much is left. Docs: https://www.interior.dev/docs/reading-progress Reference: https://www.interior.dev/reference/reading-progress License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/reading-progress.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRef } from "react"; import { ReadingProgress } from "@/components/interior/reading-progress"; export function ArticlePage({ body }: { body: React.ReactNode }) { const article = useRef(null); return ( <>
{body}
); } ``` ### Props - `target` (`{ readonly current: HTMLElement | null }`) The article element. Progress runs from its top edge to its last visible line, so headers and footers outside it never count as reading. - `scroller` (`{ readonly current: HTMLElement | null }`) — default: `window` The scroll container, when the article lives in a pane rather than the document. Omit for window scrolling. - `steps` (`number`) — default: `24` How finely the scroll is quantized, and therefore the maximum number of renders a full scroll can cost. The fill still reads as continuous — the spring smooths between steps. - `words` (`number`) — default: `0` Word count of the article. Above zero the readout is minutes remaining. At zero there is no readout at all — the track is the percentage. - `wordsPerMinute` (`number`) — default: `220` Reading rate used for the estimate. - `label` (`string`) — default: `"Reading progress"` Accessible name of the progressbar, also used in the completion announcement. - `doneLabel` (`string`) — default: `"End"` Replaces the estimate once the last cell lights. - `className` (`string`) — default: `""` Appended last, so callers can override height, gap and color. ### Behavior notes - Scroll is measured inside a single coalesced requestAnimationFrame and committed only when the quantized step changes, so a fast flick through a long article costs at most twenty-four renders instead of one per frame — the quantization is a render budget, not a look; the fill itself moves on a spring, on a transform. - The track is a recessed well and the fill is the accent, because a progress fill is the system answering your scroll right now — and the radii nest, four outside, two inside, with the two pixels of padding between them. - The estimate and the end state occupy the same grid cell, sized up front by an invisible copy of the longest string it can ever hold, so the number never shifts the header as it counts down from two digits to one. - Progress is derived from the article's own box, not the document's, so a tall footer or a sticky sidebar cannot report the reader as finished while paragraphs remain. - A container shorter than its viewport reports complete rather than dividing by zero, and content that grows after mount is caught by a ResizeObserver instead of freezing the track at a stale height. - Screen readers get a progressbar with a real name and a value, and nothing else. A second live region narrating the same number is a running commentary, not an aid. - Under prefers-reduced-motion the springs are removed and the readout is not; scroll and resize listeners, the pending frame and the observer are all torn down on unmount. ### Source (`components/interior/reading-progress.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const FILL = { type: "spring", stiffness: 210, damping: 34, mass: 0.9 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const DRAW = { duration: 0.3, ease: EASE, delay: 0.08 } as const; const INSTANT = { duration: 0 } as const; export type ScrollRef = { readonly current: HTMLElement | null }; export type UseReadingProgressOptions = { target?: ScrollRef; scroller?: ScrollRef; steps?: number; words?: number; wordsPerMinute?: number; }; export type ReadingProgressState = { step: number; steps: number; progress: number; percent: number; minutesLeft: number; totalMinutes: number; complete: boolean; }; function clamp01(n: number) { if (!Number.isFinite(n) || n < 0) return 0; return n > 1 ? 1 : n; } export function useReadingProgress({ target, scroller, steps = 24, words = 0, wordsPerMinute = 220, }: UseReadingProgressOptions = {}): ReadingProgressState { const [step, setStep] = useState(0); const frame = useRef(0); const read = useCallback(() => { frame.current = 0; const scrollEl = scroller?.current ?? null; const targetEl = target?.current ?? null; const viewport = scrollEl ? scrollEl.clientHeight : window.innerHeight; let ratio: number; if (targetEl) { const rect = targetEl.getBoundingClientRect(); const base = scrollEl ? scrollEl.getBoundingClientRect().top : 0; const travel = rect.height - viewport; ratio = travel <= 0 ? 1 : (base - rect.top) / travel; } else if (scrollEl) { const travel = scrollEl.scrollHeight - scrollEl.clientHeight; ratio = travel <= 0 ? 1 : scrollEl.scrollTop / travel; } else { const doc = document.documentElement; const travel = doc.scrollHeight - viewport; ratio = travel <= 0 ? 1 : window.scrollY / travel; } const next = Math.round(clamp01(ratio) * steps); setStep((prev) => (prev === next ? prev : next)); }, [scroller, target, steps]); useEffect(() => { const scrollEl = scroller?.current ?? null; const targetEl = target?.current ?? null; const source: EventTarget = scrollEl ?? window; const schedule = () => { if (frame.current) return; frame.current = requestAnimationFrame(read); }; source.addEventListener("scroll", schedule, { passive: true }); window.addEventListener("resize", schedule); const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule); if (observer) { if (targetEl) observer.observe(targetEl); if (scrollEl) observer.observe(scrollEl); if (!targetEl && !scrollEl) observer.observe(document.documentElement); } read(); return () => { source.removeEventListener("scroll", schedule); window.removeEventListener("resize", schedule); observer?.disconnect(); if (frame.current) cancelAnimationFrame(frame.current); frame.current = 0; }; }, [read, scroller, target]); const progress = steps > 0 ? step / steps : 1; const totalMinutes = words > 0 ? Math.max(1, Math.ceil(words / wordsPerMinute)) : 0; const minutesLeft = words > 0 ? Math.ceil(((1 - progress) * words) / wordsPerMinute) : 0; return { step, steps, progress, percent: Math.round(progress * 100), minutesLeft, totalMinutes, complete: step >= steps, }; } export type ReadingProgressProps = UseReadingProgressOptions & { label?: string; doneLabel?: string; className?: string; }; export function ReadingProgress({ target, scroller, steps = 24, words = 0, wordsPerMinute = 220, label = "Reading progress", doneLabel = "End", className = "", }: ReadingProgressProps) { const { step, percent, minutesLeft, totalMinutes, complete } = useReadingProgress({ target, scroller, steps, words, wordsPerMinute }); const reduced = useReducedMotion(); const fillTransition = reduced ? INSTANT : FILL; const fadeTransition = reduced ? INSTANT : CROSSFADE; const estimate = words > 0; const readout = `${minutesLeft} min left`; const finish = `${doneLabel} · ${totalMinutes} min`; const valueText = estimate ? `${percent}% read, ${readout}` : `${percent}% read`; return (
{estimate ? (
{finish} {readout} {finish}
) : null}
); } ```