Guía detallada

Focus Indicators Implementation Guide

Ver contenido

Guía de Implementación de Indicadores de Foco: Técnicas CSS para Estados de Foco Accesibles

Introducción

Los indicadores de foco son el feedback visual que muestra qué elemento tiene actualmente el foco del teclado. Aunque los navegadores proporcionan estilos de foco predeterminados, a menudo son insuficientes—demasiado sutiles, eliminados por CSS resets o inconsistentes entre navegadores. Esta guía proporciona técnicas CSS completas para implementar indicadores de foco que cumplan los requisitos WCAG 2.2 manteniendo la estética del diseño.

WCAG 2.2 introdujo nuevos criterios relacionados con el foco (2.4.11, 2.4.12 y 2.4.13) que establecen requisitos específicos para visibilidad, tamaño y contraste de indicadores de foco. Entender e implementar estos requisitos asegura que tu sitio sea accesible para usuarios de teclado.

Entendiendo los Requisitos de Foco de WCAG

WCAG 2.4.7: Foco Visible (Nivel AA)

El elemento enfocado debe tener un indicador de foco visible. Este es el requisito fundamental—si los usuarios pueden navegar con Tab hacia él, deben poder ver el foco.

WCAG 2.4.11: Foco No Oscurecido (Mínimo) (Nivel AA)

Cuando un elemento recibe foco, no puede estar completamente oculto por contenido creado por el autor como:

  • Headers o footers sticky
  • Overlays de modales
  • Banners de cookies
  • Widgets de chat

WCAG 2.4.13: Apariencia del Foco (Nivel AAA)

Para cumplimiento mejorado, los indicadores de foco deben:

  • Tener un área de al menos el perímetro del elemento × 2 píxeles CSS
  • Tener un ratio de contraste 3:1 contra el estado sin foco
  • Tener un ratio de contraste 3:1 contra colores adyacentes

Pseudo-Clases CSS de Foco

La Pseudo-Clase :focus

Se aplica cuando un elemento recibe foco (teclado, ratón o táctil):

/* Estilo de foco básico */
button:focus {
  outline: 2px solid blue;
}

/* Problema: Se muestra también para clicks de ratón */

La Pseudo-Clase :focus-visible

Se aplica solo cuando el foco debe ser visible (principalmente teclado):

/* Enfoque moderno - solo usuarios de teclado */
button:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* Eliminar outline predeterminado para foco no-teclado */
button:focus:not(:focus-visible) {
  outline: none;
}

La Pseudo-Clase :focus-within

Se aplica cuando cualquier descendiente tiene foco (útil para grupos de formulario):

/* Resaltar grupo de formulario completo cuando cualquier campo tiene foco */
.form-group:focus-within {
  background-color: #f0f7ff;
  border-left: 3px solid #005fcc;
}

Estrategia de Soporte de Navegadores

/* Fallback para navegadores antiguos */
button:focus {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* Navegadores modernos que soportan :focus-visible */
button:focus:not(:focus-visible) {
  outline: none;
}

button:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Patrones de Diseño de Indicadores de Foco

Patrón 1: Outline con Offset

El enfoque más confiable y compatible:

:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

/* Invertido para fondos oscuros */
.dark-theme :focus-visible {
  outline: 3px solid #66b3ff;
  outline-offset: 2px;
}

Ventajas:

  • Funciona en todas las formas de elementos
  • No afecta el layout
  • Alto soporte de navegadores
  • Visibilidad clara

Desventajas:

  • No puede tener esquinas redondeadas (outline no sigue border-radius)
  • Puede superponerse a elementos adyacentes

Patrón 2: Box Shadow

Crea indicadores de foco redondeados que siguen la forma del elemento:

:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}

/* Versión sólida */
:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px #005fcc;
}

/* Efecto de anillo doble */
:focus-visible {
  outline: none;
  box-shadow:
    0 0 0 2px #ffffff,
    0 0 0 4px #005fcc;
}

Ventajas:

  • Sigue el border-radius
  • Puede crear múltiples anillos
  • Soporta transparencia

Desventajas:

  • Puede conflictuar con box-shadows existentes
  • Puede ser recortado por overflow: hidden

Patrón 3: Cambio de Borde

Usa cambio de color de borde para estado enfocado:

/* Estado base con borde transparente */
.input-field {
  border: 2px solid #ccc;
  transition: border-color 0.15s ease;
}

/* Estado enfocado */
.input-field:focus-visible {
  border-color: #005fcc;
  outline: none;
}

/* Con indicador adicional */
.input-field:focus-visible {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.25);
  outline: none;
}

Ventajas:

  • Apariencia limpia y moderna
  • No requiere espacio adicional

Desventajas:

  • Requiere borde base
  • Puede no ser suficientemente visible solo

Patrón 4: Cambio de Fondo

Cambia el color de fondo cuando tiene foco:

.nav-link:focus-visible {
  background-color: #e6f0ff;
  outline: 2px solid #005fcc;
}

