anchor-links — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited anchor-links (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Canonical source:examples/ANCHOR_LINKS_ACCESSIBILITY_BEST_PRACTICES.mdinmgifford/ACCESSIBILITY.mdThis skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules when creating or reviewing in-page anchor links, skip links, or heading links. Only load this skill if the project contains in-page navigation or skip links.
Every anchor link must have meaningful text, a reachable target with a visible focus indicator, and must not cause motion-related harm. Use a real <a> element with an href for navigation; use a button only for actions that don't navigate. Prefer native fragment navigation before adding JavaScript.
| Level | Meaning |
|---|---|
| Critical | Anchor navigation blocks access or causes a trap |
| Serious | Skip link broken or permanently hidden; smooth scroll triggers vestibular harm |
| Moderate | Focus indicator obscured by sticky header; link text ambiguous in context |
| Minor | Non-slug IDs; missing aria-current in table of contents |
Use an anchor when activation changes the URL or navigates to a location; use a button when activation performs an action without navigation.
<!-- Navigation to a page fragment -->
<a href="#installation">Installation instructions</a>
<!-- An action -->
<button type="button" id="copy-installation-link">Copy link to Installation</button>Do not use dummy links for actions:
<!-- Do not use these for actions -->
<a href="#">Open settings</a>
<a href="javascript:void(0)">Open settings</a>An empty fragment navigates to the top of the document; a JavaScript URL loses ordinary link semantics (history, copying, opening in a new tab, no-script fallback). Do not add role="link" to a native anchor with href — the role is already provided.
WCAG 2.4.4 requires a link's purpose be determinable from its text alone or text plus programmatically determined context — understanding every link without any context is the stronger Level AAA criterion (2.4.9), still a useful goal.
<!-- Prefer -->
<a href="#installation">Installation instructions</a>
<a href="#wcag-criteria">Relevant WCAG success criteria</a>
<!-- Avoid when clearer wording is available -->
<a href="#section">Click here</a>
<a href="#section">Read more</a>"Read more" is not automatically a Level A failure when programmatically determined context supplies the purpose, but clearer text is easier to scan, translate, and operate by speech. If a design must retain repeated visible text, add hidden context inside the link rather than replacing it:
<a href="#keyboard-testing">
Read more<span class="visually-hidden"> about keyboard testing</span>
</a>Do not use aria-label to replace useful visible text with a different name — the accessible name should contain the visible words (supports speech input and WCAG 2.5.3 Label in Name). For icon-only links, "Link to" is redundant — screen readers already announce the role:
<a href="#installation" aria-label="Installation section">
<svg aria-hidden="true" focusable="false"><!-- anchor icon --></svg>
</a>Links with the same purpose/destination should use consistent names; links with the same text but different destinations need enough context to distinguish them.
The fragment after # must resolve to a unique id within the document; IDs must not contain ASCII whitespace.
<nav aria-label="On this page"><a href="#installation">Installation</a></nav>
<h2 id="installation">Installation</h2>Do not target: an element with hidden/display:none/visibility:hidden; content inside a closed disclosure; an inactive tab panel; an empty marker far from the visible heading; or an element removed during hydration. If a deep link points into collapsed or tabbed content, reveal the container before scrolling or moving focus.
Keep published IDs stable: preserve an existing ID when the destination is conceptually the same; provide an alias/redirect strategy when an ID must change; don't regenerate every ID just because heading text/capitalization changed. Generated slug systems must resolve duplicate headings deterministically and produce the same ID during server rendering and client hydration.
Must be the first focusable control (or part of the first small set of skip links) and become fully visible when focused. A skip link that is permanently hidden (`display:none`, `visibility:hidden`) is Serious — it defeats WCAG 2.4.1 entirely.
<body>
<a class="skip-link" href="#main-content">Skip to main content</a>
<header><!-- Site identity and repeated navigation --></header>
<main id="main-content" class="skip-target" tabindex="-1">
<h1>Page title</h1>
</main>
</body>.skip-link {
position: absolute;
z-index: 1000;
inset-block-start: 0;
inset-inline-start: 1rem;
padding: 0.75rem 1rem;
color: #ffffff;
background: #111827;
font-weight: 700;
transform: translateY(-150%);
}
.skip-link:focus {
transform: translateY(0);
outline: 3px solid #facc15;
outline-offset: 2px;
}
@media (forced-colors: active) {
.skip-link { color: LinkText; background: Canvas; border: 1px solid LinkText; }
.skip-link:focus { outline-color: Highlight; }
}After activation: focus is in the main content; the main heading/beginning of content is visible; the next Tab reaches the first logical interactive element in the main content; the skipped navigation is not traversed first. tabindex="-1" allows focus without adding a tab stop — it is not sequential tab order. Test at 400% zoom and with long translated text; the focused link must not be clipped by the viewport, a cookie banner, a fixed header, or an overflow: hidden ancestor.
Scrolling and focus are related but different — a visual scroll does not prove keyboard focus or a screen reader's point of regard moved anywhere useful. Start with native HTML and don't cancel the click:
<a href="#testing">Testing</a>
<h2 id="testing">Testing</h2>Do not automatically add tabindex="-1" to every heading or call .focus() for every fragment merely because JavaScript can do so. Programmatic focus is appropriate when native behavior doesn't provide an understandable transition, or navigation has been intercepted:
function focusFragmentTarget(target) {
if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
target.scrollIntoView({ block: 'start' });
}Do not add a positive tabindex; do not focus a hidden element. When focus moves to a normally non-interactive target, provide a visible orientation cue:
.skip-target:focus, .programmatic-focus-target:focus {
outline: 3px solid currentColor;
outline-offset: 0.25rem;
}Use CSS scroll offsets so fragment targets and focused elements are not placed under a sticky header (WCAG 2.4.11 Focus Not Obscured Minimum also applies here):
:root { --anchor-offset: 5rem; }
html { scroll-padding-block-start: var(--anchor-offset); }
h2[id], h3[id], main[id], .anchor-target {
scroll-margin-block-start: var(--anchor-offset);
}Set scroll-padding-block-start on the element that actually scrolls — for an internal scrolling region, setting it only on html has no effect. The offset must match the header at responsive breakpoints, zoom levels, and when banners appear/disappear.
prefers-reduced-motionThe most robust default is immediate native scrolling. Unconditional scroll-behavior: smooth can trigger vestibular disorders. Applying smooth scroll globally without the media query guard is Serious.
/* Never unconditional */
/* Bad: html { scroll-behavior: smooth; } */
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
}const allowMotion = window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
target.scrollIntoView({ behavior: allowMotion ? 'smooth' : 'auto', block: 'start' });Do not pair smooth scroll with parallax, animated highlighting, or other motion that makes the destination harder to track. Do not add JavaScript only to reproduce behavior already available through native fragment navigation.
<nav aria-labelledby="contents-heading">
<h2 id="contents-heading">Contents</h2>
<ol>
<li><a href="#installation">Installation</a></li>
<li><a href="#configuration">Configuration</a></li>
</ol>
</nav>Match link wording to the destination heading; preserve heading hierarchy; don't let a long TOC become another block users need to bypass.
For scroll-spy, mark the section currently in view with `aria-current="location"` (not "true"), updated via IntersectionObserver or scroll listener:
<nav aria-labelledby="on-this-page-heading">
<ul>
<li><a href="#overview" aria-current="location">Overview</a></li>
<li><a href="#testing">Testing</a></li>
</ul>
</nav>Expose the current state visually too; keep only one link current. Do not announce every scroll-spy change with a live region, and do not push a new history entry on every scroll-spy update.
A permalink navigates to the heading's fragment; keep it visually/structurally separate from the heading so its accessible name doesn't become part of the heading text:
<div class="heading-with-permalink">
<h2 id="installation">Installation</h2>
<a class="permalink" href="#installation">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">…</svg>
<span class="visually-hidden">Permalink to Installation</span>
</a>
</div>The permalink needs a section-identifying accessible name, a visible focus indicator, a usable target size, sufficient contrast if the icon identifies the link, and availability without pointer hover. If activation copies the URL instead of navigating, use a button named "Copy link to Installation" with a status message after the copy succeeds.
For back-to-top links, use an explicit target rather than an empty #:
<header id="page-top" class="programmatic-focus-target" tabindex="-1">…</header>
<a href="#page-top">Back to top</a>Ensure the target isn't placed behind sticky content, and don't repeat the link so often it adds noise to every short section.
An accessible router must handle both route and fragment navigation:
href (e.g., /settings#notifications)— never a button for route/fragment navigation
parent before navigating to the target
container; move focus when a route change requires it
hashchange, and back/forward navigationnavigated elsewhere
A full route change generally needs document-title and main-heading focus management (see skills/navigation/SKILL.md); a same-page fragment change does not automatically need the same route announcement.
sharing, reload, and Back/Forward
history.replaceState()for ordinary anchor navigation if it would prevent Back from working
#installation-guide not #section-3) — this is adocumented authoring convention, not a WCAG requirement (HTML permits more characters than letters/hyphens)
first/near-first focusable; confirm fully visible with focus indicator; Enter moves focus to main content; Tab continues with the first logical control in main content
activates; confirm target visible and not obscured; Tab/Shift+Tab logical; Back/Forward preserve history and focus
link and verify main content is the new location; activate TOC links and confirm destination is understandable; test direct fragment URLs on load/reload
headers/banners don't cover targets or focus; text-spacing overrides; high contrast/forced-colors; reduced motion gives immediate scroll; RTL content
accessible names match visible labels for speech input
Automated checks can catch: links without accessible names, empty/dummy href, unresolved fragments, duplicate IDs, whitespace in IDs, positive tabindex, focusable content hidden with aria-hidden, icon-only links without text alternatives. Run a link checker against built output, not just source Markdown. Automation cannot confirm focus placement, sticky-header clearance, motion behavior, or whether the destination is understandable.
| Failure | Correction |
|---|---|
| Using a button for fragment navigation | Use an anchor with a real href |
Using href="#" as a JS action | Use a button, or the real fragment destination |
Replacing visible text with a different aria-label | Keep the visible words in the accessible name |
| Linking to a missing or duplicate ID | Create one unique matching target |
| Regenerating IDs whenever heading text changes | Preserve published deep links |
| Moving focus to every heading automatically | Use native navigation; add focus management only where needed |
Adding tabindex="0" to headings | Use tabindex="-1" for intentional programmatic focus only |
| Scrolling to content hidden behind a sticky header | Use correct scroll padding/margin; test responsive offsets |
| Using unconditional smooth scrolling | Gate with prefers-reduced-motion: no-preference |
| Pushing a history entry on every scroll-spy update | Update current state without polluting history |
| Linking into closed or inactive content | Reveal the containing disclosure/panel before navigation |
aria-label — role already announcedid values; published IDs kept stabletabindex="-1" only when programmatic focus is neededdisplay:none/visibility:hiddenscroll-padding/scroll-margin clears sticky headers on both target and focusprefers-reduced-motion: no-preferencearia-current="location" on active TOC/scroll-spy link (not "true")href, history, and modifier-click behaviorSmooth scroll animation is a best practice aligned with prefers-reduced-motion but is not directly tested under WCAG 2.2 AA (2.3.3 Animation from Interactions is Level AAA). The guard is still required as a progressive enhancement baseline.
Standards horizon: These rules target WCAG 2.2 AA. No breaking changes anticipated in WCAG 3.0 for anchor link patterns. Monitor: <https://www.w3.org/TR/wcag-3.0/>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.