Tienes un componente de pestañas con CSS Modules. La pestaña activa se ve bien (texto claro sobre fondo de color), pero en cuanto pasas el mouse por encima, el texto desaparece y queda solo un bloque de color liso. No hay ningún error en la consola: el síntoma es puramente visual. Este es el patrón de CSS que lo causa:
/* Tabs.module.css */
.tab {
color: var(--color-muted);
background: transparent;
}
.tab:hover {
color: var(--color-primary);
}
.tabActive {
color: var(--color-on-primary);
background: var(--color-primary);
}
En JSX, la pestaña activa recibe las dos clases, por ejemplo className={`${styles.tab} ${activa ? styles.tabActive : ''}`}. Aunque .tabActive aparece después en el archivo, al pasar el mouse el color del texto cambia al color primario, el mismo del fondo.
Por qué pasa
Cuando dos reglas de la misma hoja de estilos compiten por la misma propiedad, gana la de mayor especificidad; el orden en el archivo solo desempata cuando la especificidad es igual. MDN explica que la especificidad se compara en tres columnas (identificadores, clases y tipos), y que las clases, los atributos y las pseudoclases como :hover suman lo mismo en la columna central. En este caso:
.tab:hoveres una clase más una pseudoclase: especificidad0-2-0..tabActivees una sola clase: especificidad0-1-0.
Para la propiedad color, la regla de :hover gana a la de la clase activa, aunque esta última esté más abajo. El background no tiene conflicto (solo lo define .tabActive), así que se mantiene. El resultado es texto en color primario sobre fondo en color primario. Con colores idénticos, la relación de contraste es de 1:1; el criterio 1.4.3 de WCAG pide al menos 4.5:1 para texto normal. En una prueba con colores de ejemplo, el contraste calculado de la pestaña activa con el mouse encima fue de 1,00:1.
CSS Modules no cambia esto. Solo renombra las clases a nombres únicos, y la especificidad de los selectores sigue siendo la misma.
Solución
-
Confirma la causa. En las herramientas de desarrollo del navegador, selecciona la pestaña activa y fuerza el estado
:hoverdesde el panel de estilos. Verás tachada la declaración decolorde.tabActivey aplicada la de.tab:hover. -
Excluye la pestaña activa de la regla de hover. Es la opción más clara y no depende del orden de las reglas:
.tab:not(.tabActive):hover { color: var(--color-primary); }La regla de hover deja de coincidir con la pestaña activa, así que nada pisa su color. Procesado con
postcss-modules, la clase local dentro de:not()también se renombra y el selector sigue funcionando. Según MDN,:not()toma la especificidad del selector más específico de su argumento; aquí eso solo importa para las demás pestañas. -
Alternativa: subir la especificidad de la regla activa. Si prefieres no tocar la regla de hover, combina las dos clases:
.tab:hover { color: var(--color-primary); } .tab.tabActive { color: var(--color-on-primary); background: var(--color-primary); }Atención:
.tab.tabActivevale0-2-0, igual que.tab:hover. Con especificidad igual decide el orden, y en la prueba el problema volvió al declarar.tab.tabActiveantes que.tab:hover. Si usas esta opción, mantén la regla activa después de la de hover, o añade un selector explícito que no dependa del orden:.tab.tabActive:hover { color: var(--color-on-primary); } -
Busca el mismo patrón en el resto del proyecto. En PowerShell:
Get-ChildItem src -Recurse -Filter *.module.css | Select-String -Pattern ':hover|:focus|:active'Revisa cada coincidencia: si el selector con pseudoclase apunta a una clase base y existe otra clase "de estado" (activa, seleccionada, primaria) que define la misma propiedad, tienes el mismo conflicto.
Cómo verificar que quedó resuelto
La comprobación manual consiste en pasar el mouse sobre la pestaña activa: el texto debe seguir visible. Para una comprobación más objetiva, fuerza :hover en las herramientas de desarrollo y compara los valores de color y background-color en la pestaña Computed; no deben ser iguales. El selector de color de Chrome también muestra la relación de contraste.
Si usas Playwright, puedes automatizarlo con propiedades estándar del DOM:
const activa = page.locator('[role="tab"][aria-selected="true"]')
await activa.hover()
const [color, fondo] = await activa.evaluate((el) => {
const estilos = getComputedStyle(el)
return [estilos.color, estilos.backgroundColor]
})
// color y fondo deben ser distintos
Comprueba también que las pestañas inactivas conservan su efecto de hover: con la opción de :not(), pasar el mouse por una pestaña inactiva debe seguir cambiando su color al primario.
Variantes y errores relacionados
El mismo conflicto con :focus, :active o :disabled
Cada pseudoclase simple agregada a la clase base (:hover, :focus-visible, :active) suma el equivalente a una clase de especificidad. Por eso un .boton:focus-visible o un .boton:active también puede imponerse a una clase de estado como .botonPrimario. La corrección es la misma: excluir el estado con :not() o declarar el selector combinado explícito.
Si el hover cambia también el fondo
Si la regla de hover define color y background, ambas propiedades de la pestaña activa serían reemplazadas por las del hover (la regla de hover gana en las dos). Según los colores elegidos, el texto podría seguir siendo legible, pero la pestaña activa perdería su fondo distintivo al pasar el mouse. La exclusión con :not() resuelve los dos casos.
No uses !important como atajo
Funciona a corto plazo, pero obliga a usar más !important en cada excepción posterior. Es preferible ajustar los selectores.
No dependas del orden entre archivos
Cuando la solución depende de que una regla se declare después de otra y ambas están en archivos distintos, el resultado puede cambiar según el orden en que el empaquetador incluya los módulos CSS. Con especificidades distintas, ese riesgo desaparece.
Resumen
- Una clase con pseudoclase (
.tab:hover,0-2-0) gana a una clase sola (.tabActive,0-1-0), sin importar el orden en el archivo. - El resultado fue texto y fondo del mismo color: contraste 1:1, muy por debajo del 4.5:1 que pide WCAG.
- Solución preferida:
.tab:not(.tabActive):hover. - Si subes la especificidad con
.tab.tabActive, queda un empate con.tab:hovery decide el orden: declárala después. - Busca el mismo patrón en otras pseudoclases y componentes con clases de estado.
Comentarios
Publicar un comentario