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
idattribute, for example<h2 id="pricing">. - A link: an
<a>element whosehrefis 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, notGetting Started. - No spaces. If you must use one in a URL, it becomes
%20, which is fragile. - Ids are case sensitive in the fragment:
#Setupwill not matchid="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, notbody. If a wrapper element owns the scrollbar (overflow: auto), put the rule on that element instead. - Always add the
prefers-reduced-motionguard. 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 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.

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
svhordvhunits instead ofvhso 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-behaviorand the padding offset on that element, not onhtml.
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:
- After the content mounts, read
location.hashand callelement.scrollIntoView({ behavior: 'smooth', block: 'start' })yourself. - 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.

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.
