Reading Progress
A thin bar across the top fills as you read down the article, so readers always know how much is left.
Designing calm interfaces
Create a reading progress bar for an article in React. Listen to the scroll event of the scrolling element (window for a full page) with a passive listener, compute progress as scrollTop / (scrollHeight - clientHeight), and write it into a CSS custom property --p on the container instead of React state, so scrolling never re-renders. A sticky bar at the top uses transform: scaleX(var(--p)) with transform-origin left and an accent background; next to it, a small mono label shows the rounded percentage. Do not transition the bar, so it tracks the scroll exactly. On browsers that support animation-timeline: scroll(), this can be done in pure CSS instead. Under prefers-reduced-motion it stays as is, since it only moves when the user scrolls. The article title reads "Designing calm interfaces".
Source
'use client'
import { useRef } from 'react'
import s from './demo.module.css'
export default function ScrollProgress({ labels }: { labels: Record<string, string> }) {
const pct = useRef<HTMLSpanElement>(null)
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const p = el.scrollTop / (el.scrollHeight - el.clientHeight || 1)
el.style.setProperty('--p', String(p))
if (pct.current) pct.current.textContent = `${Math.round(p * 100)}% ${labels.read}`
}
return (
<div className={s.frame} tabIndex={0} onScroll={onScroll}>
<div className={s.top}>
<span className={s.bar} />
<span ref={pct} className={s.pct}>
0% {labels.read}
</span>
</div>
<article className={s.article}>
<h3 className={s.title}>{labels.title}</h3>
{Array.from({ length: 14 }, (_, i) => (
<span key={i} className={s.line} style={{ width: `${[100, 94, 98, 70][i % 4]}%` }} />
))}
</article>
</div>
)
}
.frame {
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.frame:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -4px; }
.hint {
display: block;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
.frame { --p: 0; }
.top {
position: sticky;
top: 0;
z-index: 1;
display: flex;
justify-content: flex-end;
padding: 10px 16px 8px;
background: var(--stage-bg);
}
.bar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: var(--stage-accent);
transform: scaleX(var(--p));
transform-origin: left;
}
.pct {
font: 500 var(--text-xs) / 1 var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--stage-muted);
}
.article {
display: grid;
gap: 12px;
max-width: 300px;
margin: 0 auto;
padding: 8px 16px 40px;
}
.title {
margin: 0 0 6px;
font: 600 var(--text-xl) / 1.2 var(--font-display);
color: var(--stage-ink);
}
.line {
display: block;
height: 8px;
border-radius: 4px;
background: var(--stage-surface);
}