Create a Smooth Scrolling Anchor for Better Navigation
A lightweight, accessibility-first pattern that makes in-page navigation feel polished—CSS one-liner, offset-safe headings, motion preferences, and a tiny JS enhancer.
Why Smooth Scrolling Improves UX (When Done Right)
Smooth scrolling creates continuity between a click and the destination section. Done poorly, it causes nausea, broken focus, or anchors that hide under fixed headers. Done right, it respects motion preferences, preserves focus for assistive tech, and lands content just below sticky nav.
- Clarity: Users see where they’re going; fewer “where did I land?” moments.
- Speed: No dependencies—pure CSS + tiny JS (< 1 KB).
- Accessibility: Honors
prefers-reduced-motion
, manages focus, and avoids hidden headings.
Step 1 — Enable Smooth Scrolling in CSS (with Motion Safety)
/* Global smooth scroll */
html { scroll-behavior: smooth; }
/* Respect users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
The media query is non-negotiable—never force animation on users who opt out of motion.
Step 2 — Prevent Hidden Headings Under Sticky Nav
Reserve anchor landing space with scroll-margin-top
on all jump targets. Set it to your sticky header height + a little breathing room.
:is(h2, h3, h4, [data-anchor]) { scroll-margin-top: 6.5rem; } /* ≈ 104px */
Using :is()
keeps the selector short. Prefer a token (e.g., --header-h
) if your header is themable.
Step 3 — Semantic Link + Focusable Target Pattern
Make anchors obvious in markup and friendly for keyboard & screen readers.
<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" tabindex="-1">...</section>
<section id="setup" tabindex="-1">...</section>
<section id="faq" tabindex="-1">...</section>
tabindex="-1"
lets you programmatically focus the section after scrolling, so screen readers announce it. Don’t use positive tabindex.
Step 4 — Tiny JS Enhancer (Hash Links, Focus, Deep Links)
Intercept same-page hash links, scroll into view (if motion allowed), then move focus to the target. Also support page loads with a hash.
<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;
// Scroll (respect motion preference)
el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
// After scroll settles, shift focus for SRs
setTimeout(() => { el.focus({ preventScroll: true }); }, reduceMotion ? 0 : 250);
}
// Click handler for same-page hash links
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')); // update URL without reload
scrollToAndFocus(el);
});
// Deep link on load
if (location.hash) {
const el = getTarget(location.hash);
if (el) scrollToAndFocus(el);
}
// Handle back/forward navigation with hashes
window.addEventListener('hashchange', () => {
const el = getTarget(location.hash);
if (el) scrollToAndFocus(el);
});
})();
</script>
We use a small delay (≈250ms) before focusing to let smooth scrolling complete. When motion is reduced, focus immediately.
Optional — Auto-Highlight the Current Section in the TOC
An IntersectionObserver
can toggle an “active” class on nav links as the reader scrolls, improving orientation on long pages.
<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 allows users to bypass repetitive navigation and jump to main content instantly.
<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>
Common Pitfalls (and How to Fix Them)
- Heading hidden under header: Set
scroll-margin-top
on targets (Step 2). - Screen reader doesn’t announce target: Ensure targets are focusable (
tabindex="-1"
) and focused after scroll. - Motion sickness reports: Respect
prefers-reduced-motion
and shorten durations. - Anchor links reload the page in SPA setups: Prevent default and use client-side router or the enhancer above.
QA Checklist
- ✅ Smooth scrolls when motion is allowed; jumps when reduced motion is on.
- ✅ Targets land fully visible below sticky header.
- ✅ Focus moves to the destination, and the section name is announced.
- ✅ Deep links (
/page#section
) and back/forward navigation work. - ✅ Current section link highlights as you scroll (if enabled).
FAQs
Is CSS alone enough for smooth scrolling?
Yes, for the animation. You still need a tiny script to manage focus and deep-link behavior accessibly.
What if my header height changes on scroll?
Use a CSS variable updated by JS or apply scroll-margin-top
to a wrapper that adapts via a class toggle.
Does this work without JavaScript?
Yes—anchors still jump. The JS only improves focus management and URL handling.
Key Takeaways
scroll-behavior: smooth;
+prefers-reduced-motion
= safe animation.- Prevent “hidden headings” with
scroll-margin-top
. - Move focus after the scroll so assistive tech announces the destination.
- Optional TOC highlighting and skip links make long pages feel effortless.