Scroll progress bar
A thin bar under the article header fills from left to right as the reader moves through the text.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import styles from "./demo.module.css";
const paragraphs = [
"A gap night is the single open night between two bookings. Most guests never see it, because search filters hide stays shorter than your minimum.",
"Start by lowering the minimum stay for that one night only. Keep your usual rules everywhere else, so long stays still win the calendar.",
"Price it close to your floor, not your peak. The night is worth more sold at a fair rate than left empty at a perfect one.",
"Then offer it to the guest arriving next. One short message asking if they would like to start a day early converts better than any listing tweak.",
"Check the turnover first. If cleaning needs the morning, offer a late arrival instead of a full night and keep the team on schedule.",
"Write the offer like a host, not a system. Name the villa, the date and the price, and say why the night is open.",
"Send it early. A guest who books three weeks out has time to change a flight; one who arrives tomorrow usually does not.",
"Keep a floor you never go under. Discounting every gap teaches guests to wait, and the calendar starts working against you.",
"Track what you sold. After a month you will know which villas fill their gaps on their own and which ones need a nudge.",
];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const bar = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scroller.current;
if (!el) return;
let raf = 0;
const update = () => {
raf = 0;
const max = el.scrollHeight - el.clientHeight;
const progress = max > 0 ? el.scrollTop / max : 0;
bar.current!.style.transform = `scaleX(${progress})`;
if (el.scrollTop > 4) root.current!.classList.add(styles.scrolled);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(raf);
el.removeEventListener("scroll", onScroll);
};
}, []);
return (
<div ref={root} className={styles.root}>
<div ref={scroller} className={styles.scroller} tabIndex={0} aria-label="Article">
<header className={styles.head}>
<div className={styles.headInner}>
<span className={styles.kicker}>Host journal</span>
<span className={styles.time}>4 min read</span>
</div>
<div className={styles.track} aria-hidden="true">
<div ref={bar} className={styles.bar} />
</div>
</header>
<article className={styles.article}>
<h2>How to sell the night between two bookings</h2>
{paragraphs.map((text) => (
<p key={text}>{text}</p>
))}
<p className={styles.end}>Written by the Casa Mar team</p>
</article>
</div>
<span className={styles.hint} aria-hidden="true">Scroll inside</span>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; background: var(--white); overflow: hidden; }
.scroller { height: 100%; overflow-y: auto; overscroll-behavior: contain; }
.scroller:focus-visible { outline-offset: -3px; }
.head { position: sticky; top: 0; z-index: 1; padding: 12px 20px; background: color-mix(in srgb, var(--white) 92%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 600; }
.headInner { max-width: 520px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
.kicker { color: var(--accent-text); }
.time { color: var(--muted); font-variant-numeric: tabular-nums; }
.track { position: absolute; left: 0; right: 0; bottom: -1px; height: 3px; }
.bar { height: 100%; background: var(--accent-text); transform: scaleX(0); transform-origin: 0 50%; transition: transform .12s linear; }
.article { max-width: 560px; margin: 0 auto; padding: 24px 20px 48px; display: grid; gap: 14px; }
.article h2 { font-size: 22px; line-height: 1.2; letter-spacing: -0.02em; margin: 0 0 4px; text-wrap: balance; }
.article p { font-size: 15px; color: var(--muted); }
.end { font-size: 13px !important; font-weight: 600; color: var(--ink) !important; padding-top: 8px; border-top: 1px solid var(--line); }
.hint { position: absolute; left: 50%; bottom: 12px; translate: -50% 0; padding: 6px 12px; border-radius: 999px; background: var(--ink); color: var(--white); font-size: 12px; font-weight: 600; pointer-events: none; transition: opacity .3s var(--out); }
.scrolled .hint { opacity: 0; }
@media (prefers-reduced-motion: reduce) { .bar { transition: none; } }
Use it for
- Long articles, guides and help pages
- Policies or terms where readers want to know how much is left
Avoid
- Short pages that fit in one or two screens
- Feeds and lists with no real end
How it works
A passive scroll listener on the article container schedules one requestAnimationFrame per frame. It divides scrollTop by the scrollable distance (scrollHeight minus clientHeight) and writes the result as scaleX on the bar, which has its transform origin on the left. Transform only, so nothing reflows.
Accessibility
The bar is decorative and aria-hidden; the reading time sits beside it as text. Reduced motion removes the short smoothing transition so the bar simply tracks the position. The container is focusable so keyboard users can scroll it with arrow keys.