Detailed guide

Skip Links Implementation Guide

View contents

Skip Links Implementation Guide: Complete Patterns for Bypass Navigation

Introduction

Skip links are one of the most important accessibility features for keyboard users, yet they’re often implemented incorrectly or omitted entirely. This guide covers everything from basic implementation to advanced patterns, including handling single-page applications, multiple skip targets, and framework-specific solutions.

A well-implemented skip link saves keyboard users from tabbing through potentially dozens of navigation links on every page load. For sites with extensive headers, this can mean the difference between a usable and unusable experience.

Minimal HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Page Title</title>
</head>
<body>
  <!-- Skip link MUST be first focusable element -->
  <a href="#main-content" class="skip-link">
    Skip to main content
  </a>

  <header>
    <nav>
      <!-- Navigation links -->
    </nav>
  </header>

  <main id="main-content" tabindex="-1">
    <h1>Page Heading</h1>
    <!-- Main content -->
  </main>

  <footer>
    <!-- Footer content -->
  </footer>
</body>
</html>

Essential CSS

/* Visually hidden but accessible */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000000;
  color: #ffffff;
  padding: 8px 16px;
  z-index: 100;
  text-decoration: none;
  font-weight: bold;
}

/* Visible on focus */
.skip-link:focus {
  top: 0;
}

Why tabindex=“-1” on Target

The target element needs tabindex="-1" to receive focus programmatically:

<!-- Without tabindex: Focus may not move in some browsers -->
<main id="main-content">

<!-- With tabindex: Guaranteed focus in all browsers -->
<main id="main-content" tabindex="-1">

Important: tabindex="-1" makes the element focusable via JavaScript/anchor links but keeps it out of the normal tab order.

Advanced CSS Patterns

Smooth Animation

.skip-link {
  position: absolute;
  transform: translateY(-100%);
  transition: transform 0.3s ease-in-out;
  background: #1a1a1a;
  color: #ffffff;
  padding: 12px 24px;
  border-radius: 0 0 4px 4px;
  z-index: 9999;
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
}

.skip-link:focus {
  transform: translateY(0);
}

/* Remove outline since we have clear visual focus */
.skip-link:focus {
  outline: 2px solid #ffffff;
  outline-offset: 2px;
}
.skip-link {
  position: absolute;
  left: 50%;
  transform: translateX(-50%) translateY(-100%);
  transition: transform 0.3s ease;
  /* ... other styles */
}

.skip-link:focus {
  transform: translateX(-50%) translateY(0);
}
.skip-link {
  position: absolute;
  top: -100%;
  left: 16px;
  background: #000000;
  color: #ffffff;
  padding: 16px 24px;
  border: 3px solid #ffffff;
  border-radius: 4px;
  font-size: 16px;
  font-weight: bold;
  text-decoration: underline;
  z-index: 10000;
  transition: top 0.2s;
}

.skip-link:focus {
  top: 16px;
}

/* Ensure works in Windows High Contrast Mode */
@media (forced-colors: active) {
  .skip-link {
    border: 3px solid currentColor;
  }
}

Use multiple skip links when your page has:

  • Significant secondary navigation or search
  • Multiple main content areas
  • Complex application layouts

Implementation Pattern

<div class="skip-links">
  <a href="#main-content" class="skip-link">Skip to main content</a>
  <a href="#search" class="skip-link">Skip to search</a>
  <a href="#navigation" class="skip-link">Skip to navigation</a>
</div>

<header>
  <form id="search" role="search" tabindex="-1">
    <input type="search" aria-label="Search">
    <button type="submit">Search</button>
  </form>
  <nav id="navigation" tabindex="-1">
    <!-- Navigation -->
  </nav>
</header>

<main id="main-content" tabindex="-1">
  <!-- Content -->
</main>
.skip-links {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 10000;
}

.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  display: block;
  padding: 12px 24px;
  background: #000;
  color: #fff;
  text-decoration: none;
  white-space: nowrap;
  transition: top 0.2s;
}

/* Stack links vertically when focused */
.skip-link:nth-child(1):focus { top: 0; }
.skip-link:nth-child(2):focus { top: 48px; }
.skip-link:nth-child(3):focus { top: 96px; }
<div class="skip-menu" role="navigation" aria-label="Skip links">
  <button class="skip-menu-trigger" aria-expanded="false">
    Skip to section
  </button>
  <ul class="skip-menu-list" hidden>
    <li><a href="#main-content">Main content</a></li>
    <li><a href="#search">Search</a></li>
    <li><a href="#footer">Footer</a></li>
  </ul>
