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:

  1. A target: any element carrying a unique id attribute, for example <h2 id="pricing">.
  2. 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.

website navigation menu

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>
website navigation menu

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; }
}
website navigation menu

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 a {
  display: block;
  padding: 6px 12px;
  color: #55606b;
  text-decoration: none;
  border-left: 2px solid transparent;
}

.toc a.is-active {
  color: #0b7285;
  border-left-color: #0b7285;
  font-weight: 600;
}

Step 6: fix keyboard focus after the jump

Here is a bug almost nobody catches. When smooth scrolling is handled by CSS the focus does move, but if you ever intercept the click in JavaScript, focus stays on the link. A keyboard user then presses Tab and lands back at the top of the page. uservoice.com has covered this at length.

Make targets programmatically focusable and move focus after the scroll:

document.querySelectorAll('.toc a[href^="#"]').forEach(link => {
  link.addEventListener('click', () => {
    const target = document.querySelector(link.getAttribute('href'));
    if (!target) return;
    target.setAttribute('tabindex', '-1');
    /* Let the smooth scroll start, then hand over focus */
    setTimeout(() => target.focus({ preventScroll: true }), 400);
  });
});

Add [tabindex="-1"]:focus { outline: none; } if you do not want a focus ring on the section wrapper, but keep visible focus styles on the links themselves.

website navigation menu

The complete, copy-paste example

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anchor link menu demo</title>
<style>
  :root { --header-height: 64px; }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: system-ui, sans-serif; }

  html { scroll-behavior: smooth; }
  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
  }

  header {
    position: sticky; top: 0; z-index: 10;
    height: var(--header-height);
    display: flex; align-items: center; padding: 0 20px;
    background: #111; color: #fff;
  }

  .layout { display: grid; grid-template-columns: 220px 1fr; gap: 32px; padding: 24px; }
  .toc { position: sticky; top: calc(var(--header-height) + 16px); align-self: start; }
  .toc ul { list-style: none; margin: 0; padding: 0; }
  .toc a { display: block; padding: 6px 12px; color: #55606b; text-decoration: none; border-left: 2px solid #e5e7eb; }
  .toc a.is-active { color: #0b7285; border-left-color: #0b7285; font-weight: 600; }

  section { min-height: 90vh; }
  [id] { scroll-margin-top: calc(var(--header-height) + 16px); }
  [tabindex="-1"]:focus { outline: none; }

  @media (max-width: 768px) {
    :root { --header-height: 56px; }
    .layout { grid-template-columns: 1fr; }
    .toc { position: static; }
  }
</style>
</head>
<body>

<header>My documentation</header>

<div class="layout">
  <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>
      <li><a href="#deploy">Deploy</a></li>
    </ul>
  </nav>

  <main>
    <section id="install"><h2>Install</h2><p>Content...</p></section>
    <section id="routing"><h2>Routing</h2><p>Content...</p></section>
    <section id="middleware"><h2>Middleware</h2><p>Content...</p></section>
    <section id="deploy"><h2>Deploy</h2><p>Content...</p></section>
  </main>
</div>

<script>
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);
});

function 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));
  });
}, { rootMargin: '-25% 0px -65% 0px', threshold: 0 });

map.forEach((link, section) => observer.observe(section));

links.forEach(link => {
  link.addEventListener('click', () => {
    const target = document.querySelector(link.getAttribute('href'));
    if (!target) return;
    target.setAttribute('tabindex', '-1');
    setTimeout(() => target.focus({ preventScroll: true }), 400);
  });
});
</script>
</body>
</html>

Mobile specific problems and how to solve them

  • The mobile URL bar shifts the viewport. Size hero sections with svh or dvh units instead of vh so the scroll position does not drift when the bar collapses.
  • The menu itself is sticky and eats the screen. On small screens, unstick the table of contents or collapse it inside a <details> element.
  • Tap targets too small. Give each anchor link at least 44px of height including padding.
  • Smooth scrolling feels endless on long pages. If a page is very long, keep the instant jump on mobile and reserve the animation for desktop, or shorten the sections.
  • Anchor inside a scrollable container. If a sidebar or modal owns the scroll, put scroll-behavior and the padding offset on that element, not on html.

