Reading Progress
An article reader with a thin progress bar on top, minutes left that count down, and a back-to-top ring that fills.
npx shadcn@latest add https://sundaeui.dev/r/reading-progress.json'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import './reading-progress.css'
export interface ReadingProgressProps {
title?: string
children: ReactNode
wpm?: number
showBar?: boolean
fill?: 'dark' | 'light'
className?: string
}
const C = 2 * Math.PI * 19
/** Article scroller with progress bar, time left and back-to-top. */
export function ReadingProgress({ title = 'Article', children, wpm = 230, showBar = true, fill = 'dark', className }: ReadingProgressProps) {
const body = useRef<HTMLDivElement>(null)
const [p, setP] = useState(0)
const [far, setFar] = useState(false)
const [mins, setMins] = useState<number | null>(null)
const onScroll = () => {
const el = body.current!; const max = el.scrollHeight - el.clientHeight
setP(max > 0 ? el.scrollTop / max : 1); setFar(el.scrollTop > el.clientHeight * 0.8)
if (mins === null) setMins(Math.max(1, Math.round((el.innerText.split(/\s+/).length || 0) / wpm)))
}
useEffect(() => { onScroll() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const total = mins ?? 1
const left = Math.ceil(total * (1 - p))
return (
<div className={['sd-rdp', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rdp__head"><span>{title}</span><span aria-live="off">{p < 0.02 ? `${total} min read` : p > 0.98 ? 'Finished' : `${left} min left`}</span>{showBar && <span className="sd-rdp__bar" role="progressbar" aria-label="Reading progress" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(p * 100)} style={{ width: `${p * 100}%` }} />}</div>
<div ref={body} className="sd-rdp__body" onScroll={onScroll} tabIndex={0}>{children}</div>
<button type="button" className="sd-rdp__top" data-hidden={!far || undefined} tabIndex={far ? 0 : -1} aria-label="Back to top" onClick={() => { body.current?.scrollTo({ top: 0, behavior: 'smooth' }); body.current?.querySelector<HTMLElement>('h3')?.focus({ preventScroll: true }) }}>
<svg viewBox="0 0 42 42" aria-hidden><circle className="bg" cx="21" cy="21" r="19" /><circle className="fg" cx="21" cy="21" r="19" strokeDasharray={C} strokeDashoffset={C * (1 - p)} /></svg>
<i><svg viewBox="0 0 12 12" aria-hidden><path d="M6 10V2M2.5 5.5 6 2l3.5 3.5" /></svg></i>
</button>
</div>
)
}.sd-rdp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rdp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rdp button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rdp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rdp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rdp { position: relative; width: min(100%, 330px); height: 400px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; display: flex; flex-direction: column; }
.sd-rdp__head { position: relative; display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 600; }
.sd-rdp__head span:last-child { font-weight: 400; color: var(--mute); font-size: 12px; font-variant-numeric: tabular-nums; }
.sd-rdp__bar { position: absolute; left: 0; bottom: -1px; height: 2px; background: var(--fg); }
.sd-rdp__body { flex: 1; overflow-y: auto; padding: 16px 18px 40px; font-size: 14px; line-height: 1.65; }
.sd-rdp__body h3 { margin: 0 0 8px; font-size: 19px; line-height: 1.25; letter-spacing: -0.01em; outline: 0; }
.sd-rdp__body p { margin: 0 0 14px; color: color-mix(in srgb, var(--fg) 85%, transparent); }
.sd-rdp__top { position: absolute; right: 14px; bottom: 14px; display: grid; place-items: center; width: 42px; height: 42px; border-radius: 99px; background: var(--bg) !important; box-shadow: 0 6px 16px -6px rgb(0 0 0 / 0.5), inset 0 0 0 1px var(--line); transition: opacity 200ms, transform 260ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-rdp__top[data-hidden] { opacity: 0; transform: translateY(12px) scale(0.8); pointer-events: none; }
.sd-rdp__top svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
.sd-rdp__top circle { fill: none; stroke-width: 2; }
.sd-rdp__top .bg { stroke: var(--soft); }
.sd-rdp__top .fg { stroke: var(--fg); stroke-linecap: round; }
.sd-rdp__top i { width: 12px; height: 12px; }
.sd-rdp__top i svg { position: static; transform: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-rdp *, .sd-rdp *::before, .sd-rdp *::after { animation: none !important; transition: none !important; } }Overview
As you scroll, the top bar fills and the header swaps “6 min read” for “3 min left”. After a screen of scrolling a back-to-top button appears; its ring fills with progress.
Works on any scroll container or the window.
When to use
- Long articles, docs, terms, changelogs.
When not to use
- Short pages that fit on screen.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/reading-progress.jsonnpx shadcn@latest add https://sundaeui.dev/r/reading-progress.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/reading-progress.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/reading-progress.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy reading-progress.tsx and reading-progress.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ReadingProgress } from '@/components/sundae/reading-progress'
<ReadingProgress />Props
| Prop | Type | Default |
|---|---|---|
children*Article content. | ReactNode | — |
wpmReading speed for the estimate. | number | 230 |
titleHeader title. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The bar is a progressbar; back to top is a labelled button that moves focus to the heading.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rdp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Reading Progress free?
- Yes. Reading Progress is free for personal and commercial projects, with full source on this page.
- How do I install Reading Progress?
- Run npx shadcn@latest add https://sundaeui.dev/r/reading-progress.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Reading Progress support keyboard and screen readers?
- The bar is a progressbar; back to top is a labelled button that moves focus to the heading. Reduced motion removes the movement and keeps every state change.
- Does Reading Progress respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Toast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined Hold to Confirm
Components · Refined- Pro
Focus Timer
Components · Refined