Navigation
ScrollNav
A section list that follows your reading. The rail fills as you scroll, the marker glides to the section you are in, and a click scrolls there smoothly.
Download
- React
- Tailwind CSS v4
- motion
components/ui/scroll-nav.tsx
"use client";
import { useEffect, useId, useState, type MouseEvent, type RefObject } from "react";
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
export type ScrollNavItem = { label: string; href: `#${string}` };
type ScrollNavProps = {
/** One link per section. href = "#" + the section's id. */
items: ScrollNavItem[];
/** Scrolling box that holds the sections. Leave out to use the page. */
container?: RefObject<HTMLElement | null>;
/** Pixels from the top where a section counts as "reached" (e.g. a sticky header's height). */
offset?: number;
/** Called on click, after the smooth scroll starts. */
onSelect?: (item: ScrollNavItem, e: MouseEvent<HTMLAnchorElement>) => void;
orientation?: "vertical" | "horizontal";
/** Colour of the progress rail and active marker. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Text size and colour. */
className?: string;
};
/* ScrollNav: a section list that follows your reading. The rail fills with scroll progress and the marker glides to the section you are in. */
export function ScrollNav({
items,
container,
offset = 80,
onSelect,
orientation = "vertical",
accent = "#a78bfa",
label = "On this page",
className = "text-sm text-white/55",
}: ScrollNavProps) {
const reduce = useReducedMotion();
const uid = useId();
const [current, setCurrent] = useState(items[0]?.href);
const { scrollYProgress } = useScroll(container ? { container } : undefined);
const fill = useSpring(scrollYProgress, { stiffness: 200, damping: 30, restDelta: 0.001 });
const vertical = orientation === "vertical";
// The active section is the last one whose top has passed the offset line.
useEffect(() => {
const box = container?.current;
const target: HTMLElement | Window = box ?? window;
const find = (href: string) => (box ?? document).querySelector<HTMLElement>(href);
const update = () => {
const top = (box ? box.getBoundingClientRect().top : 0) + offset + 1;
let next = items[0]?.href;
for (const item of items) {
const el = find(item.href);
if (el && el.getBoundingClientRect().top <= top) next = item.href;
}
// At the very bottom, the last section wins even if it is short.
const atEnd = box
? box.scrollTop + box.clientHeight >= box.scrollHeight - 2
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
if (atEnd) next = items[items.length - 1]?.href;
setCurrent(next);
};
update();
target.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
target.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [items, container, offset]);
const go = (item: ScrollNavItem, e: MouseEvent<HTMLAnchorElement>) => {
const box = container?.current;
const el = (box ?? document).querySelector<HTMLElement>(item.href);
if (!el) return;
e.preventDefault();
const behavior = reduce ? "auto" : "smooth";
if (box) {
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset;
box.scrollTo({ top, behavior });
} else {
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior });
}
setCurrent(item.href);
onSelect?.(item, e);
};
return (
<nav aria-label={label} className={`${vertical ? "" : "overflow-x-auto"} ${className}`}>
<div className={`relative flex ${vertical ? "flex-col pl-4" : "w-max min-w-full flex-row pb-3"}`}>
{/* rail */}
<span
aria-hidden
className={`absolute rounded-full bg-white/10 ${vertical ? "top-0 bottom-0 left-0 w-0.5" : "right-0 bottom-0 left-0 h-0.5"}`}
/>
<motion.span
aria-hidden
className={`absolute rounded-full ${vertical ? "top-0 bottom-0 left-0 w-0.5 origin-top" : "right-0 bottom-0 left-0 h-0.5 origin-left"}`}
style={{ background: accent, opacity: 0.45, ...(vertical ? { scaleY: reduce ? scrollYProgress : fill } : { scaleX: reduce ? scrollYProgress : fill }) }}
/>
<ul className={`flex ${vertical ? "flex-col gap-1" : "flex-row gap-1"}`}>
{items.map((item) => {
const on = item.href === current;
return (
<li key={item.href} className="relative shrink-0">
{on && (
<motion.span
layoutId={`${uid}-marker`}
aria-hidden
className={`absolute rounded-full ${vertical ? "top-1 bottom-1 -left-4 w-0.5" : "right-2 -bottom-3 left-2 h-0.5"}`}
style={{ background: accent, boxShadow: `0 0 10px ${accent}` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 32 }}
/>
)}
<a
href={item.href}
aria-current={on ? "location" : undefined}
onClick={(e) => go(item, e)}
className={`block rounded-md px-2 py-1 whitespace-nowrap outline-none transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 ${on ? "text-white" : ""}`}
>
{item.label}
</a>
</li>
);
})}
</ul>
</div>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/scroll-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/scroll-nav-demo.tsx
"use client"; import { useRef } from "react"; import { ScrollNav } from "@/components/ui/scroll-nav"; const ITEMS = [ { label: "Intro", href: "#sn-intro" }, { label: "Install", href: "#sn-install" }, { label: "Usage", href: "#sn-usage" }, { label: "Props", href: "#sn-props" }, { label: "FAQ", href: "#sn-faq" }, ] as const; export default function ScrollNavExample() { // The preview scrolls inside its own box. On a real page, leave out `container`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="relative h-full overflow-y-auto bg-[#0b0a14]"> <div className="grid grid-cols-[auto_1fr] gap-6 p-5"> <div className="sticky top-5 self-start"> <ScrollNav items={[...ITEMS]} container={box} offset={20} /> </div> <div className="grid gap-6 pb-40"> {ITEMS.map((item) => ( <section key={item.href} id={item.href.slice(1)} className="rounded-xl border border-white/5 bg-white/[0.03] p-4"> <h3 className="text-sm font-medium text-white">{item.label}</h3> <div className="mt-3 grid gap-2"> {Array.from({ length: 4 }, (_, i) => ( <div key={i} className="h-2 rounded-full bg-white/10" style={{ width: `${90 - i * 15}%` }} /> ))} </div> </section> ))} </div> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href }[] | โ | One link per section. href = "#" + the section id. |
| container | RefObject<HTMLElement> | โ | Scrolling box that holds the sections. Leave out to use the page. |
| offset | number | 80 | Pixels from the top where a section counts as reached (e.g. sticky header height). |
| onSelect | (item, e) => void | โ | Called after the smooth scroll starts. |
| orientation | "vertical" | "horizontal" | "vertical" | Column with a side rail, or row with a bottom rail. |
| accent | string | "#a78bfa" | Rail and marker colour. |
| label | string | "On this page" | Name read out by screen readers. |
| className | string | "text-sm text-white/55" | Text size and colour. |