Guía detallada

Skip Links Implementation Guide

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.

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 {
  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 {
  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;
  }
}

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 });
  }
}

Checklist de Pruebas Manuales

  1. Prueba de primer enfocable:

    • Cargar página fresca
    • Presionar Tab una vez
    • Skip link debe ser visible
  2. Prueba de activación:

    • Enfocar skip link
    • Presionar Enter
    • El foco debe moverse al destino
    • La página debe hacer scroll si es necesario
  3. Prueba post-salto:

    • Después de activar skip link
    • Presionar Tab
    • Debe enfocar el primer elemento interactivo en el contenido principal
  4. 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

<!-- 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


Referencias

  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

Artículos relacionados

Versión relacionada

Introducción

Skip Links Explained

Los skip links son atajos de navegación ocultos que permiten a los usuarios de teclado saltar contenido repetitivo—como headers y menús de navegación—e ir...

Hub de categoría

Hub

Wcag Compliance Hub

La accesibilidad web asegura que los sitios web y aplicaciones puedan ser utilizados por todos, incluyendo personas con discapacidades