Anchor links and single page apps

If your content is rendered by a framework, the target may not exist yet when the browser reads the hash on load. Two safe habits:

  1. After the content mounts, read location.hash and call element.scrollIntoView({ behavior: 'smooth', block: 'start' }) yourself.
  2. Update the URL with history.replaceState(null, '', '#section') when the active section changes, so users can copy a link to the exact spot without adding entries to the back button history.

On the server side, remember again that Express never receives the fragment, so a catch-all route serving your HTML is all you need. No special handling for anchors.

website navigation menu

Common anchor link errors and fixes

Symptom Likely cause Fix
Nothing happens on click Typo or case mismatch between href and id Match exactly, keep everything lowercase
Heading hidden under the header No scroll offset Add scroll-margin-top to targets
Jump is instant, not smooth scroll-behavior on the wrong element Put it on the actual scrolling container
Two links highlight at once Duplicate ids or overlapping observer band Make ids unique, tighten rootMargin
Page jumps to the top on click href="#" used as a placeholder Use a <button> for actions
Works on click, fails on a shared URL JavaScript offset applied only to click events Replace the script with CSS scroll-margin-top
Scroll lands too early on image-heavy pages Images without width and height reflow the page Set explicit dimensions or aspect ratios

Quick SEO note

Well structured anchor links are more than a UX detail. Google can surface them as sitelinks or scroll to a highlighted passage, and a clean in-page menu makes long articles easier to crawl and to understand. Keep the link text descriptive (Routing beats click here), keep the ids stable over time so shared links do not rot, and do not build a table of contents out of JavaScript-only elements that never appear in the HTML source.

FAQ

How do I anchor a link in HTML?

Add a unique id to the destination element, then link to it with a hash: <a href="#my-id">Go</a> pointing at <h2 id="my-id">. No plugin or script is required.

What is the difference between an anchor link and a hyperlink?

Both use the <a> element. A regular hyperlink loads a different document. An anchor link contains a fragment (#something) and moves the viewport to a position inside a document, either the current one or the one being loaded.

What is the difference between <a> and <link>?

<a> is a visible, clickable link inside the body. <link> lives in the <head> and declares a relationship to a resource, such as a stylesheet, a favicon or a canonical URL. Users never click a <link>.

Do anchor links work without JavaScript?

Yes. Jumping and smooth scrolling are handled by HTML and CSS alone. JavaScript is only needed for the active-link highlight and for focus management.

Can I link to a section of another page?

Yes, combine the path and the fragment: <a href="/guide/setup#requirements">Requirements</a>. The browser loads the page then scrolls to that id, and your scroll-margin-top rule keeps it clear of the header.

Should I use name or id for anchors?

Use id. The name attribute on <a> is obsolete, and id works as a target, as a CSS hook and as a JavaScript selector at the same time.

How do I highlight the section currently on screen?

Observe each section with IntersectionObserver and toggle a class on the matching menu link. A tuned rootMargin such as -25% 0px -65% 0px makes the highlight switch at reading position instead of at the edge of the viewport.

Why does my anchor scroll to the wrong place after images load?

The layout shifts while images arrive, so the saved position becomes wrong. Reserve space with width and height attributes or an aspect-ratio on every image, and the anchor will land exactly where you expect. The team at civicplus.help reached a similar conclusion.

Recent Posts

No Posts Found!

Categories

Tags

    Subscribe

    You have been successfully Subscribed! Ops! Something went wrong, please try again.

    About Us

    Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

    Contact Info

    Copyright © 2022 Express Jam Studio. All Rights Reserved.