:focus-visible y la heurística que decide por ti
Qué distingue exactamente a :focus-visible de :focus, cómo decide el navegador si el foco merece anillo, y cómo escribir un indicador que no se pueda romper.
outline: none es probablemente la declaración que más daño ha hecho a la accesibilidad de la web, y se escribió millones de veces por una razón legítima: el anillo del navegador aparecía al hacer clic con el ratón, donde nadie lo quería. :focus-visible resuelve la tensión de una forma que no tiene precedentes en el lenguaje, porque no describe un estado del elemento sino un juicio del navegador sobre el usuario. Y eso trae consecuencias que conviene entender antes de confiarle el indicador de foco de un producto.
- Enunciar la diferencia exacta entre
:focus,:focus-visibley:focus-within. - Reproducir la heurística que aplica el navegador, incluido el caso del foco programático.
- Resolver el hueco de
:focus-visible-withincon lo que ya sabes de:has(). - Escribir un indicador de foco que funcione con radios, con solapamientos y en modo de colores forzados.
Por qué :focus no bastaba
:focus encaja siempre que el elemento tiene el foco, venga de donde venga: del tabulador, de un clic, de un toque en la pantalla o de una llamada a focus() desde JavaScript. Es un estado puro, sin matices, y ahí está el problema.
Quien navega con teclado necesita ver dónde está el foco en todo momento; sin ese anillo, la interfaz es inutilizable. Quien acaba de hacer clic en un botón ya sabe perfectamente dónde está el foco, porque acaba de señalarlo con el dedo o el ratón, y el anillo le resulta ruido visual. Con solo :focus, ambos grupos recibían lo mismo, y el conflicto se resolvía sistemáticamente en contra del primero.
:focus-visible encaja únicamente cuando el navegador considera que el indicador de foco debe mostrarse. Es una pseudo-clase de política, no de estado. Y desde hace años, la hoja de estilos de usuario del navegador ya no dibuja su anillo con :focus sino con :focus-visible, así que el comportamiento correcto es el que obtienes sin escribir nada.
/* Lo que hace hoy el navegador por defecto, escrito a mano. */
:focus-visible {
outline: 2px solid Highlight;
outline-offset: 1px;
}
/* Y esto es lo que NUNCA debes escribir sin sustituto. */
:focus { outline: none; }
La heurística, con precisión
La especificación no impone un algoritmo cerrado, pero sí describe con detalle qué debe tener en cuenta el navegador, y los cuatro motores convergen en lo mismo. Cuatro reglas.
Si el foco llegó por teclado, se muestra. Tabulador, flechas, cualquier interacción de teclado que mueva el foco.
Si el elemento acepta entrada de texto, se muestra siempre. Un input de texto, un textarea, un elemento con contenteditable: aunque hayas llegado con el ratón, necesitas ver dónde va a aparecer lo que escribas. Por eso un campo de texto enfocado con clic sí tiene anillo y un botón enfocado con clic no.
Si el foco llegó por puntero a un elemento que no acepta texto, no se muestra. Botones, enlaces, casillas, elementos con tabindex.
Si el foco se movió por código, se hereda el estado anterior. Esta es la regla que sorprende. Cuando llamas a element.focus(), el navegador decide en función de si el elemento que tenía el foco antes estaba mostrando su indicador. Si el usuario venía navegando con el teclado, el elemento nuevo muestra anillo; si venía de un clic, no.
flowchart TB
A[Un elemento recibe el foco] --> B{Acepta entrada de texto}
B -- Si --> M[Mostrar el indicador]
B -- No --> C{Como llego el foco}
C -- Teclado --> M
C -- Puntero --> N[No mostrar el indicador]
C -- Llamada desde codigo --> D{El elemento anterior lo mostraba}
D -- Si --> M
D -- No --> N
style M fill:#a6e3a1,color:#11111b
style N fill:#f9e2af,color:#11111b
style D fill:#cba6f7,color:#11111bLa cuarta regla explica un comportamiento que parece un bug y no lo es: un diálogo que enfoca su primer control al abrirse muestra anillo si lo abriste con Enter y no lo muestra si lo abriste con un clic. Es exactamente lo que quieres, y lo consigues gratis. Si intentas forzarlo con clases, lo rompes.
La especificación de HTML define además una opción focusVisible en element.focus() para forzar el comportamiento en un sentido u otro, pero su soporte es desigual entre motores, así que no la uses como base de nada crítico: diseña para que la heurística por defecto sea correcta y trátala como una mejora opcional.
Como la decisión depende de la modalidad de entrada real, disparar un evento de teclado desde código no la reproduce. Un test que hace element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })) y después comprueba matches(':focus-visible') no está probando nada: el foco no se ha movido y la modalidad no ha cambiado. Los indicadores de foco se prueban con automatización de navegador que emule pulsaciones reales, o se prueban a mano. No hay atajo.
El vecindario: :focus-within y lo que falta
:focus-within encaja con un elemento que tiene el foco o que contiene al que lo tiene. Es la herramienta para resaltar el envoltorio de un campo cuando el usuario está escribiendo dentro.
.campo:focus-within { border-color: currentColor; }
.campo:focus-within > label { font-weight: 600; }
Lo que no existe es :focus-visible-within. Y a menudo es justo lo que quieres: resaltar el envoltorio solo cuando el indicador de foco debería verse, no cada vez que alguien hace clic dentro. El hueco se rellena con :has():
/* Equivalente a un :focus-visible-within que la plataforma no tiene. */
.campo:has(:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
Es un ejemplo perfecto de por qué :has() no es solo azúcar sintáctico: compone con el resto del lenguaje y rellena huecos que nadie había especificado.
El idioma inverso también hace falta a veces. Para quitar el anillo únicamente a quien llegó con el ratón, sin tocar a quien llegó con teclado, la forma correcta no es outline: none seguido de una regla que lo reponga, sino la intersección negada:
/* Solo desactiva el anillo en el caso concreto en que el navegador no lo mostraria. */
.boton:focus:not(:focus-visible) { outline: none; }
En la práctica ni siquiera hace falta, porque la hoja de estilos del navegador ya usa :focus-visible. Sirve cuando heredas un CSS antiguo que aplica estilos a :focus y quieres acotarlos sin reescribirlo entero.
Escribir un indicador que aguante
Tres detalles separan un anillo decorativo de uno que funciona en condiciones reales.
Sigue el radio. Desde hace varias versiones, outline respeta border-radius en los cuatro motores, así que no necesitas simularlo con box-shadow. Y a diferencia de box-shadow, outline no participa en el layout ni queda recortado por el overflow del padre.
Ponle separación y doble tono. Un anillo pegado al borde desaparece sobre fondos del mismo color. outline-offset lo despega, y un segundo anillo de color contrario garantiza contraste sobre cualquier fondo:
:focus-visible {
outline: 2px solid var(--anillo, currentColor);
outline-offset: 2px;
/* Segundo anillo de contraste, por si el fondo coincide con el primero. */
box-shadow: 0 0 0 4px canvas;
}
No lo pierdas en modo de colores forzados. Cuando el sistema operativo impone su paleta, muchos colores se sustituyen y algunas sombras desaparecen. La forma robusta es apoyarse en las palabras clave del sistema y comprobar el caso:
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid Highlight;
outline-offset: 2px;
box-shadow: none;
}
}
Y una cosa que no se puede negociar: el indicador tiene que ser visible sobre todos los fondos donde pueda aparecer el componente. Un anillo azul sobre una tarjeta azul no es un indicador, es una declaración de intenciones.
Todo lo que has visto hasta aquí —:nth-child(), :checked, :has()— describe hechos comprobables sobre el árbol: la posición de un nodo, el estado de un control, la existencia de un descendiente. Dos navegadores con el mismo DOM tienen que coincidir, y si no coinciden, uno tiene un bug. :focus-visible rompe esa propiedad deliberadamente: encapsula una heurística sobre la intención del usuario, y la especificación lo dice sin rodeos al describirla como orientación y no como algoritmo. Eso significa que dos navegadores pueden discrepar legítimamente, que el comportamiento puede cambiar entre versiones sin que sea una regresión, y que una preferencia del sistema operativo —“mostrar siempre el indicador de foco”— puede anular tu razonamiento entero. La reacción instintiva de un ingeniero ante algo así es intentar recuperar el control: detectar la modalidad de entrada a mano, poner una clase en html al primer keydown, replicar la lógica. Es exactamente el error. Toda esa maquinaria fue lo que se hizo durante años en forma de polyfill, y su defecto no era la implementación sino la premisa: el autor de la página no tiene la información necesaria para tomar esta decisión. No sabe si el usuario navega con un conmutador, con seguimiento ocular, con un teclado en pantalla o con una preferencia de accesibilidad activada en el sistema. El navegador sí. Ceder esa decisión no es perder control: es dejar de tomar una decisión que nunca estuviste en condiciones de tomar. Y por eso la única forma correcta de intervenir es cambiar cómo se ve el indicador, jamás cuándo aparece.
- Explica por qué un
inputde texto muestra anillo al hacer clic y un botón no. - Abre un diálogo con clic y con Enter y observa la diferencia en el primer control enfocado.
- Escribe el equivalente de
:focus-visible-withinpara un envoltorio de campo. - Comprueba que tu indicador sigue el
border-radiussin usarbox-shadow. - Activa el modo de colores forzados del sistema y verifica que el anillo sobrevive.