wandres.dev
ESPECIFICIDAD · El sistema de tres números

La trampa arquitectónica de los identificadores

Por qué un solo selector de id sube el suelo de especificidad de todo el proyecto para siempre, qué usos del atributo id siguen siendo obligatorios y cómo desactivar uno que no puedes borrar.

⏱ 16 min

“No uses identificadores en CSS” es de los pocos consejos con los que casi todo el mundo está de acuerdo y que casi nadie sabe justificar más allá de “suben mucho la especificidad”. La justificación de verdad es más interesante y más incómoda: un selector de identificador no encarece una regla, eleva el suelo de todo el proyecto de forma permanente, y el coste no lo paga quien lo escribe sino quien llegue después. Es un caso de manual de externalidad, y por eso los equipos lo repiten aunque nadie recuerde el motivo.

🎯 Al terminar esta lección sabrás
  • Explicar por qué ninguna cantidad de clases supera a un identificador.
  • Describir el efecto de trinquete que un selector de id produce sobre el resto del código.
  • Enumerar los usos del atributo id que siguen siendo necesarios en el marcado.
  • Neutralizar un selector de identificador que no está en tu poder modificar.

El acantilado

Ya conoces la mecánica: las columnas se comparan de izquierda a derecha y no hay acarreo. Un identificador ocupa la columna A y las clases la B, de modo que ninguna cantidad de clases alcanza a un identificador. No es que sea difícil, es que es imposible por construcción.

/* 1-0-0 */
#barra-lateral a { color: teal; }

/* 0-8-1 — pierde igualmente */
.tema-oscuro .contenido .barra .lista .item .enlace.activo.visitado a { color: crimson; }

Escrito así parece un ejemplo de laboratorio. En un proyecto real la escena es otra: alguien necesita cambiar un color en una zona, prueba con una clase, no funciona, prueba con dos, no funciona, y como el diagnóstico correcto exige entender la cascada, lo que suele ocurrir es que se llega a !important sin pasar por el análisis. A partir de ahí el bloque queda sellado.

El efecto trinquete

Lo que convierte esto en un problema arquitectónico y no en una molestia local es que la decisión se propaga hacia adelante. En cuanto existe un selector de identificador que afecta a una zona compartida, cualquier regla futura que necesite ganarle tiene que estar en la columna A o recurrir a la importancia. Y como los identificadores son únicos en el documento, la segunda regla no puede usar el mismo identificador para acotar mejor: tiene que usar otro, o encadenar más, o rendirse.

El resultado es un trinquete: el nivel mínimo de especificidad necesario para operar en esa zona solo puede subir. Nadie lo baja nunca, porque bajarlo exigiría tocar la regla original y quien la escribió ya no está o el fichero es de otro equipo. Después de dos años de esto, un proyecto no tiene “algunos selectores caros”: tiene un suelo, y todo el mundo escribe por encima de él porque escribir por debajo no funciona.

La segunda razón, complementaria, es de reutilización. En un documento bien formado un identificador designa exactamente un elemento. Por tanto una regla escrita con un selector de identificador es, por definición, una regla que no puede aplicarse a nada más. En un lenguaje cuyo único mecanismo de reutilización es el selector, eso equivale a renunciar a la reutilización en esa regla. Cada estilo que vayas a necesitar dos veces tendrá que duplicarse o extraerse a una clase, y en cuanto lo extraes a una clase el selector de identificador que quedó al lado se convierte en un obstáculo permanente para esa clase.

El mito de que los identificadores son más rápidos, y por qué da igual aunque fuera cierto

