Si alguna vez has lanzado una interfaz “pulida” solo para descubrir que los usuarios de teclado no saben dónde están, te has topado con la avería silenciosa más frecuente en ingeniería web.
El sitio sigue cargando. Las métricas parecen correctas. Pero alguien pulsa Tab y la interfaz se convierte en una casa encantada: las puertas se abren, las luces están apagadas y no sabes
en qué habitación estás.
Los estados de foco son ingeniería de fiabilidad para personas. Son tu capa de observabilidad para la navegación por teclado. Y sí, pueden verse bien sin anular
la accesibilidad ni convertir el sistema de diseño en un accidente neón.
Lo que realmente hacen los estados de foco (y por qué te debería importar)
“Foco” es la forma en que el navegador dice: este elemento recibirá entrada del teclado si el usuario escribe o activa algo.
Para un usuario de ratón, el cursor es el canal de retroalimentación. Para un usuario de teclado, el indicador de foco es el canal de retroalimentación. Elimínalo y no “limpiaste la UI”;
eliminaste el único panel de control que tienen.
En términos de SRE, el estilo de foco es como el logging. Cuando está presente y claro, no lo notas. Cuando falta, pasas horas adivinando. Y como la mayoría de tu
equipo usa trackpad, no lo detectarás hasta que alguien con métodos de entrada diferentes (solo teclado, dispositivos con conmutadores, lectores de pantalla, usuarios avanzados) choque con un callejón sin salida.
Una implementación decente de foco hace tres cosas de forma fiable:
- Aparece cuando el usuario la necesita. Típicamente en la navegación por teclado, no en cada clic de ratón.
- Es visualmente obvia. No “técnicamente presente pero del mismo color que el fondo.” Obvia.
- Sigue el elemento interactivo real. No un div contenedor. No un hijo aleatorio. La cosa que se activa.
Además: foco no es lo mismo que hover. Hover dice “tu puntero está cerca de algo.” Foco dice “estás aquí.” Trátalo como un estado de primera clase.
Algunos datos históricos que vale la pena conocer
No necesitas memorizar la historia de las normas. Pero un poco de contexto te ayuda a entender por qué los navegadores se comportan como lo hacen, y por qué ciertos trucos CSS “ingeniosos”
son en realidad bombas de tiempo.
- Los primeros navegadores mostraban contornos de foco por defecto porque los teclados fueron un método principal de navegación mucho antes de que las páginas “pixel-perfect” dominaran todo.
- CSS1 no tenía estilos de foco sofisticados. El contorno de foco era principalmente una preocupación del UA (agente de usuario), y estaba hecho a propósito para que no fuera fácil eliminarlo por accidente.
- La era del “outline: none” explotó con el diseño plano. Los equipos eliminaron contornos para ajustar las maquetas, y luego olvidaron reemplazarlos. La web quedó más silenciosa y menos navegable.
- WCAG 2.0 (2008) exigía accesibilidad por teclado pero no prescribía una apariencia específica de foco, así que muchos equipos cumplieron “en papel” mientras seguían enviando foco invisible.
- :focus-visible surgió para resolver un conflicto UX real: los usuarios querían un anillo de foco para la navegación por teclado pero no para cada clic de ratón.
- Los navegadores usan heurísticas para la visibilidad del foco (modalidad de entrada, interacción reciente con teclado). Por eso tu anillo a veces aparece “aleatoriamente” si no conoces las reglas.
- Los modos de alto contraste cambiaron las reglas. Los colores forzados a nivel del SO pueden anular tu paleta, pero los contornos a menudo sobreviven. Si eliminas contornos, eliminas el indicador más resistente.
- WCAG moderno (2.2) endureció las expectativas sobre el foco con requisitos más claros sobre la apariencia y visibilidad del foco, aumentando el costo de los indicadores “mínimos”.
- Demandas por accesibilidad llevaron los estilos de foco a las salas de juntas. La forma más rápida de obtener presupuesto para arreglos de foco es, desafortunadamente, una carta legal y un cliente enfadado.
Los tres pilares: :focus-visible, enlaces de salto y buenos contornos
Si quieres un sistema de foco que sobreviva tráfico real, dispositivos reales y personas reales, haz estas tres cosas y deja de improvisar:
- Usa
:focus-visiblepara mostrar un indicador potente en la navegación por teclado sin añadir ruido visual en los clics de ratón. - Proporciona un enlace de salto para que los usuarios de teclado puedan saltarse la navegación repetida y llegar rápidamente al contenido principal.
- Usa contornos (o anillos tipo contorno) que tengan suficiente contraste, no se corten y no dependan de cambios de color sutiles.
Puedes ponerte creativo después. Si no tienes estos tres, tu historia de accesibilidad es mayormente apariencia.
:focus-visible: el valor por defecto sensato
El mundo antiguo era binario: :focus siempre se mostraba, o lo eliminabas por completo y esperabas que nadie lo notara. El nuevo mundo usa
:focus-visible para mostrar indicadores de foco solo cuando es probable que el usuario esté navegando con teclado (o similar).
La regla práctica:
:focuses el estado: el elemento tiene el foco.:focus-visiblees la pista de presentación: muestra el anillo cuando el navegador cree que es necesario.
CSS básico que funciona en producción
Aquí es donde los diseñadores se ponen nerviosos y los ingenieros recurren a una hoja de restablecimiento. Relajaos. Usa un anillo coherente y aplícalo a los controles interactivos.
cr0x@server:~$ cat focus.css
:root {
--focus-ring-color: #1b6ef3;
--focus-ring-offset: 3px;
--focus-ring-width: 3px;
}
/* Default: no special ring on mouse click */
:where(a, button, input, select, textarea, summary, [tabindex]):focus {
outline: none;
}
/* Keyboard-visible ring */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
Lo que hace esto:
- Elimina el contorno por defecto en
:focuspara que los clics de ratón no dejen anillos por todas partes. - Añade un contorno claro en
:focus-visiblepara que la navegación por teclado siempre tenga un marcador visible.
¿Por qué el :where()? Mantiene la especificidad baja. Puedes sobrescribirlo en el CSS de componentes sin entrar en una pelea de cascada.
Si estás pensando “pero eliminar outline es malo,” tienes razón en abstracto. Solo es aceptable cuando añades un indicador fuerte en :focus-visible.
El modo de fallo es eliminar contornos y luego olvidar el reemplazo, que es como la mitad de internet terminó enviando foco en modo sigiloso.
Comprobación de soporte en navegadores
La mayoría de los navegadores modernos soportan :focus-visible. Pero los sistemas en producción viven el tiempo suficiente para encontrarse con clientes extraños.
Si necesitas una solución alternativa, puedes estilizar :focus y luego suprimirlo en interacciones con puntero con un pequeño script, o usar un patrón de polyfill.
Mantén la alternativa mínima y pruébala. No construyas un marco propio de detección de modalidad; lo harás mal y no disfrutarás depurándolo.
Una cita para mantenerte honesto
La esperanza no es una estrategia.
— General Gordon R. Sullivan
Esa frase aplica a los estados de foco más de lo que cualquiera quiere admitir. “Probablemente los usuarios usan un ratón” es esperanza. “Probamos la navegación por teclado” es estrategia.
Enlaces de salto que no te avergüenzan
Los enlaces de salto son la victoria de accesibilidad más barata que puedes enviar. También son una gran prueba de fuego: si tu organización no puede acordar añadir un enlace de salto,
lo vas a pasar mal con cualquier cosa más difícil.
El enlace de salto resuelve un dolor específico: la navegación repetida. En un sitio con mucho contenido, el encabezado puede contener docenas de elementos tabbables (enlace del logo, navegación, búsqueda, menú de usuario).
Los usuarios de teclado no deberían tener que pulsar Tab por todo eso en cada página solo para llegar al contenido principal.
Patrón de marcado
cr0x@server:~$ cat skip-link.html
Skip to main content
Detalles clave:
- El enlace de salto debe ser el primero o cercano al primero en el orden del DOM, para que sea alcanzable inmediatamente.
href="#main"debe apuntar a un elemento real que exista en cada página que use el patrón.tabindex="-1"en<main>permite mover el foco programáticamente allí en navegadores que no enfocan elementos no interactivos por defecto.
CSS que lo oculta hasta que está enfocado
cr0x@server:~$ cat skip-link.css
.skip-link {
position: absolute;
top: 0;
left: 0;
padding: 0.75rem 1rem;
transform: translateY(-120%);
background: #111;
color: #fff;
z-index: 9999;
}
.skip-link:focus-visible {
transform: translateY(0);
outline: 3px solid #fff;
outline-offset: 2px;
}
Este es el tipo correcto de “oculto”: está visualmente fuera de pantalla pero se vuelve visible al recibir foco. No uses display:none ni visibility:hidden.
Esos lo eliminan del árbol de accesibilidad y de la navegación por teclado. Eso no es “oculto”, es “borrado”.
Broma #1: La forma más rápida de encontrar un enlace de salto faltante es pasar por un mega-menú con Tab una vez — de repente te conviertes en un interesado en accesibilidad.
Contornos que se ven bien (y sobreviven al modo oscuro)
Los anillos de foco no tienen que ser feos. Tienen que ser visibles. Esas son dos cosas diferentes.
“El contorno se ve feo” suele ser un eufemismo por “el anillo no encaja con el sistema de diseño.” Está bien. Haz que encaje. Pero no lo debilites hasta la invisibilidad.
Si un diseñador quiere un anillo gris claro de 1px sobre un fondo blanco, tu trabajo es decir “no” con educación y enviar algo que los usuarios puedan ver.
Outline vs box-shadow vs anillos híbridos
Tienes tres enfoques comunes:
outline: Simple, resistente, funciona en colores forzados, no afecta el layout. El clásico por una razón.box-shadow: Visuales más flexibles (resplandor, bordes difuminados), pero puede ser recortado poroverflow:hiddeny puede desaparecer en colores forzados.- Híbrido: Usa
outlinepara compatibilidad con colores forzados, añadebox-shadowpara estética.
Un anillo que se ve moderno pero sigue siendo legible
cr0x@server:~$ cat ring.css
:root {
--ring: #2563eb;
--ring-outer: color-mix(in srgb, var(--ring) 30%, transparent);
}
:where(a, button, input, select, textarea):focus-visible {
outline: 3px solid var(--ring);
outline-offset: 3px;
box-shadow: 0 0 0 6px var(--ring-outer);
border-radius: 6px;
}
Decisiones incrustadas en ese CSS:
- Ancho 3px suele ser visible en fondos comunes.
- Offset 3px evita que el anillo se mezcle con los bordes y se vea intencional.
- Un halo externo suave ayuda en fondos ocupados sin requerir un anillo neón.
Modo oscuro: no solo inviertas colores
En modo oscuro, un anillo azul saturado aún puede funcionar, pero debes comprobar el contraste contra los píxeles circundantes inmediatos, no solo el fondo de la página.
Los anillos de foco a menudo están sobre tarjetas, chips y superficies apiladas. El anillo debe ser visible en todas ellas.
cr0x@server:~$ cat dark-mode.css
@media (prefers-color-scheme: dark) {
:root {
--ring: #93c5fd;
}
}
Elige un color de anillo que funcione en múltiples superficies. Si tu aplicación usa varias elevaciones de fondo, considera usar un anillo de doble color (interno + externo) para garantizar la visibilidad.
Colores forzados / modo de alto contraste
Cuando el SO fuerza colores, tu paleta cuidadosamente elegida puede ser ignorada. Los contornos tienen más probabilidad de permanecer visibles.
Súpórtalo explícitamente:
cr0x@server:~$ cat forced-colors.css
@media (forced-colors: active) {
:where(a, button, input, select, textarea):focus-visible {
outline: 2px solid CanvasText;
outline-offset: 2px;
box-shadow: none;
}
}
La decisión aquí es simple: en colores forzados, prioriza la fiabilidad sobre el estilo. Tu color de marca no es más importante que que alguien pueda usar tu producto.
Componentes que rompen el foco (y cómo detenerlos)
Los culpables habituales
- Botones personalizados hechos con divs con manejadores de clic, sin soporte de teclado y sin estilos de foco.
- Reinicios CSS demasiado agresivos que eliminan contornos globalmente, incluso en widgets de terceros.
- Contenedores con
overflow:hiddenque recortan anillos de foco basados en box-shadow. - Modales y cajones que atrapan el foco incorrectamente o no restauran el foco al cerrar.
- Cambios de ruta en SPA que mueven contenido sin mover el foco, dejando a los usuarios de teclado “en algún lugar” del DOM antiguo.
No inventes nuevos elementos interactivos
Usa elementos nativos siempre que sea posible. Un <button> te da activación por teclado, enfocabilidad, semántica y comportamiento gratis.
Rehacer eso con un div es como reimplementar TCP porque quieres “más control”.
Gestión del foco: las reglas aburridas que te mantienen fuera de problemas
- Al abrir un modal: mover el foco al modal (usualmente su encabezado o el primer campo).
- Mientras el modal esté abierto: atrapar el foco dentro del modal (recorrer el orden de tabulación).
- Al cerrar el modal: restaurar el foco al control que lo abrió.
Si omites el paso de restauración, los usuarios de teclado “se caen” del flujo y pierden su lugar. Eso no es un problema menor de UX; es una falla funcional.
Guía rápida de diagnóstico
Cuando el foco está “roto”, los equipos tienden a agitarse. Alguien culpa al CSS. Alguien culpa al navegador. Alguien propone reescribir.
No. Diagnostica como lo harías con una regresión de latencia: aisla, reproduce, identifica la capa.
Primero: ¿el foco realmente se mueve?
- Pulsa Tab desde la parte superior de la página.
- Mira la barra de URL y la página; observa si el foco entra en el documento.
- Si parece que no pasa nada, comprueba si la página está tragando eventos de teclado.
Segundo: ¿falta el indicador o está solo invisible?
- Usa DevTools para inspeccionar el elemento actualmente enfocado (
:focus). - Revisa estilos calculados: outline, box-shadow, cambio de fondo.
- Busca
outline: noneproveniente de un reset o clase base de componente.
Tercero: ¿se está robando o atrapando el foco?
- Abre y cierra modales. ¿Vuelve el foco al disparador?
- En SPAs, navega rutas y comprueba si el foco se mueve a un encabezado significativo.
- Busca atributos
autofocusdescontrolados y scripts que llamen afocus()en temporizadores.
Cuarto: ¿el orden de tabulación es sensato?
- Revisa si hay valores de tabindex positivos (
tabindex="1", etc.). - Busca elementos ocultos pero tabbables.
- Confirma que controles deshabilitados no sigan siendo enfocables (común con componentes personalizados).
Quinto: ¿modos especiales lo rompen?
- Prueba en modo de colores forzados.
- Prueba con zoom al 200%.
- Prueba en modo oscuro si está soportado.
Broma #2: Un anillo de foco que solo aparece en la pantalla del diseñador no es una “expresión de marca”, es un punto ciego de monitorización.
Tareas prácticas: comandos, salidas, decisiones
Estas están pensadas para ejecutarse por alguien que tiene un código, un build y sentido de urgencia. Cada tarea incluye un comando, qué significa la salida típica,
y la decisión que tomas a partir de ello.
Tarea 1: Encontrar eliminación global de outline en CSS
cr0x@server:~$ rg -n "outline\s*:\s*none" web/ styles/
styles/reset.css:42:*:focus{outline:none}
web/components/button.css:18:.btn:focus{outline:none}
Qué significa la salida: Tienes al menos dos reglas que eliminan contornos, una global. La global es el incendiario habitual.
Decisión: Sustituye la eliminación global por una estrategia con :focus-visible y asegura que cada elemento interactivo tenga un indicador visible.
Tarea 2: Buscar cobertura de focus-visible (o su ausencia)
cr0x@server:~$ rg -n ":focus-visible" web/ styles/
styles/focus.css:12::where(a, button, input):focus-visible { outline: 3px solid var(--ring); }
Qué significa la salida: Tienes una regla focus-visible. Buen comienzo, pero comprueba si realmente se aplica a todos los componentes y no está sobrescrita.
Decisión: Asegura que el selector incluya todos los controles interactivos relevantes y mantén baja la especificidad para que los estilos de componentes puedan extenderlo en vez de pelear con él.
Tarea 3: Identificar elementos que usan tabindex positivo
cr0x@server:~$ rg -n "tabindex\s*=\s*\"[1-9]" web/
web/pages/legacy-dashboard.html:88:
web/pages/legacy-dashboard.html:101:
Qué significa la salida: Se está usando tabindex positivo para forzar un orden de tabulación personalizado. Eso suele crear navegación impredecible entre navegadores y lectores.
Decisión: Refactorizar al orden DOM con tabindex="0" solo cuando sea necesario. Trata el tabindex positivo como un bug salvo que haya una razón muy específica y probada.
Tarea 4: Localizar divs que fingen ser botones
cr0x@server:~$ rg -n "role\s*=\s*\"button\"" web/
web/components/filters.html:14:
web/components/menu.html:55:
Qué significa la salida: Tienes elementos interactivos personalizados. Estos requieren manejadores de activación por teclado, estilos de foco y ARIA correctas.
Decisión: Reemplaza por <button> donde sea posible. Si no, confirma activación con Enter/Espacio, estilos focus-visible y estados ARIA correctos.
Tarea 5: Comprobar overflow que corta anillos de foco
cr0x@server:~$ rg -n "overflow:\s*hidden" web/components styles
web/components/card.css:7:.card{overflow:hidden;border-radius:12px}
web/components/modal.css:22:.modal-body{overflow:hidden}
Qué significa la salida: Cualquier indicador de foco implementado con box-shadow puede recortarse dentro de estos contenedores.
Decisión: Prefiere outline (no recortado) o ajusta la estrategia de overflow del contenedor, o añade un anillo interno que no dependa de sombras externas.
Tarea 6: Detectar presencia de enlace de salto en plantillas
cr0x@server:~$ rg -n "Skip to main content" web/
web/layouts/base.html:3:Skip to main content
Qué significa la salida: El enlace de salto existe en el layout base. Ahora verifica que no lo eliminen los layouts específicos de página y que #main exista en todas.
Decisión: Añade una prueba en CI para que falle la compilación si falta #main en el HTML renderizado o si falta el texto del enlace de salto.
Tarea 7: Verificar que el objetivo main existe en todas las páginas
cr0x@server:~$ rg -n 'id="main"' web/pages
web/pages/home.html:12:
web/pages/pricing.html:9:
web/pages/blog.html:15:
Qué significa la salida: Una página usa un <div id="main"> en lugar de <main> y puede que no sea enfocables.
Decisión: Estandariza en <main id="main" tabindex="-1"> en todas las páginas, o asegura que el objetivo pueda recibir foco de forma fiable.
Tarea 8: Ejecutar una suite de pruebas de accesibilidad localmente (Playwright)
cr0x@server:~$ npm test
> webapp@1.0.0 test
> playwright test
Running 18 tests using 4 workers
✓ a11y: skip link is reachable (1.2s)
✗ a11y: focus indicator visible on buttons (2.0s)
Error: expected focus ring to be visible on .btn-primary, but computed outline-style was 'none'
Qué significa la salida: La prueba detectó que tu botón principal no tiene un contorno visible al recibir foco.
Decisión: Arregla la anulación CSS del componente y conserva la prueba. No la marques como inestable. Un anillo de foco no es una funcionalidad opcional.
Tarea 9: Identificar peleas de especificidad que afectan focus-visible
cr0x@server:~$ rg -n "\.btn.*:focus" web/components/button.css
18:.btn:focus{outline:none}
24:.btn-primary:focus-visible{outline:none;box-shadow:none}
Qué significa la salida: El CSS del componente está eliminando explícitamente el estilo focus-visible. Esta es la firma de “alguien quiso que desapareciera”.
Decisión: Elimina esas reglas, o reemplázalas por un estilo focus-visible conforme. Si el diseño quiere un estilo personalizado, perfecto—fusiona uno visible, no nada.
Tarea 10: Buscar uso descontrolado de autofocus
cr0x@server:~$ rg -n "\bautofocus\b" web/
web/pages/login.html:22:
web/components/modal.html:8:
Qué significa la salida: Autofocus puede robar el foco inesperadamente, especialmente cuando montas modales o cambian rutas.
Decisión: Mantén autofocus solo donde sea claramente beneficioso (el campo de login suele estar bien). Reemplaza autofocus en modales por gestión explícita de foco al abrir.
Tarea 11: Validar que el objetivo del enlace de salto es enfocables en tiempo de ejecución (chequeo Node simple)
cr0x@server:~$ node -e "const fs=require('fs');const html=fs.readFileSync('dist/pricing.html','utf8');console.log(/id=\"main\"/.test(html), /tabindex=\"-1\"/.test(html));"
true true
Qué significa la salida: El HTML renderizado contiene id="main" y tabindex="-1".
Decisión: Añade este tipo de comprobación en CI para plantillas que varían. Es tosca, pero efectiva para atrapar regresiones.
Tarea 12: Inspeccionar el CSS compilado por eliminación accidental de contornos
cr0x@server:~$ rg -n "outline:none" dist/assets/app.css | head
1882:*:focus{outline:none}
45110:.btn:focus{outline:none}
Qué significa la salida: Tu salida de build aún incluye eliminación global de contornos. Incluso si el código fuente parece correcto, una dependencia o paso de build puede reintroducirlo.
Decisión: Arregla en la fuente (hoja de restablecimiento, anulación de dependencia o pipeline de build). Luego añade una comprobación en tiempo de compilación que falle si *:focus{outline:none} aparece.
Tarea 13: Ejecutar Lighthouse CI e interpretar la falla relacionada con foco
cr0x@server:~$ npx lhci autorun
✅ .lighthouseci/ collected 1 run(s)
⚠️ Accessibility score: 0.92
- [fail] Background and foreground colors do not have a sufficient contrast ratio.
- [warn] Interactive elements do not have a focus indicator.
Qué significa la salida: Herramientas automatizadas están señalando problemas con el indicador de foco. Puede que no identifiquen el componente exacto, pero es una señal de que tu base no es fiable.
Decisión: Usa esto como puerta en CI. Luego suplementa con pruebas de teclado dirigidas en flujos críticos (checkout, login, acciones de admin).
Tarea 14: Usar Git para localizar cuándo se rompió el foco
cr0x@server:~$ git log -p -S "outline:none" -- web/styles/reset.css | head -n 20
commit 7c2a1b9d3d2c1a4b9d0c1f8e3a2b7f3c1d9a8b7c
Author: dev
Date: Tue May 14 10:22:11 2024 -0700
Align focus styles with new design system
diff --git a/web/styles/reset.css b/web/styles/reset.css
@@ -39,6 +39,7 @@
* { box-sizing: border-box; }
-*:focus { outline: auto; }
+*:focus { outline: none; }
Qué significa la salida: Un commit eliminó intencionalmente contornos. El mensaje sugiere alineación con el diseño, pero el diff muestra una regresión de accesibilidad.
Decisión: Revertir o enmendar con el estilo apropiado de :focus-visible. También añade elementos en la lista de revisión para que “alineación de diseño” no sea una excusa general.
Tres micro-historias corporativas desde el frente
Micro-historia 1: El incidente causado por una suposición equivocada
Una compañía SaaS mediana implementó un nuevo encabezado de navegación: mega-menú, selector de producto, notificaciones, el típico aspecto de “ya crecimos”.
El equipo lo lanzó detrás de una feature flag, hizo una prueba rápida y lo dio por terminado.
La suposición equivocada: “Si funciona con ratón, funciona.” Habían eliminado el contorno por defecto globalmente años antes, y la UI antigua tenía estilos de foco personalizados
en un puñado de componentes. El nuevo encabezado usaba un dropdown de terceros y un componente “pill” casero. Ninguno tenía estilo focus-visible.
El primer reporte no vino de una auditoría de accesibilidad. Vino de soporte empresarial: la política interna de un cliente requería navegación por teclado
para ciertos flujos, y el cliente no pudo completar una acción administrativa crítica sin perder su lugar en el encabezado.
El equipo de ingeniería lo reprodujo en minutos: tabular en el encabezado funcionaba, pero nada estaba visiblemente enfocado. La gente empezó a hacer clic a lo loco para recuperarse.
La UI “funcionaba”, pero era efectivamente inoperable solo con teclado. Eso es una caída si el método de entrada de tu usuario es el teclado.
La solución fue brutalmente simple: enviar un anillo base :focus-visible en todos los elementos interactivos, y luego refinar componente por componente.
La lección quedó clara: no puedes depender de bibliotecas de componentes si saboteas el foco globalmente.
Micro-historia 2: La optimización que salió mal
Otra empresa tenía un mandato de rendimiento. Su bundle frontend estaba hinchado, así que introdujeron un paso agresivo de purge CSS y un sprint de “modernizar todo”.
La configuración del purge se afinó para mantener solo selectores detectados en plantillas en tiempo de build.
El fallo: los estilos focus-visible estaban definidos en una hoja compartida y aplicados vía selectores :where(), más un conjunto pequeño de utilidades que
aparecían dinámicamente (modales montados, código dividido por rutas). El purge no “vio” esos selectores en el análisis estático. Los eliminó.
En producción, los usuarios de teclado empezaron a reportar comportamiento extraño: algunas páginas tenían anillos de foco y otras no. Algunos modales estaban bien, otros invisibles.
Parecía aleatorio, lo peor porque genera folklore y soluciones cargo-cult.
Depurar tomó más tiempo del debido porque el equipo inicialmente culpó a peculiaridades del navegador. El problema real era el pipeline que eliminó CSS crítico.
Una vez compararon el CSS compilado con el fuente, fue obvio.
La solución: safelist de selectores focus-visible y cualquier patrón de clase usado para anillos de foco. Luego añadir una comprobación automatizada para la presencia del CSS base de foco
en el artefacto final. El rendimiento importa, pero no a costa de quitar la capacidad de navegar a tus usuarios.
Micro-historia 3: La práctica aburrida pero correcta que salvó el día
Un gran equipo de herramientas internas tenía un hábito poco glamuroso: cada nuevo componente tenía que pasar una prueba de humo por teclado antes de mergear.
No era un proceso formal grande. Solo un checklist en la plantilla del PR: Tabular, Shift+Tab, activar con Enter/Espacio, confirmar que el anillo de foco sea visible,
y verificar que el orden de tabulación tenga sentido.
Durante un refactor, reemplazaron un select nativo por un “dropdown buscable” personalizado. Lucía genial. También introdujo una trampa de foco sutil: al abrir el dropdown
el foco se movía a un listbox, pero al cerrarlo no volvía al disparador. Los usuarios de teclado acababan “detrás” de la UI, tabulando por elementos ocultos.
El desarrollador lo detectó antes de la revisión porque el checklist le hizo probar el flujo con Tab. Lo arregló guardando explícitamente el elemento disparador, moviendo el foco
al dropdown al abrir y restaurándolo al cerrar.
Sin incidente. Sin tickets enfadados. Sin escalada ejecutiva. Solo una práctica pequeña y aburrida evitando que una falla en cámara lenta se enviara.
En operaciones veneramos los sistemas aburridos porque son predecibles. La accesibilidad por teclado es lo mismo. Hazla aburrida. Hazla estándar.
Errores comunes: síntomas → causa raíz → solución
1) Síntoma: “Tab funciona, pero no veo dónde estoy.”
Causa raíz: Eliminación global de contornos (*:focus{outline:none}) sin un reemplazo visible, o un color de anillo demasiado cercano al fondo.
Solución: Implementar un anillo base con :focus-visible con contraste y offset suficientes; eliminar la supresión global de contornos o acotarla cuidadosamente.
2) Síntoma: “El anillo de foco aparece al hacer clic y a los diseñadores les molesta.”
Causa raíz: Estilizar :focus en lugar de :focus-visible, o navegadores que tratan la interacción como tipo teclado debido a heurísticas de modalidad.
Solución: Mover el anillo visible a :focus-visible. Mantén un :focus mínimo solo si es necesario como fallback.
3) Síntoma: “Algunos botones muestran foco y otros no.”
Causa raíz: Anulaciones a nivel de componente que eliminan outline/box-shadow; conflictos de especificidad; purge CSS que elimina selectores base.
Solución: Auditar CSS de componentes en busca de outline:none; mantener la regla base de foco con baja especificidad; safelist de selectores en purge; añadir cheques en build.
4) Síntoma: “El anillo de foco se corta.”
Causa raíz: Anillos basados en box-shadow recortados por overflow:hidden o contenedores de scroll.
Solución: Usar outline para el anillo principal; aumentar outline-offset; cambiar la estrategia de overflow del contenedor o añadir padding para evitar el recorte.
5) Síntoma: “El enlace de salto existe pero no hace nada.”
Causa raíz: Objetivo del ancla ausente, IDs duplicados o el elemento objetivo no es enfocables en algunos navegadores.
Solución: Asegurar que id="main" exista exactamente una vez; añadir tabindex="-1" al objetivo; confirmar que está presente en todas las plantillas.
6) Síntoma: “Los usuarios de teclado quedan atrapados en un modal.”
Causa raíz: Implementación rota de focus trap, o el orden de tabulación incluye elementos ocultos fuera del modal.
Solución: Implementar un focus trap probado; deshabilitar scroll e interacción de fondo; marcar el fondo como inert si está soportado; restaurar foco al cerrar.
7) Síntoma: “Tras un cambio de ruta, el foco se pierde o queda en la UI antigua.”
Causa raíz: La navegación SPA cambia contenido sin mover el foco; el foco queda en un elemento eliminado o en un ítem de navegación.
Solución: En el cambio de ruta, mover el foco a un encabezado significativo o al contenedor principal (con tabindex="-1"). Mantener consistencia entre rutas.
8) Síntoma: “El lector de pantalla anuncia cosas raras; el comportamiento del teclado es inconsistente.”
Causa raíz: Elementos interactivos personalizados sin semántica; roles/estados ARIA incorrectos; mezclar role="button" con enlaces anidados, etc.
Solución: Preferir controles nativos. Si son personalizados, implementar eventos de teclado correctos, estados ARIA y asegurar que el estilo de foco se aplique al nodo enfocables real.
Checklists / plan paso a paso
Paso a paso: enviar una base fiable en un sprint
- Inventario de supresión de foco. Busca
outline:noneybox-shadow:noneen estados de foco. Elimina o justifica cada uno. - Añadir una regla base
:focus-visible. Cubre anclas, botones, campos de formulario y cualquier cosa con tabindex. - Definir tokens de anillo. Elige color(es) de anillo, ancho y offset como variables raíz. Hazlos conscientes del tema.
- Añadir enlace de salto al layout base. Asegura que
#mainexista en todas las páginas y sea enfocables contabindex="-1". - Parchar los 10 componentes principales. Botones, enlaces, inputs, selects, menús, pestañas, chips, toggles, modales y dropdowns.
- Probar flujos críticos solo con teclado. Login, checkout, cambios de configuración, acciones destructivas y todo lo que pueda bloquear a un usuario.
- Cubrir colores forzados y modo oscuro. Añadir
@media (forced-colors: active)y verificar la visibilidad del anillo en tema oscuro. - Añadir cheques en CI. Fallar builds si falta el CSS base de foco en el artefacto o si falta el enlace de salto / objetivo main.
Checklist de aceptación de navegación por teclado (lista lista para PR)
- El orden de tabulación sigue el orden visual (o al menos no sorprende).
- No hay valores tabindex positivos sin una razón escrita y pruebas.
- Cada elemento interactivo tiene un indicador de foco visible en la navegación por teclado.
- El enlace de salto es el primer elemento alcanzable y funciona.
- Los modales atrapan el foco y restauran el foco al disparador al cerrar.
- Los dropdowns y menús soportan Escape para cerrar y devolver el foco.
- El anillo de foco no está recortado por estilos de contenedor.
- El modo de colores forzados todavía muestra el foco claramente.
Checklist del sistema de diseño: “bonito” sin romper a los usuarios
- Ancho de anillo ≥ 2px en la mayoría de contextos; 3px es más seguro.
- El offset del anillo lo hace distinto de los bordes.
- Contraste del color del anillo probado en todas las superficies (tarjetas, banners, inputs, estados semi-deshabilitados).
- No confiar únicamente en cambios de color dentro del elemento (como cambiar el fondo en 5%).
- Preferir outline (o incluir outline) para resiliencia en colores forzados.
Preguntas frecuentes
1) ¿Debería usar outline: none alguna vez?
Sí, pero solo con un reemplazo visible para la navegación por teclado. El patrón seguro es: eliminar el outline por defecto en :focus, añadir un estilo fuerte en :focus-visible.
Si no puedes garantizar el reemplazo en todos los componentes, no lo elimines globalmente.
2) ¿Por qué :focus-visible a veces se muestra al hacer clic con el ratón?
Los navegadores usan heurísticas. Si usaste teclado recientemente, o interactúas con un control donde la indicación de foco ayuda (como inputs de texto),
el navegador puede decidir que el foco debe ser “visible”. No luches demasiado contra eso. El objetivo es usabilidad, no pureza estética.
3) ¿Un cambio sutil de color de fondo es suficiente como indicador de foco?
Normalmente no. Rellenos sutiles fallan en fondos ocupados, modo oscuro y pantallas de baja calidad. Usa un anillo que sea claramente visible y sobreviva a colores forzados.
Piensa “obvio”, no “con gusto”.
4) ¿Por qué mi anillo de box-shadow desaparece en algunos componentes?
Porque algo en el layout lo está recortando: overflow:hidden, contenedores con scroll o contextos de apilamiento. Usa outline como anillo principal,
o asegura que haya suficiente espacio y nada que recorte alrededor de los elementos enfocados.
5) ¿Los enlaces de salto importan en aplicaciones single-page?
Sí. Las SPAs suelen tener encabezados persistentes y regiones de contenido dinámico. Un enlace de salto más gestión consistente del foco en cambios de ruta hace que la app se sienta estable para usuarios de teclado.
6) ¿A dónde debería ir el foco después de un cambio de ruta?
Normalmente al encabezado principal (como el <h1> de la página) o al contenedor principal. Haz que el objetivo sea enfocables con tabindex="-1" y mueve el foco intencionalmente.
No dejes el foco en el elemento de navegación que disparó el cambio; así es como los usuarios pierden el contexto.
7) ¿Cuál es el problema con tabindex="5" y similares?
tabindex positivo crea un orden de tabulación separado que puede volverse inconsistente y frágil cuando el DOM cambia. Además suele romper expectativas de tecnologías asistivas.
Prefiere el orden DOM y tabindex="0" solo cuando debas hacer enfocables elementos no nativos.
8) ¿Cómo hago que el estilo de foco sea consistente en un sistema de diseño?
Define tokens de anillo de foco (color, ancho, offset) en la raíz, aplica una regla base de baja especificidad, y permite que los componentes amplíen en lugar de sobrescribir.
Añade cheques en CI que aseguren que existen reglas focus-visible en los artefactos finales.
9) ¿Necesito un anillo de foco en cada elemento?
Solo en elementos que puedan recibir foco: controles interactivos y cualquier cosa que hiciste enfocables (enlaces, botones, inputs, widgets personalizados con tabindex).
No hagas contenedores aleatorios enfocables solo para “igualar el hover”. Eso crea ruido y fatiga de tabulación.
10) ¿Y si el diseño quiere un estilo de foco personalizado por componente?
Perfecto. La restricción es visibilidad y consistencia. Mantén un anillo base como fallback y luego mejora. En el momento en que los estilos personalizados empiezan a eliminar el anillo,
estás de nuevo en zona de avería.
Conclusión: próximos pasos que puedes enviar esta semana
Los estados de foco accesibles no son un “extra”. Son infraestructura de interacción básica. Trátalos como tratas TLS: establécelos, hazlos cumplir y no dejes que componentes aleatorios
se salten porque a alguien no le gusta cómo se ve en una captura.
Próximos pasos prácticos:
- Implementa un anillo base
:focus-visibleusandooutlinecon offset, más un halo opcional para estética. - Añade un enlace de salto en el layout base y estandariza
<main id="main" tabindex="-1">en todas las páginas. - Elimina la supresión global de foco a menos que la reemplaces correctamente.
- Parchea los componentes que anulan focus-visible y añade pruebas para mantener la integridad.
- Ejecuta la guía rápida de diagnóstico cada vez que alguien diga “el teclado está raro”. Usualmente no es raro; está roto.