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
-
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
-
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
-
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-visiblepara 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
- W3C - WCAG 2.2 SC 2.4.7 Focus Visible
- W3C - WCAG 2.2 SC 2.4.11 Focus Not Obscured
- W3C - Focus Appearance Understanding
- Sara Soueidan - A Guide to Designing Accessible Focus Indicators
- WebAIM - Keyboard Accessibility