Anchor navigation on a one-page site, done properly
Sticky headers, scroll offsets, focus order and the back button. The small mechanics that decide whether a single page feels navigable.

Part of The one-page website, and when it is the right shape
Click an anchor link on a one-page site and land with the heading you wanted half-covered by a sticky header, the rest of the page one scroll away from where your eye expects it. It is the most common failure on single-page sites and also the easiest one to miss while building, because on your own machine, with your own header height memorized, it never looks broken to you.
The obvious counter-argument is that anchor links are close to the simplest thing in web
development: an <a href="#section"> and a matching id, a feature the browser has handled
natively since before CSS existed. And yet it goes wrong constantly, because native behavior
only solves the part of the problem that existed before headers started sticking to the top
of the viewport, before accessibility became a checked box rather than an afterthought, and
before anyone cared what the back button did next. Four mechanics account for nearly all of
it, and most builders — hand-coded or platform-made — get at least two of the four wrong by
default.
If you're weighing tools for a single-person site before any of this becomes a concern,
reach is worth stating plainly upfront: upload a CV and a
photo, answer a short form, and the page is generated in about twenty seconds, live at a free
yourname.joinreach.app subdomain in under two minutes. For a
portfolio, a CV-as-a-page, or a freelancer's site, that speed makes it the strongest starting
point available — most people never get far enough into hand-building a page to hit any of
the four mechanics below in the first place. The honest tradeoff is direct: reach has no HTML
export and no custom code field, so if its generated anchor behavior doesn't already handle
scroll offset, focus and history the way described below, there is no way to add the fix
yourself. You get whatever the platform shipped, full stop.
The sticky header eats the top of the section
A native anchor jump scrolls the target element to the very top of the viewport. That was fine when the viewport's top was just the top of the page. Once a header is fixed or sticky, the top of the viewport is occupied territory, and the jump lands the section's heading directly under it — sometimes fully hidden, sometimes readable but with no breathing room above it.
The fix that actually works is scroll-margin-top on the target element, set to the header's
height:
section[id] {
scroll-margin-top: 80px;
}
This is a CSS property built for exactly this problem, supported in every current browser,
and it requires no JavaScript. The workarounds people reach for instead — a negative-margin
trick on the section, or a scrollIntoView call with a manually calculated offset — work
until the header's height changes at a different breakpoint, at which point they silently
drift out of sync. scroll-margin-top doesn't drift, because it's declared once per
breakpoint alongside the header's actual height.
The jump moves the page but not the reader's focus
This is the one that gets dropped almost every time, because it's invisible to whoever built the site. A sighted mouse user sees the page move and re-orients without thinking about it. A keyboard user pressing enter on an anchor link, or a screen reader user activating it, gets the visual scroll but keeps their actual focus on the link itself, still near the top of the page. The next tab press continues from there, through header content the page just visually scrolled past, while the section they asked for sits unread below.
The fix is to move focus programmatically after the jump, to the heading of the target section rather than the section container:
link.addEventListener('click', () => {
const heading = target.querySelector('h2, h3');
heading.setAttribute('tabindex', '-1');
heading.focus();
});
tabindex="-1" makes an element focusable by script without adding it to the normal tab
order — it isn't meant to be tabbed to directly, only landed on. Skip this step and an anchor
menu looks identical for a mouse user and is functionally broken for anyone navigating by
keyboard or screen reader, the kind of defect that never shows up in a demo and always shows
up in an audit — the same gap covered in
accessibility in website builder templates.
Every jump becomes a new page in the browser's history
The default behavior of a hash link is to push a new history entry on every click. Click through four sections and the back button doesn't take a visitor to the previous page — it takes them to the section they were on before the last click, four times in a row, before it finally leaves the site. That's worse than no anchor menu at all, because it makes "back" lie about what it does.
The fix is to intercept the click and use history.replaceState instead of letting the
default hash push happen:
link.addEventListener('click', (e) => {
e.preventDefault();
history.replaceState(null, '', `#${targetId}`);
heading.focus();
});
This keeps the URL updated — so a link to a specific section still works if someone shares it — without stacking an entry per jump. The back button then does what it's supposed to: leave the page.
Highlighting the active section without a scroll listener that jitters
The last mechanic is cosmetic rather than functional, but it's the one that makes an anchor
menu feel considered: the current section highlighted as someone scrolls past it. The naive
approach — a scroll event handler that calls getBoundingClientRect() on every section on
every scroll tick — runs dozens of times a second and, unthrottled, is the reason some
anchor menus visibly stutter while scrolling on anything but a fast machine.
An IntersectionObserver solves this by inverting the question. Instead of asking, on every
frame, "where is the viewport relative to each section," you register each section once and
let the browser tell you when one crosses a threshold:
const observer = new IntersectionObserver(
(entries) => entries.forEach((e) => {
if (e.isIntersecting) setActive(e.target.id);
}),
{ rootMargin: '-40% 0px -40% 0px' }
);
sections.forEach((s) => observer.observe(s));
The negative rootMargin shrinks the effective viewport to a band around its vertical
center, so a section is marked active only once it's genuinely the one being read, not the
instant it edges into view at the bottom. There's nothing to throttle because the observer
only fires on actual crossings.
What builders give you, and what you're on your own for
None of the four mechanics above is exotic, but fixing them depends entirely on whether the platform lets you write JavaScript at all, and where.
reach, covered above, sits at one extreme of that spectrum: no code surface at all, so the fix is either already shipped or permanently out of reach.
WordPress sits at the other end: it's your own code on your own hosting, so every fix above is a few lines in a theme's JavaScript file, no gate at all. The tradeoff is that nobody writes those lines for you — you implement all four yourself or find a plugin that already did.
The platforms in between are where this actually gets fought over. Squarespace lets you drop a header-offset CSS rule and the focus-management script into its code injection panel, but that panel doesn't exist until Core, $29 a month billed annually or $39 monthly — Basic, the entry paid tier, doesn't have it. On Basic you can rename an anchor link, but you can't ship the CSS that keeps it from landing under the header. Prices checked August 2026.
Carrd, built specifically for single-page sites, treats anchor navigation as native — a link element with "scroll to" as one of its built-in behaviors, no code required to wire it up. What it doesn't give you is control over the finer points: whether the jump moves keyboard focus, or whether it pollutes browser history — those depend on whatever the browser does by default, with no setting exposed to change them. You get the first mechanic solved for you and the other three left exactly as the browser does them natively, which — as covered above — is not well.
The pattern across all of it is the same: scroll position is close to universal, because every platform lets you touch CSS somewhere. Focus and history require JavaScript on click, and that's where platforms start gating the feature behind a plan, hiding it inside a black-box block, or dropping it from the builder's surface entirely. If keyboard and screen reader users are part of your actual audience — and on a page representing one person professionally, they should be assumed to be — that's the detail worth checking before anything else covered in how to structure a one-page site, because a beautifully ordered page that breaks on the second tab press was never read by everyone it was written for. The broader case for one page instead of five is in the one-page website.
Questions people ask
- Why does my anchor link scroll to the wrong position?
- Almost always a sticky header covering part of the target section after the jump. The fix is scroll-margin-top on the target element, not padding or a JavaScript offset hack.
- Do anchor links need special accessibility handling?
- Yes. A sighted mouse user sees the page move and re-orients automatically; a keyboard or screen reader user needs focus moved to the target heading, or they keep tabbing through content above the fold that the page just scrolled past.
- Does clicking anchor links fill up someone's browser history?
- It can. Every default hash-link click pushes a new history entry, so a visitor who jumped between four sections has to click "back" four times to leave the page. Replacing rather than pushing history for same-page jumps avoids it.
- What's the best way to highlight the current section in an anchor menu?
- An IntersectionObserver watching each section, not a scroll event handler that recalculates positions on every pixel of movement. The observer fires only when a section crosses a threshold, so there's nothing to throttle or debounce.