/* Para botones */
.button:focus-visible {
  background-color: #004494;
  outline: 2px solid #002255;
  outline-offset: 2px;
}

Ventajas:

  • Alta visibilidad
  • Cambio de estado claro

Desventajas:

  • Puede conflictuar con el diseño
  • Requiere planificación de colores

Implementación Específica por Componente

Enlaces de Navegación

.nav-link {
  position: relative;
  padding: 0.5rem 1rem;
}

.nav-link:focus-visible {
  outline: none;
  background-color: rgba(0, 95, 204, 0.1);
  border-radius: 4px;
}

/* Indicador de subrayado */
.nav-link::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: transparent;
  transition: background 0.15s ease;
}

.nav-link:focus-visible::after {
  background: #005fcc;
}

Botones

/* Botón primario */
.btn-primary {
  background: #005fcc;
  color: white;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 6px;
}

.btn-primary:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
  box-shadow: 0 0 0 5px rgba(0, 95, 204, 0.3);
}

/* Botón secundario */
.btn-secondary {
  background: transparent;
  color: #005fcc;
  border: 2px solid #005fcc;
}

.btn-secondary:focus-visible {
  background: #e6f0ff;
  outline: 2px solid #003d99;
  outline-offset: 2px;
}

Inputs de Formulario

.form-input {
  border: 2px solid #ccc;
  padding: 0.75rem;
  border-radius: 4px;
  transition: border-color 0.15s, box-shadow 0.15s;
}

.form-input:focus-visible {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.2);
  outline: none;
}

/* Estado de error con foco */
.form-input.error:focus-visible {
  border-color: #dc3545;
  box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.2);
}

Checkboxes y Radios

/* Checkbox personalizado */
.checkbox-custom {
  width: 20px;
  height: 20px;
  border: 2px solid #666;
  border-radius: 3px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

.checkbox-input:focus-visible + .checkbox-custom {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.3);
}

/* Radio personalizado */
.radio-custom {
  width: 20px;
  height: 20px;
  border: 2px solid #666;
  border-radius: 50%;
}

.radio-input:focus-visible + .radio-custom {
  border-color: #005fcc;
  box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.3);
}

Cards y Contenedores Interactivos

.card-link {
  display: block;
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1.5rem;
  text-decoration: none;
  transition: box-shadow 0.2s, transform 0.2s;
}

.card-link:focus-visible {
  outline: none;
  border-color: #005fcc;
  box-shadow:
    0 0 0 3px rgba(0, 95, 204, 0.3),
    0 4px 12px rgba(0, 0, 0, 0.15);
  transform: translateY(-2px);
}

Manejo de Foco con Headers Sticky

El Problema

<header class="sticky-header">Navegación</header>
<main>
  <a href="#content">Enlace que queda oculto</a>
</main>

Al navegar con Tab hacia elementos cerca de la parte superior del viewport, los headers sticky pueden cubrirlos.

Solución: Scroll Margin

/* Agregar scroll margin a todos los elementos enfocables */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
  scroll-margin-top: 100px; /* Altura del sticky header + padding */
}

/* O aplicar a todos los elementos enfocables */
:focus-visible {
  scroll-margin-top: var(--header-height, 80px);
  scroll-margin-bottom: var(--footer-height, 0);
}

Solución: Scroll Padding en Contenedor

html {
  scroll-padding-top: 100px;
}

/* Con propiedad personalizada CSS */
:root {
  --header-height: 80px;
}

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

Integración con Frameworks

Patrón de Componente React

// FocusRing.tsx
import { forwardRef } from 'react'

interface FocusRingProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode
  offset?: number
}

export const FocusRing = forwardRef<HTMLDivElement, FocusRingProps>(
  ({ children, offset = 2, className, ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={`focus-ring-container ${className}`}
        style={{ '--focus-offset': `${offset}px` } as React.CSSProperties}
        {...props}
      >
        {children}
      </div>
    )
  }
)
.focus-ring-container:focus-visible {
  outline: 3px solid var(--focus-color, #005fcc);
  outline-offset: var(--focus-offset, 2px);
}

Composable Vue 3

<!-- FocusIndicator.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const props = defineProps<{
  color?: string
  offset?: number
}>()

const element = ref<HTMLElement | null>(null)
const isFocusVisible = ref(false)

function handleFocus() {
  isFocusVisible.value = true
}

function handleBlur() {
  isFocusVisible.value = false
}
</script>

<template>
  <div
    ref="element"
    :class="{ 'focus-visible': isFocusVisible }"
    :style="{
      '--focus-color': color || '#005fcc',
      '--focus-offset': `${offset || 2}px`
    }"
    @focus="handleFocus"
    @blur="handleBlur"
    tabindex="0"
  >
    <slot />
  </div>
</template>

<style scoped>
.focus-visible {
  outline: 3px solid var(--focus-color);
  outline-offset: var(--focus-offset);
}
</style>

Utilidades Tailwind CSS

