Anchor Links Behind Sticky Headers

6 March 2023 · Updated 9 August 2026

cssweb-developmentanchor-linkssticky-headersscroll-paddingaccessibilityreduced-motion

A sticky header covers the heading you just jumped to. That part is one line of CSS. Getting keyboard focus to follow, and not trampling someone’s reduced motion preference on the way, take a little more.

The offset

html {
  scroll-padding-top: 5rem;
}

scroll-padding-top goes on the scroll container, usually html, and reserves space at the top so anchor targets land below the header. It’s been baseline across browsers since April 2021.

If you want it per element instead of site wide, scroll-margin-top goes on the target:

h2[id],
h3[id] {
  scroll-margin-top: 5rem;
}

Same result, different owner. Reach for scroll-padding-top when one offset covers the page, scroll-margin-top when a particular section needs its own.

If the header height changes between breakpoints, keep it in a custom property so the offset can’t drift out of sync with the header:

:root {
  --header-height: 4rem;
}

@media (min-width: 48rem) {
  :root {
    --header-height: 5.5rem;
  }
}

html {
  scroll-padding-top: var(--header-height);
}

Focus doesn’t follow

scroll-padding-top fixes where the page scrolls to. It doesn’t touch where focus goes.

Activating an in-page link is supposed to move keyboard focus to the target. WCAG’s G1 technique is explicit about it: after activating the link, the keyboard focus has moved to the content. A plain heading isn’t a focusable element though, so it never takes focus, and the next Tab press carries on from wherever the user already was. They’ve been moved down the page visually while their keyboard position hasn’t changed at all.

Make the target programmatically focusable:

<h2 id="focus-doesnt-follow" tabindex="-1">Focus doesn't follow</h2>

tabindex="-1" keeps the heading out of the tab order while letting it receive focus. If your headings come from Markdown, add it in the rehype or remark step rather than by hand.

Leave the focus indicator alone once you’ve done this. It’s tempting to write [tabindex="-1"]:focus { outline: none } because the ring looks unexpected on a heading, but that ring is the only thing telling a keyboard user they arrived.

Smooth scrolling needs a guard

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

WCAG 2.3.3 treats the scrolling itself as essential, because the user is the one driving it. What it asks is that any non-essential animation you add to a scrolling interaction can be turned off, and it names prefers-reduced-motion as the way to do that. Smooth scroll on an anchor click is exactly that kind of addition, so an unguarded scroll-behavior: smooth ignores a preference the user has already expressed at the OS level.

This site shipped that bug in November 2024, and the reduced motion checks in the canvas components predate it by three days. The awareness was already there. The one CSS rule just went in without it.

The old way, and why it was replaced

Before scroll-padding-top existed, the fix was a pseudo-element on :target:

:target::before {
  content: "";
  display: block;
  height: 5rem;      /* header height */
  margin: -5rem 0 0; /* cancel the space it would take */
}

An invisible block pushes the target down the page, and the negative margin stops it consuming any layout space. It does work, and it’s worth recognising if you meet it in an older codebase.

It has real limits. It only fires on :target, so nothing happens when the scroll comes from scrollIntoView(), from the browser restoring a position on back or forward, or from find in page. It leaves a phantom box in the layout that can interfere with backgrounds and borders. It also needs the header height written in two places that have to stay in step.

scroll-padding-top has none of that and is one property.