Anchors hidden behind a sticky header: one scroll-padding-top for the whole site
A sticky header covers every anchor target. scroll-margin per heading always misses one; scroll-padding-top on the scroll container does not, fed by the real header height.
A sticky header swallows anchor targets. You click a section in the table of contents, or open a shared link with a # fragment, and the heading lands exactly under the header.
The obvious fix, and why it leaks
The first instinct is to give every heading a scroll-margin-top:
h2, h3 { scroll-margin-top: 72px; }
It works. It also survives review. Then someone adds a footnote, or an id on a small label, or an anchor that only appears after a details opens. The offset is a constant and the set of targets keeps growing, so one miss brings the bug back.
The right place is the scroll container
scroll-padding-top lives on the scrolling container and covers every descendant anchor at once:
html {
scroll-padding-top: var(--anchor-offset);
}
The default value of --anchor-offset is the header height plus one spacing step. That is enough on desktop. It is not enough on a phone.
The half a constant cannot know
On narrow screens the navigation wraps, the header goes from one line to two, and its real height is one line taller than --header-height. CSS has no way to know that, so the hard-coded token comes up short and the heading slides back under the header.
For values CSS cannot compute but the browser can measure, this site uses one small browser-side script (src/lib/anchor.ts): a ResizeObserver measures the header with getBoundingClientRect().height, adds one spacing step, and writes it back to --anchor-offset. It re-measures whenever the size changes: webfonts landing, a window resize, the navigation wrapping.
Why not just scroll with JavaScript
You can compute the target position yourself with window.scrollTo. Then you also own three more problems: smoothing the animation, layout shifts from images loading mid-scroll, and keyboard Tab navigation that never touches your click handler. Browsers already get those right. Reimplementing them only adds three new edge cases.
Layout knowledge stays in layout. JavaScript only supplies the one number CSS cannot measure.
The price is a script that cannot be inlined. When ResizeObserver is unavailable it falls back to the default token silently: anchors still work, the compensation just returns to a constant.

Comments
…