</div>

Framework Implementations

React Component

// SkipLink.tsx
import { useRef, useEffect } from 'react';

interface SkipLinkProps {
  targetId: string;
  children: React.ReactNode;
}

export function SkipLink({ targetId, children }: SkipLinkProps) {
  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();
    const target = document.getElementById(targetId);

    if (target) {
      target.setAttribute('tabindex', '-1');
      target.focus();
      target.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }
  };

  return (
    <a
      href={`#${targetId}`}
      className="skip-link"
      onClick={handleClick}
    >
      {children}
    </a>
  );
}

// Usage in App.tsx
function App() {
  return (
    <>
      <SkipLink targetId="main-content">
        Skip to main content
      </SkipLink>
      <Header />
      <main id="main-content">
        {/* Content */}
      </main>
    </>
  );
}

Vue 3 Component

<!-- SkipLink.vue -->
<script setup lang="ts">
interface Props {
  targetId: string
}

const props = defineProps<Props>()

function handleClick(e: Event) {
  e.preventDefault()
  const target = document.getElementById(props.targetId)

  if (target) {
    target.setAttribute('tabindex', '-1')
    target.focus()
    target.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }
}
</script>

<template>
  <a
    :href="`#${targetId}`"
    class="skip-link"
    @click="handleClick"
  >
    <slot>Skip to main content</slot>
  </a>
</template>

<style scoped>
.skip-link {
  position: absolute;
  transform: translateY(-100%);
  transition: transform 0.2s ease-in-out;
  background: #000;
  color: #fff;
  padding: 0.75rem 1.5rem;
  z-index: 9999;
  text-decoration: none;
}

.skip-link:focus {
  transform: translateY(0);
}
</style>

Next.js with App Router

// components/SkipLink.tsx
'use client';

import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';

export function SkipLink() {
  const pathname = usePathname();
  const skipLinkRef = useRef<HTMLAnchorElement>(null);

  // Reset focus to skip link on route change
  useEffect(() => {
    skipLinkRef.current?.blur();
  }, [pathname]);

  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault();
    const main = document.getElementById('main-content');
    if (main) {
      main.tabIndex = -1;
      main.focus();
    }
  };

  return (
    <a
      ref={skipLinkRef}
      href="#main-content"
      className="skip-link"
      onClick={handleClick}
    >
      Skip to main content
    </a>
  );
}

Single-Page Application Considerations

Route Change Handling

In SPAs, you need to manage focus when routes change:

// React Router example
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function useRouteAnnouncer() {
  const location = useLocation();

  useEffect(() => {
    // Focus main content on route change
    const main = document.getElementById('main-content');
    if (main) {
      main.tabIndex = -1;
      main.focus();

      // Remove tabindex after focus (optional)
      main.addEventListener('blur', () => {
        main.removeAttribute('tabindex');
      }, { once: true });
    }
  }, [location.pathname]);
}

Vue Router Integration

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [...],
  scrollBehavior(to, from, savedPosition) {
    // Focus main content after navigation
    setTimeout(() => {
      const main = document.getElementById('main-content')
      if (main) {
        main.setAttribute('tabindex', '-1')
        main.focus()
      }
    }, 100)

    return savedPosition || { top: 0 }
  }
})

Handling Sticky Headers

The Problem

When skip link targets are near the top of the page, sticky headers can cover them:

/* Skip link works but content is hidden under header */
.header {
  position: sticky;
  top: 0;
  height: 80px;
}

#main-content {
  /* Content starts right under header visually,
     but skip link scrolls to actual element position */
}

Solution: Scroll Padding

html {
  scroll-padding-top: 100px; /* Header height + buffer */
}

/* Or target specific elements */
#main-content,
#search,
#footer {
  scroll-margin-top: 100px;
}

Solution: CSS Custom Properties

:root {
  --header-height: 80px;
  --scroll-buffer: 20px;
}

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

/* Update with JavaScript if header height changes */

Solution: JavaScript Offset

