How to Create an Anchor Link Menu That Scrolls Smoothly (HTML, CSS, and JS)
Anchor links in HTML, explained in 30 seconds An anchor link (also called a jump link or in-page link) is a hyperlink that sends the visitor to a specific spot on a page instead of loading a new document. It needs two pieces: A target: any element carrying a unique id attribute, for example <h2 id=”pricing”>. A link: an <a> element whose href is the id preceded by a hash, for example <a href=”#pricing”>Pricing</a>. <!– The link –> <a href=”#pricing”>Jump to pricing</a> <!– The target, anywhere on the same page –> <h2 id=”pricing”>Pricing</h2> That is the whole mechanism. The old <a name=”pricing”> syntax still works in browsers, but it was dropped from the HTML spec, so use id on the real element instead. The problem is that raw anchor links behave badly on modern sites: the browser jumps instantly, the target heading ends up hidden behind a fixed header, and nothing in the menu tells the reader where they are. This tutorial fixes all three issues with about 20 lines of CSS and a small script. Anchor link syntax: every variation you will need Goal HTML Result Jump to a section on the same page <a href=”#setup”>Setup</a> Scrolls to the element with id=”setup” Jump to a section of another page <a href=”/docs/routing#params”>Params</a> Loads the page, then scrolls to #params Back to the top <a href=”#top”>Top</a> Scrolls to the top even without an element named top Link on an external domain <a href=”https://example.com/guide#faq”>FAQ</a> Opens the URL and scrolls to #faq Placeholder link (no jump) <a href=”#” role=”button”>Open</a> Avoid it, use <button> for actions Rules for the id value It must be unique on the page. Duplicated ids make the browser pick the first match and break your highlight logic. Use lowercase letters, digits and hyphens: getting-started, not Getting Started. No spaces. If you must use one in a URL, it becomes %20, which is fragile. Ids are case sensitive in the fragment: #Setup will not match id=”setup”. One detail that trips up backend developers: the fragment after the # is never sent to the server. If you serve pages with Express, your route handler sees /docs/routing and nothing else. Anchor scrolling is 100% a browser job, which is why every fix below lives in CSS and JavaScript. Step 1: mark up the sections Put the id on the element you actually want at the top of the viewport. In most layouts that is the <section> wrapper rather than the heading, because the section has padding you want to keep visible. <main> <section id=”install”> <h2>Install</h2> <p>…</p> </section> <section id=”routing”> <h2>Routing</h2> <p>…</p> </section> <section id=”middleware”> <h2>Middleware</h2> <p>…</p> </section> </main> Step 2: build the menu An in-page menu is a navigation landmark, so wrap it in <nav> and give it a label. Screen reader users then get a real table of contents instead of a pile of links. See echoecho.com for their take. <nav class=”toc” aria-label=”On this page”> <ul> <li><a href=”#install”>Install</a></li> <li><a href=”#routing”>Routing</a></li> <li><a href=”#middleware”>Middleware</a></li> </ul> </nav> Step 3: turn the jump into a smooth scroll (pure CSS) You do not need a library for this. One CSS declaration on the scrolling container handles it, and it works in every current browser. html { scroll-behavior: smooth; } /* Respect users who ask for less motion */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } Two things worth knowing: Apply it to html, not body. If a wrapper element owns the scrollbar (overflow: auto), put the rule on that element instead. Always add the prefers-reduced-motion guard. Long animated scrolls trigger nausea for some users and it is a documented accessibility requirement. Step 4: stop the sticky header from covering your headings This is the number one complaint with anchor links html tutorials never mention. With a fixed or sticky header of 72px, the browser scrolls the target to y=0, which is behind the header. The fix is scroll-margin-top on the targets. :root { –header-height: 72px; } /* Any element that can be an anchor target */ [id] { scroll-margin-top: calc(var(–header-height) + 16px); } scroll-margin-top tells the browser to leave that much space above the element when it scrolls it into view. It works for clicks, for keyboard navigation and for a page loaded directly with a hash in the URL, which is exactly what the old JavaScript offset hacks failed to do. Do not use these old workarounds Old trick Why it fails Empty <span id=”x”> pushed up with negative margins Breaks layout, invisible targets confuse assistive tech padding-top + negative margin-top on every section Creates dead click zones over the previous section window.scrollTo(y – offset) on click Does nothing for direct hits on a URL that already contains a hash Header height that changes on mobile If your header shrinks on small screens, redeclare the variable in a media query. The scroll offset follows automatically. @media (max-width: 768px) { :root { –header-height: 56px; } } Step 5: highlight the link for the section being read An anchor menu without an active state is half a feature. Use IntersectionObserver rather than a scroll listener: it is cheaper, it does not fire hundreds of times per second and it does not jank on mobile. const links = Array.from(document.querySelectorAll(‘.toc a[href^=”#”]’)); const map = new Map(); links.forEach(link => { const target = document.querySelector(link.getAttribute(‘href’)); if (target) map.set(target, link); }); const setActive = (link) => { links.forEach(l => { l.classList.toggle(‘is-active’, l === link); if (l === link) { l.setAttribute(‘aria-current’, ‘true’); } else { l.removeAttribute(‘aria-current’); } }); }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) setActive(map.get(entry.target)); }); }, { /* Activate a section when it crosses the upper third of the screen */ rootMargin: ‘-25% 0px -65% 0px’, threshold: 0 }); map.forEach((link, section) => observer.observe(section)); The rootMargin value is the part you tune. It shrinks the detection box to a horizontal band near the top of the viewport, so a section becomes active when its content reaches reading position, not when one pixel of it appears at the bottom. The matching CSS: .toc
How to Create an Anchor Link Menu That Scrolls Smoothly (HTML, CSS, and JS) Read More »




