You paste a link to a section in your internal wiki, and your coworker lands… somewhere nearby.
Or the heading is hidden behind a sticky header. Or the anchor changes on every deploy because somebody “improved” slug generation.
Now you’re debugging links instead of systems.
Doc-style anchor links look trivial—until you run them at scale across years of content, multiple renderers, dark mode, and a design system that loves sticky headers.
This is one of those “small UI features” that becomes an uptime problem when your runbooks can’t be deep-linked during an incident.
What good anchor links feel like (and why SREs should care)
A good docs site makes section links feel inevitable. Hover a heading, a little link icon appears, you click it, the URL updates,
and you can paste it into chat. When you open it, the page scrolls so the heading sits neatly below the sticky header.
No weird jump, no hidden title, no “why is the browser at the wrong spot?”
That’s not polish for polish’s sake. It’s operational capability. Runbooks and postmortems are only as good as their deep links.
During a messy incident, you do not want to say “scroll down to the third ‘Mitigation’ heading.” You want a link that lands
exactly on the right paragraph.
Also: anchors are a contract. Once people share them, they’re basically APIs. Break them and you’ll find out—usually when
an executive is watching a live incident call and someone says, “The link in the runbook is dead.”
Joke #1: Anchor links are like on-call rotations—everyone ignores them until they fail, then suddenly it’s the only thing anyone talks about.
Non-negotiables for “docs-grade” anchors
- Stable IDs: headings should keep the same
idacross rebuilds and minor copy edits. - Offset-aware scrolling: sticky headers must not cover the target heading.
- Hover affordance: permalink icon appears on hover/focus, not always cluttering the page.
- Clickable heading or adjacent control: users can copy a section link without precision clicking.
- Accessible behavior: keyboard focus, screen-reader naming, reduced motion handling.
- Works without JavaScript: baseline anchor navigation should still function.
Facts and history: why anchors work the way they do
Anchors feel modern, but the core mechanism is old and stubborn. That’s good: boring primitives are reliable.
A few concrete facts and context points that explain today’s constraints:
- Fragment identifiers predate modern CSS: the
#fragmentportion of a URL has been used since early web standards to target in-page locations. - Fragments are client-side: the fragment is not sent to the server in HTTP requests, which is why server logs won’t show it unless you instrument the client.
- Early HTML used named anchors: historically you’d write
<a name="foo">; modern HTML usesid="foo"on any element. - Duplicate IDs are undefined behavior: browsers pick “the first one” or “whatever the DOM ends up meaning,” which varies and gets worse with hydration.
- CSS gained a real fix for sticky headers:
scroll-margin-topandscroll-padding-topexist largely because sticky headers became the norm. - Docs sites popularized hover permalinks: MediaWiki and later developer portals trained users to expect heading permalinks.
- Unicode complicates slugging: you can put non-ASCII in an
id, but interoperability and copy/paste behavior push many teams toward ASCII slugs. - Browsers have “scroll to text” now: some support text fragments (
#:~:text=), but it’s not a substitute for stable IDs and can be brittle.
Design decisions that make anchors boring—in the best way
Choose your UX: clickable heading vs. explicit permalink button
Two common patterns:
- Clickable heading: the whole heading is a link to itself. It’s fast and discoverable. It can, however, annoy users who just wanted to select text.
- Permalink button next to heading: the heading remains normal text; a link icon appears on hover/focus. This is the docs-site classic. It’s my default recommendation.
In production docs, I prefer the explicit permalink control because it separates “navigate/copy link” from “select text.”
You’ll get fewer accidental clicks while highlighting headings.
Offset strategy: CSS first, JavaScript last
Sticky headers create the most visible anchor bug: the browser scrolls, but the target is hidden under the header.
You can fix it with JavaScript scroll adjustments. You can also fix it in CSS and keep the browser’s native behavior.
Use CSS whenever possible:
scroll-margin-topon headings: clean, local, and works for normal in-page navigation.scroll-padding-topon the scroll container: good when you have a layout with a scrolling main area.
JavaScript should only be used when you have complicated scrolling containers, dynamic header heights, or old browser constraints you can’t drop.
Treat heading IDs like schema
IDs are not decoration. They’re stable identifiers referenced by:
- internal links (TOC, cross references)
- external links (chat, tickets, docs in other systems)
- search engine indexes
- automation (linters, link checkers, doc extractors)
If you change an ID generation algorithm, you’re doing a breaking change. Act like it: version it, migrate it, redirect it where feasible, and communicate it.
Implementation: hover icons, offsets, clickable headings
Baseline HTML structure
The best structure is simple: headings have an id. Next to each heading, render a small anchor link
that points to #id. The anchor should be focusable, have a readable label, and be visually subtle until hover/focus.
cr0x@server:~$ cat heading-anchors.html
<article class="doc">
<h2 id="fast-diagnosis">
Fast diagnosis
<a class="permalink" href="#fast-diagnosis" aria-label="Permalink to Fast diagnosis">
<span aria-hidden="true">#</span>
</a>
</h2>
<p>Start with the obvious checks first.</p>
</article>
That “#” can be an SVG link icon in real life. Keep the accessible label, and keep the visible icon aria-hidden.
CSS: hover/focus affordance and offset fix
Do two things in CSS:
- Hide the permalink control until the heading is hovered or focused (but keep it available to keyboard users).
- Apply
scroll-margin-topso the anchor lands below your sticky header.
cr0x@server:~$ cat anchors.css
:root {
--sticky-header-height: 64px;
}
.doc h2, .doc h3, .doc h4 {
scroll-margin-top: calc(var(--sticky-header-height) + 12px);
position: relative;
}
.doc .permalink {
margin-left: 0.5rem;
text-decoration: none;
opacity: 0;
transition: opacity 120ms linear;
}
.doc h2:hover .permalink,
.doc h3:hover .permalink,
.doc h4:hover .permalink,
.doc .permalink:focus {
opacity: 1;
outline: none;
}
.doc .permalink:focus-visible {
opacity: 1;
outline: 2px solid currentColor;
outline-offset: 2px;
}
If your header height changes with breakpoints, set --sticky-header-height per media query.
Don’t “measure it in JS” unless you absolutely have to.
Clickable headings: the careful version
If you insist on making the whole heading clickable, do it without wrapping all heading text in an <a> that steals selection.
One decent compromise is: keep the heading text normal, and add a pseudo-element overlay link with a limited hit area.
Another is: wrap, but add CSS that improves selection and keeps the permalink icon as the explicit control.
My blunt guidance: make the permalink control the primary click target. Let headings be headings.
“Copy link” behavior (and why it matters)
Some sites add a dedicated “copy link” button that writes the full URL to the clipboard. This is nice for users and reduces
“I copied only the fragment” confusion. But it’s not required.
If you implement it, do it progressively: the anchor href should still work without JS.
cr0x@server:~$ cat copy-link.js
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-copy-permalink]');
if (!btn) return;
const id = btn.getAttribute('data-copy-permalink');
const url = new URL(window.location.href);
url.hash = id;
try {
await navigator.clipboard.writeText(url.toString());
btn.setAttribute('data-copied', 'true');
setTimeout(() => btn.removeAttribute('data-copied'), 1200);
} catch {
// Fallback: update location so users can copy from address bar
window.location.hash = id;
}
});
Clipboard APIs have permission quirks in embedded contexts. Your fallback should still give a copyable URL via the address bar.
Stable slugs: the part everybody underestimates
The job: turn heading text into a stable id like fast-diagnosis-playbook.
The trap: headings change, punctuation changes, duplicate headings happen, and different renderers slug differently.
The correct approach depends on your content lifecycle:
- Internal docs with frequent edits: allow explicit IDs in source (Markdown extension) and encourage authors to pin IDs for important sections.
- Public docs with external links: stability matters even more—prefer explicit IDs for major headings and keep slugging algorithm versioned.
A sane slug algorithm (and rules you should write down)
Pick rules and don’t improvise later. Here’s a practical set:
- Normalize Unicode (NFKD) and remove combining marks for ASCII slugs.
- Lowercase.
- Replace non-alphanumeric sequences with single hyphens.
- Trim leading/trailing hyphens.
- Keep a collision counter:
heading,heading-1,heading-2. - Allow an explicit override, e.g.,
{#my-stable-id}in Markdown.
If you change these rules, you break links. That’s not a theory. It will happen.
You can’t reliably redirect fragments on the server
Because fragments aren’t sent to the server, you can’t do server-side redirects like “old fragment → new fragment” in a normal way.
You can do client-side mapping with JavaScript on page load (read location.hash, map it, set location.hash).
It’s clunky but sometimes necessary for migrations.
Don’t build a business on it. Better: keep IDs stable.
Accessibility and UX: do not ship cute, ship usable
Heading permalinks are a classic place to accidentally punish keyboard and screen-reader users.
You’re adding interactive controls next to headings, and headings are already navigation landmarks.
Baseline requirements
- Keyboard: permalink control must be reachable by Tab and show a visible focus indicator.
- Screen readers: link has a meaningful label (e.g., “Permalink to …”). The icon is aria-hidden.
- Hit target: don’t ship a 10px-wide click target. Make it at least comfortable on touch devices.
- Reduced motion: avoid fancy scroll animations by default; respect
prefers-reduced-motion.
Smooth scrolling: be careful with it
Smooth scroll feels nice until it doesn’t. It can make users nauseated, and it makes “where am I” moments worse in long docs.
If you enable it globally, ensure reduced motion disables it.
cr0x@server:~$ cat motion.css
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
Still: native anchor jumps are fast and predictable. I don’t turn on smooth scrolling unless design insists.
Focus management after an anchor jump
When you click a permalink, the URL changes and the page scrolls, but keyboard focus may stay on the clicked link.
That’s fine. What’s not fine is landing at a heading with no visible focus context if you navigated via keyboard or script.
A pragmatic improvement: add tabindex="-1" to headings so they can be focused programmatically, then focus the target on hashchange.
Do this only if you’ve tested it with real assistive tech; don’t “accessibility theater” your way into broken behavior.
SEO and analytics: anchors are not your routing layer
Anchor fragments don’t change the server route, so crawlers and analytics treat them differently:
- Search engines: generally index the page URL; fragments may show as sitelinks in some cases but are not a separate page.
- Analytics: server-side analytics won’t see fragments. Client-side can, but you need to implement it.
- Canonical URLs: don’t set canonicals with fragments; canonicals should point to the base page.
If you care about “most-linked sections,” add client instrumentation on hashchange and on permalink clicks.
Also: don’t spam pageviews for every hash change. Track as an event.
One reliability maxim worth keeping here comes from John Ousterhout’s engineering writing. Paraphrased idea: complexity is the root cause of most software failures
— John Ousterhout (paraphrased idea).
Keep this feature boring.
Practical tasks: commands, outputs, and decisions
This section is deliberately operational. Each task includes a command, what the output means, and what decision you make.
The examples assume a static site output in ./dist and source in ./src. Adapt to your repo.
Task 1: Find duplicate IDs in built HTML
cr0x@server:~$ rg -n ' id="' dist | sed -n 's/.* id="\([^"]\+\)".*/\1/p' | sort | uniq -d | head
getting-started
troubleshooting
Output meaning: at least two pages (or one page) contain the same id values. Within a single page, duplicates are a correctness bug.
Decision: if duplicates occur within the same HTML file, fix slug collision handling. If duplicates occur across pages, that’s fine unless you embed pages together (SPA) or have client-side routing merging DOMs.
Task 2: Check for duplicate IDs within each file
cr0x@server:~$ for f in dist/**/*.html; do
> ids=$(perl -nE 'say $1 while / id="([^"]+)"/g' "$f" | sort)
> dups=$(printf "%s\n" "$ids" | uniq -d)
> if [ -n "$dups" ]; then
> echo "DUP IDs in $f"
> echo "$dups" | head
> fi
> done
DUP IDs in dist/runbook.html
mitigation
Output meaning: dist/runbook.html has at least two elements with id="mitigation".
Decision: update slug generator to suffix collisions, or require explicit IDs for repeated headings like “Mitigation” and “Rollback.”
Task 3: Audit sticky header height in computed CSS
cr0x@server:~$ rg -n 'position:\s*sticky|position:\s*fixed' src/styles -S
src/styles/header.css:14:position: sticky;
src/styles/header.css:15:top: 0;
Output meaning: you have a sticky header. It probably occludes anchor targets without offset handling.
Decision: set scroll-margin-top on headings or scroll-padding-top on the scroll container using the header height.
Task 4: Confirm you’re applying scroll offsets somewhere
cr0x@server:~$ rg -n 'scroll-margin-top|scroll-padding-top' src -S
src/styles/anchors.css:6: scroll-margin-top: calc(var(--sticky-header-height) + 12px);
Output meaning: offsets are implemented in CSS.
Decision: validate the variable value across breakpoints; if you have multiple headers (banner + nav), sum them.
Task 5: List in-page hash links and verify targets exist
cr0x@server:~$ python3 - <<'PY'
import glob, re, sys
from collections import defaultdict
href_re = re.compile(r'href="#([^"]+)"')
id_re = re.compile(r' id="([^"]+)"')
for f in glob.glob("dist/**/*.html", recursive=True):
html = open(f, "r", encoding="utf-8").read()
hrefs = set(href_re.findall(html))
ids = set(id_re.findall(html))
missing = sorted(hrefs - ids)
if missing:
print(f"{f}: missing targets: {missing[:5]}")
PY
dist/index.html: missing targets: ['fast-diagnosis-playbook']
Output meaning: the page links to #fast-diagnosis-playbook but no element has that ID (TOC mismatch, slug changed, or content missing).
Decision: fix the renderer so TOC generation and heading ID generation share the same slug source of truth.
Task 6: Detect heading ID churn between builds
cr0x@server:~$ git diff --name-only HEAD~1..HEAD | rg '\.md$' | head
src/docs/runbook.md
src/docs/storage.md
cr0x@server:~$ python3 - <<'PY'
import re, sys, pathlib
p = pathlib.Path("dist/runbook.html")
html = p.read_text(encoding="utf-8")
ids = re.findall(r'<h[2-4][^>]* id="([^"]+)"', html)
print("\n".join(ids[:20]))
PY
fast-diagnosis
common-mistakes
checklists
Output meaning: you can snapshot IDs per page and compare across releases. This example prints the first 20 heading IDs.
Decision: add a CI job that fails if IDs change for unchanged headings (you’ll need source mapping), or at least alerts on large churn.
Task 7: Verify permalink controls have accessible labels
cr0x@server:~$ rg -n 'class="permalink"' dist | head -n 3
dist/runbook.html:42: <a class="permalink" href="#fast-diagnosis">
dist/runbook.html:88: <a class="permalink" href="#common-mistakes" aria-label="Permalink to Common mistakes">
dist/runbook.html:132: <a class="permalink" href="#checklists" aria-label="Permalink to Checklists / step-by-step plan">
Output meaning: at least one permalink lacks an aria-label. That link will be announced as “link” or “#” with no context.
Decision: enforce an aria-label template in your renderer. Don’t rely on tooltips; screen readers don’t care.
Task 8: Ensure focus-visible styling exists
cr0x@server:~$ rg -n ':focus-visible' src/styles -S
src/styles/anchors.css:22:.doc .permalink:focus-visible {
Output meaning: you’re at least thinking about keyboard users.
Decision: if missing, add it. If present, verify contrast in dark mode. Failing that, you’ll get “keyboard trap” complaints in enterprise environments.
Task 9: Identify whether you’re scrolling the page or a nested container
cr0x@server:~$ rg -n 'overflow:\s*(auto|scroll)' src/styles -S | head
src/styles/layout.css:31:overflow: auto;
Output meaning: you likely have a nested scroll container (common in app-like docs shells).
Decision: use scroll-padding-top on that container instead of (or in addition to) scroll-margin-top on headings.
If anchor jumps don’t land correctly, nested scrolling is often why.
Task 10: Audit JavaScript that “helps” anchor scrolling
cr0x@server:~$ rg -n 'location\.hash|hashchange|scrollIntoView' src -S
src/app/router.js:118:window.addEventListener('hashchange', onHashChange);
src/app/router.js:141:document.querySelector(hash).scrollIntoView({ behavior: 'smooth' });
Output meaning: custom code intercepts hash navigation and scrolls manually.
Decision: confirm it accounts for sticky headers and nested scroll containers. If not, remove it and rely on CSS offsets.
Manual scrolling code is a frequent source of double-scroll, wrong offset, and reduced-motion violations.
Task 11: Validate your collision strategy in the generator
cr0x@server:~$ rg -n 'slug|permalink|heading.*id' src -S | head
src/build/slugify.js:3:function slugify(text) {
src/build/markdown.js:88: heading.id = slugify(heading.text);
cr0x@server:~$ sed -n '1,140p' src/build/slugify.js
function slugify(text) {
return text.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
module.exports = { slugify };
Output meaning: this slugify has no collision handling. Two identical headings will produce identical IDs.
Decision: implement collision suffixing per page and add tests. Also consider Unicode normalization if you have non-ASCII headings.
Task 12: Confirm permalink icons appear on hover and on keyboard focus
cr0x@server:~$ rg -n 'opacity:\s*0|opacity:\s*1|h2:hover.*permalink|permalink:focus' src/styles/anchors.css
12: opacity: 0;
18:.doc h2:hover .permalink,
21:.doc .permalink:focus {
Output meaning: the icon is hidden by default and becomes visible on hover and focus. That’s the correct pattern.
Decision: verify that the icon is also visible on touch devices (no hover). A simple fix is always-visible on small screens via media query.
Task 13: Ensure TOC entries match heading IDs
cr0x@server:~$ python3 - <<'PY'
import re, pathlib
html = pathlib.Path("dist/runbook.html").read_text(encoding="utf-8")
toc = re.findall(r'<nav[^>]*aria-label="Table of contents"[\s\S]*?</nav>', html)
if not toc:
print("No TOC nav found")
raise SystemExit(0)
toc_html = toc[0]
toc_hrefs = set(re.findall(r'href="#([^"]+)"', toc_html))
heading_ids = set(re.findall(r'<h[2-4][^>]* id="([^"]+)"', html))
missing = sorted(toc_hrefs - heading_ids)
extra = sorted(heading_ids - toc_hrefs)
print("TOC missing targets:", missing[:10])
print("Headings not in TOC:", extra[:10])
PY
TOC missing targets: []
Headings not in TOC: ['appendix-debug-notes']
Output meaning: the TOC matches targets, but one heading is not included (maybe intentional).
Decision: decide whether to include all headings or only certain levels. Make it consistent; inconsistency is what confuses people and breaks expectations.
Task 14: Check whether caching makes people land on old anchors
cr0x@server:~$ curl -I -s https://docs.example.invalid/runbook | sed -n '1,12p'
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=31536000, immutable
etag: "a1b2c3d4"
Output meaning: long-lived immutable caching on HTML is risky if the HTML changes but the URL doesn’t. Users can get stale pages where IDs don’t match current links.
Decision: cache HTML lightly (or with revalidation), and cache assets (JS/CSS) aggressively with fingerprinted filenames. If you must cache HTML hard, version the path.
Fast diagnosis playbook
When anchors “don’t work,” people describe it poorly: “The link is broken.” That can mean ten different failure modes.
Here’s the triage order that finds the bottleneck fast.
First: does the target ID exist in the DOM?
- If you control the page: view source (or inspect element) and search for the ID.
- If it’s built content: run a grep/riggrep check on the HTML output (see Task 5).
If the ID doesn’t exist, stop. It’s not a scroll bug. It’s generation mismatch, slug churn, or collision.
Second: is the ID duplicated?
Duplicate IDs can land you on the wrong section. It looks like “anchors are flaky” because sometimes you land in the first instance,
sometimes the DOM order changes with hydration.
Check per-file duplicates (Task 2). Fix collisions in the generator.
Third: is the scroll container what you think it is?
If your main content scrolls inside a container, native anchor jumps may scroll the page, not the container,
or they may jump but appear “wrong” because the container’s top padding isn’t set.
Search for overflow: auto or scroll (Task 9). Apply scroll-padding-top to the actual scroll container.
Fourth: sticky header offset (CSS fix first)
If the anchor exists and is unique, but the heading is hidden under the header, it’s an offset issue.
Use scroll-margin-top on headings. Avoid JS hacks unless you have dynamic height.
Fifth: JavaScript is intercepting hash navigation
Router code, smooth scroll code, or analytics code can block native anchor behavior.
Find hashchange, preventDefault, and scrollIntoView usage (Task 10).
Delete most of it. Seriously. Native anchor navigation has decades of hardening. Your 40 lines of “help” do not.
Common mistakes (symptom → root cause → fix)
Symptom: link lands on the right section, but the heading is hidden
Root cause: sticky header overlays the content and you didn’t account for it.
Fix: apply scroll-margin-top to headings or scroll-padding-top to the scroll container. Keep the offset in CSS variables per breakpoint.
Symptom: link lands on the wrong section with the same name
Root cause: duplicate IDs from repeated headings (e.g., multiple “Summary” headings) without collision handling.
Fix: implement per-page collision suffixing in slug generation; encourage explicit IDs for repeated operational headings.
Symptom: anchors work locally but fail in production
Root cause: HTML cached too aggressively or a different renderer pipeline in production generates different slugs.
Fix: align slugging between environments; cache HTML with revalidation; fingerprint assets; add a build-time “ID stability” check.
Symptom: anchors work on full reload, but not when navigating within the app shell
Root cause: client-side router prevents default hash behavior, or content is injected after navigation so the element doesn’t exist yet.
Fix: on navigation completion, if location.hash exists, scroll to the target after render. Prefer scrollIntoView on the target and include offset handling via CSS.
Symptom: TOC links don’t match headings
Root cause: TOC is generated from raw heading text while IDs are generated from processed text (or vice versa), or the pipeline uses two slug functions.
Fix: one slug source of truth. Export it as a shared module and test it with golden cases.
Symptom: hover icon appears, but keyboard users can’t find it
Root cause: the icon is hidden via display: none or only shown on hover, not focus.
Fix: hide with opacity/visibility, and show on :focus / :focus-visible. Ensure Tab reaches the control.
Symptom: copying a section link sometimes gives a full URL, sometimes only #fragment
Root cause: users are copying from different UI surfaces (address bar vs. right-click link vs. selection), and your UI doesn’t guide them.
Fix: optional “Copy link” control that always copies full URL; otherwise keep the permalink as a normal anchor so right-click copy works.
Symptom: the page looks like a chain-link icon museum
Root cause: permalink icons always visible, including for small headings and dense API references.
Fix: show on hover/focus, and selectively enable for heading levels (typically H2–H4). On mobile, consider always-visible but subtle.
Joke #2: If you think duplicate IDs “probably won’t happen,” congratulations—you’ve just created the most reusable bug in the company.
Checklists / step-by-step plan
Checklist: ship docs-grade anchors in a week
- Pick the pattern: permalink button next to headings (recommended) or clickable headings. Decide now.
- Define slug rules: write them down in the repo. Include collision handling and Unicode behavior.
- Implement a single slug function: used by heading IDs, TOC, and any cross-reference generator.
- Enable explicit IDs: allow authors to pin IDs for critical sections (runbooks, SOPs, legal docs).
- CSS offset: apply
scroll-margin-topand set--sticky-header-heightper breakpoint. - Hover/focus affordance: show permalink on hover and focus-visible.
- Accessibility pass: aria-labels, hit targets, focus style, reduced motion.
- CI validation: fail builds on duplicate IDs per page and TOC-target mismatches.
- Cache policy check: avoid long-lived immutable caching on HTML unless versioned paths are used.
- Migration plan: if you’re changing slug logic, decide how you’ll preserve old IDs or map them client-side.
Checklist: CI gates that actually catch anchor regressions
- Duplicate IDs per HTML file (hard fail).
- TOC href targets exist (hard fail).
- Permalink controls have
aria-label(hard fail). - Optional: detect large ID churn vs. previous release (soft fail / alert).
- Optional: ensure no headings without IDs for certain doc types (runbooks, handbooks).
Three corporate mini-stories (anonymized, technically accurate)
Mini-story 1: the incident caused by a wrong assumption
A mid-sized company had an internal “Production Runbook” site, rendered from Markdown into a single-page app shell.
They had permalinks on headings and a TOC. Everyone trusted it. It had survived multiple org reorganizations, which is basically immortality.
Then they redesigned the top navigation: a sticky header grew from one row to two, plus an incident banner that appeared during major events.
The front-end change shipped Friday afternoon because the CSS diff looked harmless and nobody wanted to fight the release train.
The wrong assumption: “anchor links will still land correctly; the browser handles that.”
Monday brought an incident. Someone pasted a link to the “Disable autoscaling” section. The page loaded, scrolled, and… the heading was hidden.
In a calm moment this is a mild annoyance. During a live incident it became a coordination bug:
three people thought they were looking at the same instructions, but two were actually reading the previous section.
The proximate issue wasn’t the sticky header existing. It was the offset being hard-coded for the old header height.
Worse, the incident banner only appeared in production, so local testing never saw it.
The fix was boring and effective: scroll-margin-top on headings with a CSS variable set by the header component,
with an additive variable for the banner when present. No JS scroll math, no layout thrash.
Mini-story 2: the optimization that backfired
Another organization tried to “optimize” docs rendering. They moved slug generation to a shared package used across products.
Good goal. Then they “improved” the algorithm: previously it preserved hyphens and collapsed whitespace; now it normalized more punctuation,
removed stop words, and trimmed to a maximum length “for cleanliness.”
The change reduced ugly long IDs. It also destroyed permalink stability.
Headings like “How to roll back: API gateway” and “How to roll back – API gateway” now collided into the same ID.
Collision suffixing wasn’t implemented because the library was “pure” and didn’t track per-page state.
The first signal wasn’t a bug report from docs readers. It was on-call engineers complaining that links in old incident tickets no longer worked.
That’s the fun part about deep links: they’re used by people who are busy and irritated, which is exactly who you should not annoy.
Rolling back the slug algorithm was harder than expected. Pages had already been shared externally with partners.
They ended up implementing a client-side fragment mapping for the most common old IDs and reintroducing the previous slug algorithm
with a version flag. Also, they added collision suffixing. The “optimization” cost weeks and a lot of credibility.
Mini-story 3: the boring practice that saved the day
A large enterprise had a content platform that generated multiple outputs: public docs, internal docs, PDFs, and an offline HTML bundle
for restricted environments. That’s a minefield for anchors because each renderer wants to do its own thing.
They did one extremely boring thing: they wrote a “Heading ID Contract” document and treated it like an API.
It defined slug rules, collision behavior, and when explicit IDs were mandatory. It also included test vectors:
strings with punctuation, Unicode, repeated headings, and edge cases like “C++” and “S3 / IAM.”
Then they enforced it in CI across all outputs. Not “best effort.” Hard fail.
Every renderer had to use the same slug function, and every build ran a duplicate-ID scan plus TOC target verification.
Months later they migrated the site shell, including a new sticky header and a new markdown pipeline.
The migration had the usual chaos—except permalinks stayed stable. Old tickets and runbooks kept working.
That’s what “boring but correct” looks like: nobody congratulated them, and production didn’t catch fire.
FAQ
Should I put the id on the heading element or on a child anchor?
Put it on the heading element (e.g., <h2 id="...">) unless your renderer makes that painful.
It’s semantically clean and plays well with scroll-margin-top.
Do I need JavaScript to make hover permalinks work?
No. Hover/focus behavior is CSS. JavaScript is optional for “Copy link” to clipboard and for special cases like SPA navigation timing.
What’s the best way to handle sticky header offsets?
CSS first: scroll-margin-top on headings and/or scroll-padding-top on the scroll container.
JS offset scrolling is a last resort.
Why do some anchors work only after a full page reload in SPAs?
Because the element isn’t in the DOM when the hash navigation occurs, or the router prevents default behavior.
Fix by scrolling after render and ensuring your content container is the scroll target.
How do I ensure heading IDs don’t change when the heading text changes?
Allow explicit IDs in authoring (e.g., Markdown extension) and use them for “stable link” sections.
Otherwise, any slug-from-text system will change when text changes.
Is it okay to use non-ASCII characters in IDs?
Browsers can handle it, but interoperability across tooling (linters, processors, copy/paste contexts) is better with ASCII.
If you have multilingual content, consider explicit IDs or a robust Unicode normalization strategy.
Can I “redirect” old anchors to new ones?
Not on the server in the usual way, because fragments aren’t sent to the server.
You can do client-side mapping on load by reading location.hash and rewriting it, but it’s a migration hack, not a foundation.
Should the permalink icon be visible all the time?
Usually no: it adds noise. Show on hover and focus-visible. On touch devices, consider always-visible but subtle, or show on tap via a larger hit target.
How do I test this without a full browser automation suite?
Start with build-time HTML checks: duplicate IDs, missing targets, aria-label presence. Then do a manual pass: keyboard tabbing, sticky header landing, and mobile tap targets.
What’s the one thing teams forget about anchors?
That IDs become external dependencies. People paste them into tickets, chat, postmortems, and automation. Changing them casually is like renaming a public API method.
Conclusion: next steps you can do this week
If you want anchors that feel like a real docs site, stop treating them as decoration.
They are navigation, collaboration tooling, and incident response infrastructure—just wearing a UI hat.
Do these next:
- Implement CSS offsets (
scroll-margin-top) tied to your sticky header height variable. - Standardize slugging with collision handling and one shared implementation.
- Add CI checks for duplicate IDs and TOC-target mismatches.
- Make permalinks accessible: labels, focus-visible, and reasonable hit targets.
- Decide on stability policy: explicit IDs for runbooks and “forever” docs.
You’ll ship something that looks like a docs site. More importantly, you’ll ship something that behaves like one under stress.