Ver contenido
Guía de Implementación de Skip Links: Patrones Completos para Navegación de Bypass
Introducción
Los skip links son una de las características de accesibilidad más importantes para usuarios de teclado, sin embargo frecuentemente se implementan incorrectamente o se omiten por completo. Esta guía cubre todo desde implementación básica hasta patrones avanzados, incluyendo el manejo de aplicaciones de página única, múltiples destinos de salto, y soluciones específicas para frameworks.
Un skip link bien implementado ahorra a los usuarios de teclado navegar a través de potencialmente docenas de enlaces de navegación en cada carga de página. Para sitios con headers extensos, esto puede significar la diferencia entre una experiencia usable e inutilizable.
Implementación Básica de Skip Link
Estructura HTML Mínima
<!DOCTYPE html>
<html lang="es">
<head>
<title>Título de Página</title>
</head>
<body>
<!-- Skip link DEBE ser el primer elemento enfocable -->
<a href="#main-content" class="skip-link">
Saltar al contenido principal
</a>
<header>
<nav>
<!-- Enlaces de navegación -->
</nav>
</header>
<main id="main-content" tabindex="-1">
<h1>Encabezado de Página</h1>
<!-- Contenido principal -->
</main>
<footer>
<!-- Contenido del footer -->
</footer>
</body>
</html>
CSS Esencial
/* Visualmente oculto pero accesible */
.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 al recibir foco */
.skip-link:focus {
top: 0;
}
Por Qué tabindex=“-1” en el Destino
El elemento destino necesita tabindex="-1" para recibir foco programáticamente:
<!-- Sin tabindex: El foco puede no moverse en algunos navegadores -->
<main id="main-content">
<!-- Con tabindex: Foco garantizado en todos los navegadores -->
<main id="main-content" tabindex="-1">
Importante: tabindex="-1" hace el elemento enfocable vía JavaScript/enlaces ancla pero lo mantiene fuera del orden normal de tab.
Patrones CSS Avanzados
Animación Suave
.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);
}
/* Remover outline ya que tenemos foco visual claro */
.skip-link:focus {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
Skip Link Centrado
.skip-link {
position: absolute;
left: 50%;
transform: translateX(-50%) translateY(-100%);
transition: transform 0.3s ease;
/* ... otros estilos */
}
.skip-link:focus {
transform: translateX(-50%) translateY(0);
}
Skip Link de Alto Contraste
.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;
}
/* Asegurar que funciona en Modo de Alto Contraste de Windows */
@media (forced-colors: active) {
.skip-link {
border: 3px solid currentColor;
}
}
Múltiples Skip Links
Cuándo Usar Múltiples Enlaces
Usa múltiples skip links cuando tu página tiene:
- Navegación secundaria o búsqueda significativa
- Múltiples áreas de contenido principal
- Layouts de aplicación complejos
Patrón de Implementación
<div class="skip-links">
<a href="#main-content" class="skip-link">Saltar al contenido principal</a>
<a href="#search" class="skip-link">Saltar a búsqueda</a>
<a href="#navigation" class="skip-link">Saltar a navegación</a>
</div>
<header>
<form id="search" role="search" tabindex="-1">
<input type="search" aria-label="Buscar">
<button type="submit">Buscar</button>
</form>
<nav id="navigation" tabindex="-1">
<!-- Navegación -->
</nav>
</header>
<main id="main-content" tabindex="-1">
<!-- Contenido -->
</main>
CSS para Múltiples Enlaces
.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;
}
/* Apilar enlaces verticalmente cuando tienen foco */
.skip-link:nth-child(1):focus { top: 0; }
.skip-link:nth-child(2):focus { top: 48px; }
.skip-link:nth-child(3):focus { top: 96px; }
Implementaciones en Frameworks
Componente React
// 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>
);
}
// Uso en App.tsx
function App() {
return (
<>
<SkipLink targetId="main-content">
Saltar al contenido principal
</SkipLink>
<Header />
<main id="main-content">
{/* Contenido */}
</main>
</>
);
}
Componente Vue 3
<!-- 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>Saltar al contenido principal</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>
Consideraciones para Aplicaciones de Página Única
Manejo de Cambio de Ruta
En SPAs, necesitas gestionar el foco cuando las rutas cambian:
// Ejemplo con React Router
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
function useRouteAnnouncer() {
const location = useLocation();
useEffect(() => {
// Enfocar contenido principal en cambio de ruta
const main = document.getElementById('main-content');
if (main) {
main.tabIndex = -1;
main.focus();
// Remover tabindex después del foco (opcional)
main.addEventListener('blur', () => {
main.removeAttribute('tabindex');
}, { once: true });
}
}, [location.pathname]);
}
Integración con Vue Router
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [...],
scrollBehavior(to, from, savedPosition) {
// Enfocar contenido principal después de navegación
setTimeout(() => {
const main = document.getElementById('main-content')
if (main) {
main.setAttribute('tabindex', '-1')
main.focus()
}
}, 100)
return savedPosition || { top: 0 }
}
})
Manejando Headers Sticky
El Problema
Cuando los destinos de skip link están cerca de la parte superior de la página, los headers sticky pueden cubrirlos:
/* Skip link funciona pero el contenido queda oculto bajo el header */
.header {
position: sticky;
top: 0;
height: 80px;
}
#main-content {
/* El contenido comienza justo bajo el header visualmente,
pero el skip link hace scroll a la posición real del elemento */
}
Solución: Scroll Padding
html {
scroll-padding-top: 100px; /* Altura del header + margen */
}
/* O apuntar a elementos específicos */
#main-content,
#search,
#footer {
scroll-margin-top: 100px;
}
Solución: Propiedades Personalizadas CSS
:root {
--header-height: 80px;
--scroll-buffer: 20px;
}
html {
scroll-padding-top: calc(var(--header-height) + var(--scroll-buffer));
}
/* Actualizar con JavaScript si cambia la altura del header */
Solución: Offset con JavaScript
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 });
}
}
Probando Skip Links
Checklist de Pruebas Manuales
-
Prueba de primer enfocable:
- Cargar página fresca
- Presionar Tab una vez
- Skip link debe ser visible
-
Prueba de activación:
- Enfocar skip link
- Presionar Enter
- El foco debe moverse al destino
- La página debe hacer scroll si es necesario
-
Prueba post-salto:
- Después de activar skip link
- Presionar Tab
- Debe enfocar el primer elemento interactivo en el contenido principal
-
Prueba con lector de pantalla:
- Navegar con lector de pantalla
- Skip link debe ser anunciado
- El destino debe ser anunciado después de activación
Pruebas Automatizadas
// Test con Playwright
import { test, expect } from '@playwright/test';
test.describe('Skip Links', () => {
test('skip link es el primer elemento enfocable', 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 mueve el foco al contenido principal', 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 es visible solo con foco', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('.skip-link');
// No visible inicialmente
await expect(skipLink).not.toBeInViewport();
// Visible con foco
await page.keyboard.press('Tab');
await expect(skipLink).toBeInViewport();
// Oculto de nuevo después de blur
await page.keyboard.press('Tab');
await expect(skipLink).not.toBeInViewport();
});
});
Errores Comunes y Soluciones
Error 1: Skip Link No Es Primero
<!-- INCORRECTO: El enlace del logo viene primero -->
<body>
<a href="/" class="logo">Inicio</a>
<a href="#main" class="skip-link">Saltar</a>
</body>
<!-- CORRECTO: Skip link es primero -->
<body>
<a href="#main" class="skip-link">Saltar</a>
<a href="/" class="logo">Inicio</a>
</body>
Error 2: El Destino No Existe
<!-- INCORRECTO: IDs no coinciden -->
<a href="#main-content">Saltar</a>
<main id="content">...</main>
<!-- CORRECTO: IDs coinciden -->
<a href="#main-content">Saltar</a>
<main id="main-content">...</main>
Error 3: Usar display:none
/* INCORRECTO: Remueve del árbol de accesibilidad */
.skip-link {
display: none;
}
.skip-link:focus {
display: block;
}
/* CORRECTO: Visualmente oculto pero accesible */
.skip-link {
position: absolute;
top: -9999px;
}
.skip-link:focus {
top: 0;
}
Checklist de Resumen
- [ ] Skip link es el primer elemento enfocable en el DOM
- [ ] Skip link está visualmente oculto hasta recibir foco
- [ ] Skip link usa texto claro y descriptivo
- [ ] El elemento destino tiene ID coincidente
- [ ] El destino tiene tabindex=“-1” para foco confiable
- [ ] La página hace scroll para mostrar el destino cuando se activa
- [ ] Headers sticky no oscurecen el destino
- [ ] Funciona correctamente después de cambios de ruta en SPA
- [ ] Probado con teclado y lector de pantalla
- [ ] Compatible con modo de alto contraste
Artículos Relacionados
- Enlaces de Salto Explicado - Requisitos WCAG 2.4.1
- Guía de Navegación por Teclado - Soporte completo de teclado
- Guía de Indicadores de Foco - Estados de foco visibles
- Hub de Cumplimiento WCAG - Todos los evaluadores de accesibilidad
Referencias
- W3C - WCAG 2.2 SC 2.4.1 Bypass Blocks
- W3C - Technique G1: Skip Link
- W3C - Technique G124: Adding Links at Top
- WebAIM - Skip Navigation Links
- A11Y Project - Skip Navigation Links