function handleSkipLink(e) {
  e.preventDefault();
  const targetId = e.target.getAttribute('href').slice(1);
  const target = document.getElementById(targetId);
  const headerHeight = document.querySelector('.header').offsetHeight;

  if (target) {
    const targetPosition = target.getBoundingClientRect().top + window.scrollY;
    window.scrollTo({
      top: targetPosition - headerHeight - 20,
      behavior: 'smooth'
    });
    target.setAttribute('tabindex', '-1');
    target.focus({ preventScroll: true });
  }
}

Manual Testing Checklist

  1. First focusable test:

    • Load page fresh
    • Press Tab once
    • Skip link should be visible
  2. Activation test:

    • Focus skip link
    • Press Enter
    • Focus should move to target
    • Page should scroll if needed
  3. Post-skip test:

    • After activating skip link
    • Press Tab
    • Should focus first interactive element in main content
  4. Screen reader test:

    • Navigate with screen reader
    • Skip link should be announced
    • Target should be announced after activation

Automated Testing

// Playwright test
import { test, expect } from '@playwright/test';

test.describe('Skip Links', () => {
  test('skip link is first focusable element', async ({ page }) => {
    await page.goto('/');
    await page.keyboard.press('Tab');

    const focused = await page.locator(':focus');
    await expect(focused).toHaveClass(/skip-link/);
    await expect(focused).toBeVisible();
  });

  test('skip link moves focus to main content', async ({ page }) => {
    await page.goto('/');
    await page.keyboard.press('Tab');
    await page.keyboard.press('Enter');

    const focused = await page.locator(':focus');
    await expect(focused).toHaveAttribute('id', 'main-content');
  });

  test('skip link is visible only on focus', async ({ page }) => {
    await page.goto('/');

    const skipLink = page.locator('.skip-link');

    // Not visible initially
    await expect(skipLink).not.toBeInViewport();

    // Visible on focus
    await page.keyboard.press('Tab');
    await expect(skipLink).toBeInViewport();

    // Hidden again after blur
    await page.keyboard.press('Tab');
    await expect(skipLink).not.toBeInViewport();
  });
});

axe-core Integration

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('page has no bypass block violations', async () => {
  const { container } = render(<App />);
  const results = await axe(container, {
    rules: {
      'bypass': { enabled: true }
    }
  });
  expect(results).toHaveNoViolations();
});

Common Mistakes and Fixes

<!-- WRONG: Logo link comes first -->
<body>
  <a href="/" class="logo">Home</a>
  <a href="#main" class="skip-link">Skip</a>
</body>

<!-- CORRECT: Skip link is first -->
<body>
  <a href="#main" class="skip-link">Skip</a>
  <a href="/" class="logo">Home</a>
</body>

Mistake 2: Target Doesn’t Exist

<!-- WRONG: ID mismatch -->
<a href="#main-content">Skip</a>
<main id="content">...</main>

<!-- CORRECT: IDs match -->
<a href="#main-content">Skip</a>
<main id="main-content">...</main>

Mistake 3: Using display:none

/* WRONG: Removes from accessibility tree */
.skip-link {
  display: none;
}
.skip-link:focus {
  display: block;
}

/* CORRECT: Visually hidden but accessible */
.skip-link {
  position: absolute;
  top: -9999px;
}
.skip-link:focus {
  top: 0;
}

Summary Checklist

  • [ ] Skip link is first focusable element in DOM
  • [ ] Skip link is visually hidden until focused
  • [ ] Skip link uses clear, descriptive text
  • [ ] Target element has matching ID
  • [ ] Target has tabindex=“-1” for reliable focus
  • [ ] Page scrolls to show target when activated
  • [ ] Sticky headers don’t obscure target
  • [ ] Works correctly after SPA route changes
  • [ ] Tested with keyboard and screen reader
  • [ ] High contrast mode compatible

References

  1. W3C - WCAG 2.2 SC 2.4.1 Bypass Blocks
  2. W3C - Technique G1: Skip Link
  3. W3C - Technique G124: Adding Links at Top
  4. WebAIM - Skip Navigation Links
  5. A11Y Project - Skip Navigation Links

Related articles

Related version

Introduction

Skip Links Explained

Skip links are hidden navigation shortcuts that allow keyboard users to bypass repetitive content—like headers and navigation menus—and jump directly to the...

Category hub

Hub

Wcag Compliance Hub

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities