Scroll Storytelling · Starter
Reading Progress Indicator
A ScrollTrigger spanning the article reports progress to a scaleX bar and readout inside a sticky header.
Live preview
Read the essay; the sticky hairline tracks your position. The preview scrolls independently of this page.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Reading Progress Indicator
* An editorial essay with a softly scrubbed sticky reading indicator.
* Requires: gsap, Tailwind CSS with shadcn semantic tokens.
*/
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
function useSystemReduced() {
const [value, setValue] = useState(true);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setValue(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return value;
}
const sections = [
{
title: "Begin with less",
paragraphs: [
"Restraint is not the absence of an idea. It is the confidence to let one idea be enough. A generous margin, a deliberate pause, a single image given room to breathe: each asks us to look more closely instead of looking elsewhere.",
"The temptation is to add. Another transition, another layer, another explanation of what the work already knows how to say. But attention is finite. Every additional gesture borrows something from the gesture beside it.",
],
},
{
title: "Give attention a place to land",
paragraphs: [
"A page has a rhythm before anything moves. The distance between a heading and its first sentence establishes an expectation. The width of a paragraph determines how long a thought can travel before the eye returns.",
"Motion should respect that rhythm. It can introduce a new chapter, reveal a relationship, or confirm that an action has been understood. It need not announce itself every time the reader turns a corner.",
],
},
{
title: "Let the useful become beautiful",
paragraphs: [
"The most convincing detail often begins as a practical decision. An indicator explains where we are. A transition preserves the connection between two states. A moment of stillness gives something important time to be understood.",
"Beauty arrives when these decisions agree with one another. Nothing feels pasted on. The surface and the structure share the same intention, and the experience becomes easier without becoming less distinctive.",
],
},
{
title: "Know when to stop",
paragraphs: [
"Finishing is a different skill from making. It requires us to notice the point at which another refinement would take more than it gives. The work is not complete because every possibility has been explored.",
"It is complete when the necessary things are clear, the unnecessary things have fallen away, and the reader can move through it without having to think about the person who designed it.",
],
},
];
export default function ScrollProgressIndicator({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useSystemReduced();
const reduced = reducedProp ?? systemReduced;
const scopeRef = useRef<HTMLElement>(null);
const articleRef = useRef<HTMLElement>(null);
const [percentage, setPercentage] = useState(0);
const strength = Math.min(1.5, Math.max(0.5, intensity));
useEffect(() => {
if (reduced || !scopeRef.current) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const article = articleRef.current;
if (!article) return;
const progress = { value: 0 };
gsap.to(progress, {
value: 100,
ease: "none",
onUpdate: () => setPercentage(Math.round(progress.value)),
scrollTrigger: {
trigger: article,
start: "top top+=100",
end: "bottom bottom",
scrub: 0.35 * strength,
invalidateOnRefresh: true,
},
});
}, scopeRef);
const timer = window.setTimeout(() => ScrollTrigger.refresh(), 200);
return () => {
window.clearTimeout(timer);
ctx.revert();
};
}, [reduced, strength]);
return (
<main ref={scopeRef} className="bg-background text-foreground">
<header className="mx-auto max-w-5xl px-6 py-[18vh]">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Journal / design & attention
</p>
<h1 className="mt-6 font-serif text-6xl tracking-tight md:text-9xl">On restraint.</h1>
<p className="mt-8 max-w-xl text-xl leading-relaxed text-muted-foreground">
The quiet confidence of knowing what to leave out.
</p>
<p className="mt-8 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
An essay in four parts
</p>
</header>
<section className="relative" aria-label="Essay and reading progress">
<div
className={`${reduced ? "" : "sticky top-0 z-10"} border-y border-border bg-background`}
>
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-5">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
On restraint
</p>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{reduced ? "Four-part essay" : `${percentage}% read`}
</span>
</div>
{!reduced && (
<div
role="progressbar"
aria-label="Reading progress"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
className="h-px bg-muted"
>
<div
className="h-full origin-left bg-accent"
style={{ transform: `scaleX(${percentage / 100})` }}
/>
</div>
)}
</div>
<article ref={articleRef} className="mx-auto max-w-2xl px-6 pb-24 pt-16">
<p className="mb-16 border-l-2 border-border pl-6 font-serif text-3xl leading-snug tracking-tight md:text-4xl">
“The most considered gesture may be the one you choose not to make.”
</p>
{sections.map((section, index) => (
<section
key={section.title}
className="mb-20"
aria-labelledby={`restraint-part-${index}`}
>
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Part 0{index + 1}
</p>
<h2
id={`restraint-part-${index}`}
className="mb-7 mt-4 font-serif text-4xl tracking-tight md:text-5xl"
>
{section.title}
</h2>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} className="mt-6 text-lg leading-[1.9] text-muted-foreground">
{paragraph}
</p>
))}
</section>
))}
<p className="border-t border-border pt-8 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
End of essay / thank you for reading
</p>
</article>
</section>
<footer className="px-6 py-[25vh] text-center font-serif text-4xl tracking-tight">
Enough is a beautiful thing.
</footer>
</main>
);
}
Next pattern3D Card Tilt with Glare→