Durante años la defensa técnica de los selectores de identificador fue el rendimiento, y merece la pena desmontarla bien porque sigue apareciendo en revisiones de código. Los motores no evalúan los selectores de izquierda a derecha, sino al revés: parten del componente de más a la derecha, llamado selector clave, y desde ahí comprueban hacia arriba si el resto del selector encaja. Además mantienen índices por tipo de selector clave, de modo que ante un elemento con clase .enlace solo se consideran las reglas cuyo selector clave sea esa clase, ese tipo o el universal. La consecuencia es que la velocidad de una regla la determina casi por completo su parte derecha, no su parte izquierda. Un #barra a no es más rápido que un .barra a para los elementos a: los dos se buscan por el mismo índice de tipo y solo después se comprueba el ancestro. Y si te fijas, el consejo que se derivaba del mito era exactamente el contrario del correcto: recomendaba anteponer identificadores, es decir, engordar la parte izquierda, que es la que no importa. Pero hay algo más importante que ganar la discusión: incluso si la diferencia existiera, sería irrelevante. El emparejamiento de selectores en el recálculo de estilo de una página normal se mide en fracciones de milisegundo y no aparece en ningún perfil realista salvo en casos patológicos con decenas de miles de reglas y de elementos. Optimizar ahí es cambiar mantenibilidad —que es cara, escasa y se degrada— por un rendimiento que no vas a poder medir. Ese intercambio se hizo mal durante una década entera de buenas prácticas.

El atributo id no es el problema

Aquí es donde el consejo mal enunciado hace daño. “No uses identificadores” se entiende a menudo como “no pongas id en el HTML”, y eso es directamente incorrecto: hay usos del atributo que no tienen alternativa y varios de ellos son requisitos de accesibilidad.

Uso Por qué no hay alternativa
Destino de un enlace de fragmento El navegador salta a un id
label for Es el mecanismo que asocia etiqueta y control
aria-labelledby y aria-describedby Referencian por id
aria-controls Referencia por id
Atributo form en un control fuera del formulario Referencia por id
popovertarget Referencia por id
getElementById Es la búsqueda directa por identificador

Quitar los id del marcado para “limpiar el CSS” rompe cosas que importan mucho más que la especificidad. La formulación correcta del consejo es otra y es fácil de recordar: usa id en el HTML todo lo que necesites; no lo uses como selector en el CSS.

Y hay una elegancia adicional que se aprovecha poco: puedes estilar en función del identificador sin usar el selector de identificador. Si lo que quieres es reaccionar a que un elemento es el destino de la navegación actual, la pseudo-clase :target vale 0-1-0 y hace exactamente eso:

:target {
  scroll-margin-block-start: 5rem;
  outline: 2px solid oklch(70% 0.16 250);
}

Ni una columna A, y el comportamiento es el que querías.

Desactivar un identificador que no puedes borrar

Casi siempre el selector problemático está en CSS que no controlas: una biblioteca, un tema heredado, un widget de un proveedor. Hay tres salidas y conviene elegirlas en este orden.

Primera, si el CSS es tuyo pero antiguo: cámbialo por un selector de atributo. El elemento sigue siendo el mismo, el marcado no se toca, y la regla baja de la columna A a la B:

/* Antes: 1-0-0 */
#cabecera { position: sticky; }

/* Después: 0-1-0, mismo elemento */
[id="cabecera"] { position: sticky; }

Segunda, si el CSS es tuyo y quieres conservar la legibilidad del #: neutralízalo con :where(). El selector se lee igual y su especificidad cae a cero, con lo que cualquier clase puede sobrescribirlo:

:where(#cabecera) { position: sticky; }

Tercera, si el CSS es de terceros y no puedes tocarlo: cámbialo de capa. Aquí es donde el problema deja de ser de especificidad y pasa a resolverse un escalón más arriba de la cascada. Importando esa hoja dentro de una capa temprana, todas sus reglas —incluidas las que llevan identificadores— pierden contra cualquier regla tuya de una capa posterior, sin que tengas que igualar ninguna cifra:

@layer externo, propio;

@import url("tema-heredado.css") layer(externo);

@layer propio {
  .cabecera { position: static; }  /* gana con 0-1-0 */
}

Ese es el uso canónico de las capas y el asunto del nivel siguiente. Fíjate en lo que ha pasado: el problema no se ha resuelto contando mejor, se ha resuelto dejando de contar.