UI Components Medium
Anchor Navigation
Sticky in-page navigation that highlights the active section as you scroll, with smooth scroll-to-section on click.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #050910;
--card: #0d1117;
--text: #f2f6ff;
--muted: #475569;
--border: rgba(255, 255, 255, 0.08);
--accent: #38bdf8;
--sidebar-w: 220px;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
}
html {
scroll-behavior: smooth;
}
body {
font-family: Inter, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* โโ Progress bar โโ */
.progress-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.06);
z-index: 300;
}
.progress-bar__fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.1s linear;
border-radius: 0 2px 2px 0;
}
/* โโ Layout โโ */
.layout {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
max-width: 900px;
margin: 0 auto;
padding: 3rem 1.5rem;
gap: 3rem;
min-height: 100vh;
align-items: start;
}
/* โโ Sidebar โโ */
.sidebar {
position: sticky;
top: 2rem;
}
/* โโ Anchor nav โโ */
.anchor-nav {
}
.anchor-nav__title {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.875rem;
}
.anchor-nav__list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0;
border-left: 1px solid var(--border);
}
.anchor-nav__link {
display: block;
padding: 0.45rem 0.875rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--muted);
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -1px;
transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}
.anchor-nav__link:hover {
color: var(--text);
}
.anchor-nav__link--active {
color: var(--accent);
border-left-color: var(--accent);
font-weight: 600;
}
/* โโ Content โโ */
.content {
min-width: 0;
}
.content__header {
margin-bottom: 3rem;
}
.content__page-title {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: 0.75rem;
}
.content__lead {
font-size: 1.0625rem;
color: var(--muted);
line-height: 1.7;
}
/* โโ Section โโ */
.section {
padding-top: 2rem;
padding-bottom: 2.5rem;
border-bottom: 1px solid var(--border);
scroll-margin-top: 2rem;
}
.section:last-child {
border-bottom: none;
}
.section__title {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 1.125rem;
}
.section__body {
font-size: 0.9375rem;
color: #94a3b8;
line-height: 1.75;
margin-bottom: 1rem;
}
.section__body:last-child {
margin-bottom: 0;
}
.code {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.8125em;
background: rgba(255, 255, 255, 0.07);
padding: 0.1em 0.4em;
border-radius: 4px;
color: var(--accent);
}
/* โโ Callout โโ */
.callout {
margin-top: 1.25rem;
padding: 0.875rem 1rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
font-size: 0.875rem;
color: #94a3b8;
line-height: 1.6;
}
.callout strong {
color: var(--text);
}
.callout--blue {
background: rgba(56, 189, 248, 0.05);
border-color: rgba(56, 189, 248, 0.2);
}
/* โโ Feature list โโ */
.feature-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.875rem;
margin-top: 1rem;
}
.feature-item {
display: flex;
gap: 0.75rem;
font-size: 0.9rem;
color: #94a3b8;
line-height: 1.6;
}
.feature-item strong {
color: var(--text);
}
.feature-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
margin-top: 0.45em;
}
/* โโ Mobile โโ */
@media (max-width: 640px) {
.layout {
grid-template-columns: 1fr;
padding: 1.5rem 1rem;
gap: 1.5rem;
}
.sidebar {
position: static;
}
.anchor-nav__list {
flex-direction: row;
flex-wrap: wrap;
border-left: none;
gap: 0.5rem;
}
.anchor-nav__link {
border-left: none;
border-bottom: 2px solid transparent;
padding: 0.3rem 0.75rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
margin-left: 0;
}
.anchor-nav__link--active {
border-bottom-color: var(--accent);
background: rgba(56, 189, 248, 0.08);
}
}(function () {
var navLinks = document.querySelectorAll(".anchor-nav__link");
var sections = document.querySelectorAll(".section");
var progressFill = document.getElementById("progress-fill");
var content = document.getElementById("content");
if (!navLinks.length || !sections.length) return;
// โโ Scroll progress bar โโ
function updateProgress() {
var scrollEl = document.documentElement;
var scrolled = scrollEl.scrollTop || document.body.scrollTop;
var total = scrollEl.scrollHeight - scrollEl.clientHeight;
var pct = total > 0 ? Math.min(100, (scrolled / total) * 100) : 0;
if (progressFill) progressFill.style.width = pct + "%";
}
window.addEventListener("scroll", updateProgress, { passive: true });
updateProgress();
// โโ Active section tracking โโ
var visibleSections = new Set();
function setActiveLink(id) {
navLinks.forEach(function (link) {
var isActive = link.getAttribute("href") === "#" + id;
link.classList.toggle("anchor-nav__link--active", isActive);
if (isActive) {
link.setAttribute("aria-current", "true");
} else {
link.removeAttribute("aria-current");
}
});
}
function pickTopmost() {
var topmost = null;
var topY = Infinity;
visibleSections.forEach(function (id) {
var el = document.getElementById(id);
if (el) {
var y = el.getBoundingClientRect().top;
if (y < topY) {
topY = y;
topmost = id;
}
}
});
return topmost;
}
var observer = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
var id = entry.target.id;
if (entry.isIntersecting) {
visibleSections.add(id);
} else {
visibleSections.delete(id);
}
});
var active = pickTopmost();
if (active) setActiveLink(active);
},
{
rootMargin: "-10% 0px -60% 0px",
threshold: 0,
}
);
sections.forEach(function (section) {
if (section.id) observer.observe(section);
});
// โโ Smooth scroll on click โโ
navLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var id = link.getAttribute("href").slice(1);
var target = document.getElementById(id);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "start" });
// Update immediately for snappy UX
setActiveLink(id);
}
});
});
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anchor Navigation</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Progress bar -->
<div class="progress-bar" id="progress-bar" aria-hidden="true">
<div class="progress-bar__fill" id="progress-fill"></div>
</div>
<div class="layout">
<!-- โโ Sticky sidebar nav โโ -->
<aside class="sidebar">
<nav class="anchor-nav" aria-label="On this page">
<p class="anchor-nav__title">On this page</p>
<ul class="anchor-nav__list" role="list">
<li>
<a class="anchor-nav__link anchor-nav__link--active" href="#introduction" aria-current="true">Introduction</a>
</li>
<li>
<a class="anchor-nav__link" href="#features">Features</a>
</li>
<li>
<a class="anchor-nav__link" href="#usage">Usage</a>
</li>
<li>
<a class="anchor-nav__link" href="#customization">Customization</a>
</li>
</ul>
</nav>
</aside>
<!-- โโ Page content โโ -->
<main class="content" id="content">
<header class="content__header">
<h1 class="content__page-title">Anchor Navigation</h1>
<p class="content__lead">Scroll down to see the sidebar highlight the active section in real time.</p>
</header>
<section id="introduction" class="section">
<h2 class="section__title">Introduction</h2>
<p class="section__body">Anchor navigation (scroll-spy) is a pattern that keeps users oriented within long-form content pages โ documentation, blog posts, legal pages โ by highlighting the section currently in view.</p>
<p class="section__body">This component uses the native <code class="code">IntersectionObserver</code> API for efficient, paint-free scroll detection. There are no event listeners on the scroll event itself, which keeps performance budget low even on complex pages.</p>
<p class="section__body">The sidebar sticks to the viewport as the user scrolls, providing persistent access to every section link. Clicking a link smooth-scrolls to the target without a visible hash change in the URL bar.</p>
<div class="callout">
<strong>Tip:</strong> Combine this with a reading-progress bar (the thin line at the top of this page) to give users a sense of how far through the content they are.
</div>
</section>
<section id="features" class="section">
<h2 class="section__title">Features</h2>
<p class="section__body">This implementation ships with several quality-of-life features out of the box.</p>
<ul class="feature-list">
<li class="feature-item">
<span class="feature-dot" aria-hidden="true"></span>
<strong>Scroll-spy via IntersectionObserver</strong> โ the topmost visible section is always highlighted, not just the last one to cross the threshold.
</li>
<li class="feature-item">
<span class="feature-dot" aria-hidden="true"></span>
<strong>Smooth scroll on click</strong> โ no jarring anchor jumps; the page animates fluidly to the target.
</li>
<li class="feature-item">
<span class="feature-dot" aria-hidden="true"></span>
<strong>Reading progress bar</strong> โ a fixed-top bar fills as the user approaches the end of the content.
</li>
<li class="feature-item">
<span class="feature-dot" aria-hidden="true"></span>
<strong>Accessible markup</strong> โ <code class="code">aria-current</code>, <code class="code">aria-label</code>, and semantic <code class="code"><nav></code> element.
</li>
<li class="feature-item">
<span class="feature-dot" aria-hidden="true"></span>
<strong>Zero dependencies</strong> โ pure HTML, CSS, and Vanilla JS.
</li>
</ul>
</section>
<section id="usage" class="section">
<h2 class="section__title">Usage</h2>
<p class="section__body">Drop the sidebar HTML alongside your content and ensure each content section has a unique <code class="code">id</code>. The sidebar links must use <code class="code">href="#section-id"</code> to match. Then include the script โ the observer wires itself up automatically.</p>
<p class="section__body">The sidebar is positioned with CSS <code class="code">position: sticky</code> inside a two-column grid layout. It will follow the user as they scroll without any JavaScript required for the sticking behavior.</p>
<p class="section__body">For mobile screens, the sidebar collapses into a compact horizontal pill-row nav at the top of the content. The same JS wires up the active state for both orientations.</p>
<div class="callout callout--blue">
<strong>Note:</strong> For this demo the page content is contained inside a scrollable <code class="code">.content</code> element rather than the window โ the <code class="code">IntersectionObserver</code> uses the content wrapper as its root.
</div>
</section>
<section id="customization" class="section">
<h2 class="section__title">Customization</h2>
<p class="section__body">All visual tokens are declared as CSS custom properties on <code class="code">:root</code> so you can override them without touching the component CSS.</p>
<p class="section__body">Change the accent color, indicator thickness, sidebar width, or transition timing โ all from a single set of variables at the top of your stylesheet.</p>
<p class="section__body">The observer's <code class="code">rootMargin</code> controls when sections are considered "active". The default value of <code class="code">-30% 0px -60% 0px</code> means a section is highlighted when its top edge is within the upper 30โ40 % of the viewport โ a comfortable reading zone.</p>
</section>
</main>
</div>
<script src="script.js"></script>
</body>
</html>Anchor Navigation
A sticky in-page sidebar navigation that uses IntersectionObserver to highlight the active section as you scroll. Clicking a link smooth-scrolls to the target section. No dependencies.
Features
- Sticky sidebar that tracks scroll position
- Active link highlighted with accent color and an animated left border indicator
- Smooth scroll on click via
scrollIntoView({ behavior: 'smooth' }) - Reading progress bar at the top of the page
IntersectionObserverwatches all section headings โ the topmost visible section wins
How it works
- Each section has a unique
id. The sidebar links point to these IDs withhref="#id" - An
IntersectionObserverwith a negative top rootMargin fires when a section enters the viewport - The observer tracks which sections are currently intersecting and highlights the one closest to the top
- Click handler calls
e.preventDefault()thensection.scrollIntoView()to animate smoothly without a hash jump
Accessibility
- Nav uses
<nav aria-label="On this page"> - Active link receives
aria-current="true" - Each section has a proper heading hierarchy starting with
<h2>