AnimatedCSS
Scroll Progress
An accent hairline that fills as you scroll, driven by CSS scroll timelines with a light fallback.
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/glass/scroll-progress.jsonAlso installs the theme, cn(). Packages: clsx, tailwind-merge.
Usage
scroll-progress-demo.tsx
import { ScrollProgress } from "@/registry/kit/ui/scroll-progress";
const paragraphs = [
"Release notes are written as the work lands, not the night before. Each entry links to the change that caused it.",
"Builds now reuse the cache from the last successful deploy on the same branch, which cuts most rebuilds in half.",
"Preview links stay up for thirty days and inherit the environment variables of their target branch.",
"The dashboard remembers the last project you opened on each device.",
"Logs can be filtered by request id, and a click on any line opens the full trace.",
"Webhooks retry with exponential backoff for up to a day before giving up.",
];
export default function ScrollProgressDemo() {
return (
<div className="surface h-80 w-full max-w-lg overflow-hidden rounded-card">
{/* Scroll on an inner element: the surface shell itself must not scroll. */}
<div className="h-full overflow-y-auto [scrollbar-width:thin]">
<ScrollProgress scope="container" />
<article className="space-y-4 p-7 text-[15px] leading-relaxed text-muted">
<h3 className="font-display text-3xl tracking-[-0.02em] text-fg">Changelog</h3>
{paragraphs.concat(paragraphs).map((p, i) => (
<p key={i}>{p}</p>
))}
</article>
</div>
</div>
);
}API
scope= "page"
"page" | "container"Track the document, or the nearest scrolling ancestor.
Source
components/ui/scroll-progress.tsx
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/registry/kit/lib/utils";
const styles = `
@keyframes kit-scroll-grow { from { transform: scaleX(0) } to { transform: scaleX(1) } }
.kit-scroll-progress { transform: scaleX(0); transform-origin: left }
@supports (animation-timeline: scroll()) {
.kit-scroll-progress { animation: kit-scroll-grow linear both }
.kit-scroll-progress[data-scope="page"] { animation-timeline: scroll(root) }
.kit-scroll-progress[data-scope="container"] { animation-timeline: scroll(nearest) }
}
`;
export interface ScrollProgressProps {
/**
* `page` pins a bar to the top of the viewport and tracks the document.
* `container` sticks to the top of the nearest scrolling ancestor.
*/
scope?: "page" | "container";
className?: string;
}
function scrollParent(el: HTMLElement | null): HTMLElement | null {
for (let node = el?.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
/**
* An accent hairline that fills as you scroll. Runs on CSS scroll-driven
* animations off the main thread; browsers without them get a passive,
* frame-throttled listener instead.
*/
export function ScrollProgress({ scope = "page", className }: ScrollProgressProps) {
const barRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const bar = barRef.current;
if (!bar || CSS.supports("animation-timeline: scroll()")) return;
const container = scope === "container" ? scrollParent(bar) : null;
const target: HTMLElement | Window = container ?? window;
let frame = 0;
const update = () => {
frame = 0;
const el = container ?? document.documentElement;
const range = el.scrollHeight - el.clientHeight;
bar.style.setProperty("transform", `scaleX(${range > 0 ? el.scrollTop / range : 0})`);
};
const onScroll = () => {
frame ||= requestAnimationFrame(update);
};
update();
target.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelAnimationFrame(frame);
target.removeEventListener("scroll", onScroll);
};
}, [scope]);
return (
<>
<style href="kit-scroll-progress" precedence="medium">
{styles}
</style>
<div
ref={barRef}
aria-hidden
data-scope={scope}
className={cn(
"kit-scroll-progress z-50 h-[2px] w-full bg-accent",
scope === "page" ? "fixed inset-x-0 top-0" : "sticky top-0",
className,
)}
/>
</>
);
}