Create a Smooth Scrolling Anchor for Better Navigation
Make in-page navigation feel premium without weighing down performance: semantic anchors, a motion-safe Cascading Style Sheets (CSS) baseline, offset-aware headings, and a tiny progressive script. Sticking to simplicity avoids framework bloat and mystery bugs. Instead, we are left with a crisp, accessible scroll that respects users and plays nicely with SEO.
Why Smooth Scrolling Improves UX (When Done Right)
Most anchor links are “functional” in the sense that they mostly work, but they are rarely, if ever, thoughtful. On most sites, users click a table-of-contents item and have to deal with page elements snapping and overlapping. That kind of unspoken but present friction comes at the cost of trust. The good news: you don’t need five libraries to fix it — you need a predictable structure, modern CSS, and a few lines of JavaScript.
Smooth scrolling is important because it adds continuity between intent and destination — you’re showing “how the page works” in a way that helps orientation instead of fighting it. When done our way, it also honors motion preferences, maintains focus for screen readers, and keeps Core Web Vitals at high levels.
If you want to cross-check the fundamentals, start with
MDN on scroll-behavior
,
MDN on scroll-margin
,
and the WCAG note on motion from interactions.
Strategy First: Treat Anchors Like Information Architecture
Smooth scrolling may seem like a trivial detail on the surface, but underneath, it’s information architecture (IA) and search strategy at the most fundamental level. Descriptive section names alone have the power to make pages more scannable, fragments easily linkable in Slack and email, and give you natural internal link targets across your site when creating your information web paths.
That is distribution.
A lot of the issue at hand here is actually referencability, or the ability of your audience and your team to easily share site information and resources in an efficient, simple, and predictable manner. There are a few expectations when creating smooth referencing: Make readable IDs that reflect the heading of a given section, keep your table of contents in the same order as your narrative, and design for shareability. If a reader can paste /page#qa-checklist
into chat and the recipient lands in the right spot, you just made your content easier and more intuitive to follow — which is prime for human interactions and for search engines crawling your canonical page.
To see how this roll-up connects to site architecture and internal links, read site architecture for SEO success and internal linking best practices. They pair directly with this anchor strategy.
Step 1 — Enable Smooth Scrolling in CSS (Motion-Safe by Default)
/* Global smooth scrolling */
html { scroll-behavior: smooth; }
/* Respect users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
That media query isn’t optional. It’s the difference between “delight” and “nausea” for motion-sensitive users. WCAG covers this at a high level; see Animation from Interactions.
Step 2 — Prevent “Hidden Headings” Under Sticky Navigation
A sticky header is often the best choice for many pages... that is, until the reference anchor lands directly beneath it and causes headaches.
You can solve this in one of two ways:
set scroll-padding-top
on the scrolling container (html
or a wrapper) or set scroll-margin-top
on the targets. We prefer target-side spacing because it gives component-level control—you can tune cards, tabs, and headings independently.
:root { --header-h: 72px; } /* keep in sync with your layout */
:is(h2, h3, h4, [data-anchor], .anchor-target) { scroll-margin-top: calc(var(--header-h) + 12px); }
If your header collapses on scroll, update --header-h
when that state changes. MDN’s primer on
scroll-margin
is a quick reference.
Step 3 — Keep the Markup Honest: Real Links, Focusable Targets
Accessibility here hinges on two essential decisions: (1) whether or not the thing you click is a real anchor link and not a div element pretending to be one; (2) that the destination can receive a focus so assistive technology detects and announces it.
Here’s the baseline:
<nav aria-label="On this page" class="text-sm space-x-3">
<a href="#why" class="underline">Why</a>
<a href="#setup" class="underline">Setup</a>
<a href="#faq" class="underline">FAQ</a>
</nav>
<section id="why" class="anchor-target" tabindex="-1">...</section>
<section id="setup" class="anchor-target" tabindex="-1">...</section>
<section id="faq" class="anchor-target" tabindex="-1">...</section>
tabindex="-1"
allows programmatic focus without inserting the section into the normal tab order. For a concise background, see
WebAIM on skip navigation.
Step 4 — Progressive Script: Hash Links, Focus, Deep Links
CSS can animate the scroll, but that’s only half the job. You still need a little script to handle the human side: intercept same-page links so they don’t hard-jump, run a smooth scroll (or an instant jump if the user says “no motion”), and then shift focus so assistive tech actually announces where you landed.
That same helper also takes care of deep links like /page#faq
when someone shares a fragment, and it keeps back/forward navigation working the way people expect.
In other words: one small script makes anchors behave like they always should have.
<script>
(() => {
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function getTarget(hash){
try { return document.getElementById(decodeURIComponent(hash.replace('#',''))); }
catch { return null; }
}
function scrollToAndFocus(el){
if (!el) return;
el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
setTimeout(() => { el.focus({ preventScroll: true }); }, reduceMotion ? 0 : 250);
}
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href^="#"]');
if (!a || a.getAttribute('href') === '#') return;
const el = getTarget(a.getAttribute('href'));
if (!el) return;
e.preventDefault();
history.pushState(null, '', a.getAttribute('href'));
scrollToAndFocus(el);
});
if (location.hash) {
const el = getTarget(location.hash);
if (el) scrollToAndFocus(el);
}
window.addEventListener('hashchange', () => {
const el = getTarget(location.hash);
if (el) scrollToAndFocus(el);
});
})();
</script>
Keep it tiny. We’re talking well under 1 KB after minification. For the mindset behind “baseline first, enhancement second,” see progressive enhancement in practice.
Optional — Auto-Highlight the Section in View
On long pages, gently highlighting the link for the section in view reduces cognitive load. Using IntersectionObserver
means no scroll listeners and minimal main-thread work.
<script>
(() => {
const links = [...document.querySelectorAll('nav[aria-label="On this page"] a[href^="#"]')];
const map = new Map(links.map(a => [a.getAttribute('href').slice(1), a]));
const obs = new IntersectionObserver(entries => {
entries.forEach(({ target, isIntersecting }) => {
const a = map.get(target.id);
if (!a) return;
a.classList.toggle('text-[#d4a856] font-semibold', isIntersecting);
});
}, { rootMargin: '-40% 0px -50% 0px', threshold: 0.01 });
map.forEach((a, id) => {
const sec = document.getElementById(id);
if (sec) obs.observe(sec);
});
})();
</script>
Bonus — Add a Skip Link for Keyboard Users
A skip link lets keyboard and screen reader users jump straight to main content. It’s a tiny feature that sends a big signal — "we respect your time and your tools."
<a href="#main" class="sr-only focus:not-sr-only fixed left-2 top-2 bg-[#284B63] text-white px-3 py-2 rounded">
Skip to content
</a>
<main id="main" tabindex="-1">...</main>
Performance & SEO: Fast by Nature, Friendly by Design
While smooth anchors are effectively free outside of a quick document check and implementation — play defense by keeping animations native and avoiding shipping a framework just to move a viewport. Don't stop there — pre-size media around anchor destinations to prevent layout shifts and prefer observers to manual scroll listeners so interaction stays responsive.
- CLS: Pre-reserve image and embed space around anchor targets. We cover this in optimizing images for performance.
- INP: Keep the script tiny and event-driven. Parsing and execution should be negligible.
- Linkability: Descriptive IDs create natural internal links and better snippet sharing. Pair with internal linking best practices.
- Content design: Clear headings + table of contents increase dwell time and reduce pogo-sticking.
For a broader view on keeping pages light, Google’s web.dev performance course is my recommended baseline.
Integrate with Your System (Without Death-by-Props)
In design systems, anchor behavior gets spread across components: the TOC, the header, and the heading primitives.
Centralize the rules and keep them boring:
- Expose a global
--header-h
custom property that the header updates on scroll. - Ship a
.anchor-target
utility that appliesscroll-margin-top
and focus styles. - Provide a tiny shared script or hook for hash handling and focus management.
This pattern aligns with how we build everything else: simple, resilient, and maintainable. See the same philosophy in our accessible contact form tutorial and responsive image grid.
Troubleshooting: Quick Fixes for Common Issues
- Heading hidden under header: Add
scroll-margin-top
to the target orscroll-padding-top
to the scroller. - Screen reader silence: Ensure the destination has
tabindex="-1"
and is focused after scroll. - Motion sensitivity: Respect
prefers-reduced-motion
and avoid long durations. See NN/g’s guidance on animation and usability. - SPA reloads on hash: Prevent default on same-page links and update history with
pushState
. - ID encoding issues: Decode the fragment and avoid spaces in IDs; prefer
kebab-case
.
QA Checklist
- Anchors animate smoothly when motion is allowed; they jump instantly when reduced motion is on.
- Targets land fully visible below the sticky header on mobile and desktop.
- Focus moves to the destination and the section name is announced by assistive tech.
- Deep links (
/page#section
) and browser back/forward work reliably. - TOC highlights the section in view (if you enabled it) without scroll listeners.
- No layout shifts around anchor targets; media is pre-sized.
For a broader mobile-first lens on keeping interactions snappy, skim best practices for mobile performance and understanding Core Web Vitals.
Copy-Paste Mini Demo (Production-Ready)
This is the minimal setup we ship on marketing pages. One token, one utility class, one small script. It plays nicely with static builds and keeps maintenance simple.
/* 1) Header token + target spacing */
:root { --header-h: 72px; }
.anchor-target { scroll-margin-top: calc(var(--header-h) + 12px); }
/* 2) Motion-safe smooth scroll */
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
/* 3) Markup */
<h2 id="benefits" class="anchor-target" tabindex="-1">Benefits</h2>
/* 4) JS enhancer */
// Use the script from Step 4 above.
Want a deeper tour of why we structure projects this way? Start with file structure for speed and scale.
FAQs
Is CSS alone enough for smooth scrolling?
Yes for the animation portion, but you still want a tiny script to move focus and handle deep links so assistive technologies can announce the destination.
What if my header height changes on scroll?
Bind a CSS variable (for example --header-h
) to the header’s current height and update it when the header condenses or expands. Your scroll-margin-top
stays accurate.
Does this work without JavaScript?
Yes — anchors still jump without JS — JavaScript only improves focus management, URL updates, and optional “active section” highlighting.
References
Want This Pattern Dropped Into Your Site?
I build fast, accessible, owner-led sites where patterns like this are the default, not the afterthought. If you want a tidy stack that ranks and is easy to maintain: start your project, skim the portfolio, or dive into Core Web Vitals and progressive enhancement to see how we think.