Skip to content

formspiel/dynamic-skip-links

Repository files navigation

Dynamic Skip Links

A small, dependency-free module that scans a page for landmarks and headings and builds a skip link list automatically, giving keyboard and assistive-technology users a fast way to jump between sections.


Repository structure

dynamic-skip-links/
├── src/          — human-readable source files (edit these)
│   ├── dynamic-skip-links.js
│   └── dynamic-skip-links.css
├── dist/         — minified production files (auto-built by CI)
│   ├── dynamic-skip-links.min.js
│   └── dynamic-skip-links.min.css
└── tests/        — Jest test suite

Use the files in dist/ in your project. The dist/ folder is committed to the repository and is rebuilt automatically by a GitHub Actions workflow whenever src/ files change on main.


Integration

The module ships two files. Both are required.

File Purpose
dist/dynamic-skip-links.min.js Scans the page and generates the skip links
dist/dynamic-skip-links.min.css Mandatory mechanics: hides links off-screen and reveals them on focus

1. Include the module files

<link rel="stylesheet" href="dist/dynamic-skip-links.min.css">

<!-- before </body> -->
<script src="dist/dynamic-skip-links.min.js"></script>

The script injects the skip links nav automatically. No HTML snippet required.

2. Style the revealed links

dynamic-skip-links.min.css deliberately ships with no visual design. Add this to your own stylesheet and adjust to match your project:

#skiplinks a:focus-visible,
#skiplinks a:active {
  padding: .5rem 1rem;
  background-color: #fff;
  border: 2px solid #000;
  text-align: center;
  text-decoration: none;
}

That is the complete integration.


Customisation

Visual design

The only rule you need to style is #skiplinks a:focus-visible. The mandatory CSS file handles the mechanical reveal (undoing the off-screen hiding); your stylesheet adds the colours, spacing, and typography on top via the normal cascade.

/* example — adapt to your design system */
#skiplinks a:focus-visible,
#skiplinks a:active {
  padding: .75rem 1.5rem;
  font-size: 1rem;
  font-weight: bold;
  background-color: var(--color-brand);
  color: #fff;
  border: 3px solid #fff;
  border-radius: .25rem;
  text-align: center;
  text-decoration: none;
}

Stacking order

If the default z-index conflicts with your layout, override the CSS custom property — no need to edit the module file:

:root {
  --skiplinks-z-index: 500;
}

Layouts with a positioned <body>

#skiplinks uses position: absolute, which positions it relative to the nearest positioned ancestor. On most pages <body> is not positioned and the skip links sit at the top of the viewport as intended. If your layout gives <body> a position value (e.g. position: relative), override the rule in your stylesheet to use position: fixed instead:

#skiplinks {
  position: fixed;
}

This is also the right choice for pages with a sticky or fixed header where you want the skip links to always appear above the header when focused.


Configuration

Override any value without editing the module file by declaring window.dynamicSkipLinksConfig before the script tag:

<script>
  window.dynamicSkipLinksConfig = { debug: true };
</script>
<script src="dist/dynamic-skip-links.min.js"></script>

The available properties and their defaults:

{
  // id of the <ul> that receives the generated links
  containerId: "js-nav-skip-links",

  // CSS selector for elements that become skip link targets
  selector: "header, main, nav, form, h1, h2",

  // highlight targets and warn about missing labels (never true in production)
  debug: false,

  // aria-label for the injected skip links nav landmark
  navLabel: "Skip links",

  // text prepended to every skip link
  labelPrefix: "Skip to",

  // fallback labels for landmark elements that have no aria-label
  typeLabels: {
    HEADER: "Page header",
    MAIN:   "Main content",
    NAV:    "Navigation",
    FORM:   "Form"
  },

  // last-resort label when no other label can be resolved
  noLabel: "No label",

  // enable F6 / Shift+F6 to cycle focus through landmark targets
  f6: false,

  // when true, require Ctrl+F6 instead of plain F6 (avoids browser conflicts)
  f6Modifier: false,

  // hint text rendered inside the skip links nav when f6 is enabled.
  // Appears alongside the skip link list when any link receives focus.
  // Empty string (default) = no hint rendered.
  f6Hint: ""
}

Overriding individual typeLabels

typeLabels are deep-merged with the defaults, so you only need to supply the keys you want to change:

<script>
  window.dynamicSkipLinksConfig = {
    typeLabels: { NAV: "Menu" }
    // HEADER, MAIN, and FORM keep their defaults
  };
</script>

Translating to another language

All user-facing strings are in the config, so translation requires no changes to the module file:

<script>
  window.dynamicSkipLinksConfig = {
    navLabel:    "Seitennavigation",
    labelPrefix: "Springe zu",
    typeLabels: {
      HEADER: "Seitenkopf",
      MAIN:   "Hauptinhalt",
      NAV:    "Navigation",
      FORM:   "Formular"
    },
    noLabel: "Kein Label"
  };
</script>
<script src="dist/dynamic-skip-links.min.js"></script>

Suggested values for common languages:

Language navLabel labelPrefix noLabel
English (default) "Skip links" "Skip to" "No label"
German "Seitennavigation" "Springe zu" "Kein Label"
French "Liens d'évitement" "Passer à" "Sans label"
Italian "Link di navigazione" "Salta a" "Nessuna etichetta"
Spanish "Saltar a" "Saltar a" "Sin etiqueta"

The typeLabels for each language:

Language HEADER MAIN NAV FORM
German "Seitenkopf" "Hauptinhalt" "Navigation" "Formular"
French "En-tête" "Contenu principal" "Navigation" "Formulaire"
Italian "Intestazione" "Contenuto principale" "Navigazione" "Modulo"
Spanish "Encabezado" "Contenido principal" "Navegación" "Formulario"

F6 navigation

The module can optionally wire up the F6 key to cycle focus through landmark targets — the same elements the skip links point to. This gives power keyboard users a quick way to jump between sections without opening the skip link list.

<script>
  window.dynamicSkipLinksConfig = { f6: true };
</script>
Key Action
F6 Focus the next landmark target
Shift+F6 Focus the previous landmark target

Navigation wraps around: pressing F6 on the last target goes back to the first, and Shift+F6 on the first goes to the last.

Why f6 defaults to false

Browsers use F6 to cycle focus between the address bar, the bookmarks toolbar, and the page. Overriding it by default would silently break that built-in shortcut. Set f6: true only when you have confirmed that your users do not rely on the browser's F6 behaviour — or use f6Modifier to require Ctrl.

Using Ctrl+F6 instead of plain F6

Set f6Modifier: true to require the Ctrl key. This avoids any conflict with the browser's native F6 shortcut:

<script>
  window.dynamicSkipLinksConfig = { f6: true, f6Modifier: true };
</script>
Key Action
Ctrl+F6 Focus the next landmark target
Ctrl+Shift+F6 Focus the previous landmark target

Informing users with f6Hint

When F6 is enabled, keyboard users who discover the skip links via Tab have no way of knowing that F6 shortcuts exist. The f6Hint option renders a small informational paragraph inside the skip links nav. It appears alongside the skip link list whenever a link is focused (using CSS :focus-within) and is read by screen readers.

<script>
  window.dynamicSkipLinksConfig = {
    f6: true,
    f6Hint: "Press F6 / Shift+F6 to jump between sections"
  };
</script>

With f6Modifier: true, update the hint to match:

<script>
  window.dynamicSkipLinksConfig = {
    f6: true,
    f6Modifier: true,
    f6Hint: "Press Ctrl+F6 / Ctrl+Shift+F6 to jump between sections"
  };
</script>

The hint is rendered as <p class="dsl-f6-hint">. No visual design is applied by the module — add your own styles after the module CSS, for example:

.dsl-f6-hint {
  font-size: 0.75rem;
  color: #555;
  padding: 0.25rem 1rem 0.5rem;
  text-align: center;
}

Suggested f6Hint translations:

Language Plain F6 Ctrl+F6
English "Press F6 / Shift+F6 to jump between sections" "Press Ctrl+F6 / Ctrl+Shift+F6 to jump between sections"
German "F6 / Umschalt+F6 zum Springen zwischen Abschnitten" "Strg+F6 / Strg+Umschalt+F6 zum Springen zwischen Abschnitten"
French "Appuyez sur F6 / Maj+F6 pour naviguer entre les sections" "Appuyez sur Ctrl+F6 / Ctrl+Maj+F6 pour naviguer entre les sections"
Italian "Premi F6 / Maiusc+F6 per passare tra le sezioni" "Premi Ctrl+F6 / Ctrl+Maiusc+F6 per passare tra le sezioni"
Spanish "Presiona F6 / Mayús+F6 para saltar entre secciones" "Presiona Ctrl+F6 / Ctrl+Mayús+F6 para saltar entre secciones"

Debug mode

Set config.debug = true during development to surface labelling problems before they reach production.

What it does:

  • Adds a visible outline to every element that becomes a skip link target
    • Blue — element has a valid label
    • Orange — label is missing; the skip link will fall back to No label
  • Appends a visually-visible type tag (e.g. (nav), (h2)) to each skip link. The tag is wrapped in aria-hidden="true" so screen readers hear only the clean label — the tag is purely for developer orientation
  • Logs a console.warn for each missing label, naming the element by tag and id
  • Appends the element type (e.g. (nav), (h2)) visually to each skip link via an aria-hidden span — developers see it, screen readers do not

Example warning:

dynamic-skip-links: missing label on <nav id="sl-...">. Add an aria-label attribute to provide a meaningful skip link.

Set debug back to false before shipping. No outlines, no warnings, zero output.


How labels are resolved

Each skip link label is derived from the target element using this priority order:

  1. aria-label attribute on the element
  2. Text content of the element referenced by aria-labelledby
  3. The element's own text content (headings only — h1h6)
  4. Type fallback from typeLabels (HEADER, MAIN, NAV, FORM)
  5. Last resort: noLabel

Labels longer than 60 characters are trimmed automatically at the nearest word boundary and an ellipsis (…) is appended. Labels with no spaces are hard-cut at 60 characters.

Use aria-label on landmark elements (header, nav, main, form) to ensure they always produce a meaningful skip link, regardless of content.

<nav aria-label="Main navigation"></nav>
<form role="search" aria-label="Site search"></form>

Building locally

npm install
npm run build   # writes dist/dynamic-skip-links.min.js and dist/dynamic-skip-links.min.css
npm test        # runs the Jest test suite

The CI workflow (.github/workflows/build.yml) rebuilds dist/ automatically on every push to main that touches src/. The GitHub Actions bot commits the updated files back to main with the message chore: build minified dist [skip ci].

Branch protection note: for the bot commit to succeed, the repository's branch protection rules must allow GitHub Actions to push directly to main. In your repository settings go to Settings → Actions → General → Workflow permissions and enable Allow GitHub Actions to create and approve pull requests, or add a bypass exception for the github-actions[bot] actor under the branch protection rule.


Demo

Open index.html in a browser. Press Tab to reveal the skip link list, or press F6 to jump between landmarks (enabled in the demo). The demo page uses an additional demo.css file to apply a visual design — this file is not part of the module and is not required in your project.

About

Provide better Skip link to make your applications more accessible

Topics

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Contributors