/* Utilidades Tailwind personalizadas */
@layer utilities {
  .focus-ring {
    @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2;
  }

  .focus-ring-inset {
    @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset;
  }
}
<!-- Uso -->
<button class="focus-ring">Haz click</button>
<input class="focus-ring-inset" type="text" />

Probando Indicadores de Foco

Protocolo de Prueba Manual

  1. Prueba de navegación por teclado:

    • Desconecta ratón/trackpad
    • Navega con Tab por toda la página
    • Verifica que cada elemento enfocable sea visible
    • Comprueba que Shift+Tab funcione correctamente
  2. Prueba de contraste de foco:

    • Usa verificador de contraste de color en estado de foco
    • Verifica ratio 3:1 contra colores adyacentes
    • Prueba en temas claro y oscuro
  3. Prueba de elementos sticky:

    • Navega con Tab a elementos cerca de la parte superior de la página
    • Verifica que el foco no esté oculto detrás de headers
    • Comprueba interacciones con modales y overlays

Pruebas Automatizadas

// Test de Cypress para visibilidad de foco
describe('Indicadores de Foco', () => {
  it('debe mostrar foco visible en todos los elementos interactivos', () => {
    cy.visit('/')

    // Navegar por elementos enfocables
    cy.get('body').tab()
    cy.focused().should('have.css', 'outline-style', 'solid')

    // Verificar elementos específicos
    cy.get('a[href]').first().focus()
    cy.focused()
      .should('have.css', 'outline-width')
      .and('not.eq', '0px')
  })

  it('no debe ocultar el foco detrás del sticky header', () => {
    cy.visit('/')
    cy.get('.main-content a').first().focus()

    cy.focused().then($el => {
      const rect = $el[0].getBoundingClientRect()
      const headerHeight = 80

      expect(rect.top).to.be.greaterThan(headerHeight)
    })
  })
})

Linting de Accesibilidad

// Reglas del plugin ESLint jsx-a11y
module.exports = {
  rules: {
    'jsx-a11y/no-noninteractive-tabindex': 'error',
    'jsx-a11y/tabindex-no-positive': 'error',
  }
}

Errores Comunes y Soluciones

Error 1: outline:none Global

/* NO HACER */
*:focus {
  outline: none;
}

/* HACER */
*:focus:not(:focus-visible) {
  outline: none;
}

*:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Error 2: Olvidar Componentes Personalizados

/* NO olvidar elementos interactivos personalizados */
[role="button"]:focus-visible,
[role="link"]:focus-visible,
[tabindex="0"]:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Error 3: Indicadores de Bajo Contraste

/* NO HACER - bajo contraste */
:focus-visible {
  outline: 1px solid #ccc;
}

/* HACER - alto contraste */
:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

Resumen y Checklist

Checklist de Implementación de Foco

  • [ ] Todos los elementos interactivos tienen indicadores de foco visibles
  • [ ] Usando :focus-visible para foco solo de teclado
  • [ ] Indicadores de foco tienen ratio de contraste 3:1
  • [ ] El foco no está oscurecido por elementos sticky/fijos
  • [ ] Componentes personalizados incluyen estilos de foco
  • [ ] Inputs de formulario muestran estados de foco claros
  • [ ] El foco funciona en temas claro y oscuro
  • [ ] Probado con navegación solo de teclado

Referencia Rápida

Técnica Mejor Para Necesidades de Contraste
Outline + offset Todos los elementos 3:1 vs adyacente
Box-shadow Elementos redondeados 3:1 vs fondo
Cambio de borde Inputs de formulario 3:1 vs sin foco
Cambio de fondo Navegación 3:1 vs predeterminado

Referencias

  1. W3C - WCAG 2.2 SC 2.4.7 Focus Visible
  2. W3C - WCAG 2.2 SC 2.4.11 Focus Not Obscured
  3. W3C - Focus Appearance Understanding
  4. Sara Soueidan - A Guide to Designing Accessible Focus Indicators
  5. WebAIM - Keyboard Accessibility

Artículos relacionados

En la misma categoría

Introducción

Ai Crawlability Explained

A medida que asistentes de IA como ChatGPT, Claude, Gemini y Perplexity se convierten en fuentes primarias de información, surge una nueva pregunta: ¿Deberías...

Guía detallada

Ai Crawler Management Guide

Gestionar el acceso de rastreadores de IA requiere entender el diverso panorama de bots de IA, sus propósitos y los mecanismos técnicos para controlarlos

Introducción

Alt Text Explained

El texto alternativo (alt text) proporciona una descripción textual de las imágenes para usuarios que no pueden verlas

Guía detallada

Alt Text Implementation Guide

Esta guía completa cubre la implementación técnica de texto alternativo para todos los tipos de imágenes y contextos

Introducción

Alt Text Seo Explained

Cada imagen en tu sitio web está ayudando o perjudicando tu SEO

Guía detallada

Alt Text Seo Optimization Guide

La optimización de texto alt se encuentra en la intersección del SEO, la accesibilidad y la experiencia de usuario