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

Dejar de pelear: cinco tácticas y un plan de saneamiento

El principio de bajar al ganador en vez de subir al perdedor, las cinco herramientas que lo hacen posible y el orden en que se aplican a un proyecto que ya existe.

⏱ 17 min

Toda pelea de especificidad se puede resolver de dos maneras: subiendo al que pierde o bajando al que gana. La primera es inmediata, funciona siempre y empeora el sistema; la segunda cuesta un minuto de pensar y lo mejora. Como la primera es la que sale sola bajo presión, la única defensa realista es tener las alternativas tan a mano que no haya que buscarlas. Esta lección cierra el nivel con las cinco tácticas ordenadas por preferencia y con el plan concreto para aplicarlas a código que ya está escrito.

🎯 Al terminar esta lección sabrás
  • Aplicar el principio de bajar al ganador en lugar de subir al perdedor.
  • Elegir la táctica adecuada según de quién sea el CSS que estorba.
  • Diseñar la superficie de sobrescritura de un componente con custom properties.
  • Ejecutar un saneamiento por capas sin reescribir selectores uno a uno.

El principio

Cuando dos reglas compiten y gana la que no quieres, tienes dos movimientos posibles. Subir la perdedora significa añadirle peso: otro selector delante, una clase repetida, un identificador, o !important. Bajar la ganadora significa quitarle peso: neutralizarla con :where(), cambiar su identificador por un atributo, o meterla en una capa anterior.

Las dos resuelven el síntoma. La diferencia es lo que dejan detrás. Subir al perdedor eleva el suelo para todos los que vengan después, y como nadie lo volverá a bajar, la próxima pelea empezará más arriba. Bajar al ganador reduce el rango de especificidad del proyecto, y el siguiente que tenga que sobrescribir algo lo tendrá más fácil que tú.

Formulado como criterio operativo: antes de añadir peso a tu regla, comprueba si puedes quitárselo a la otra. Si la otra regla es tuya, casi siempre puedes. Si no lo es, hay una táctica específica para eso.

Las cinco tácticas

Primera: especificidad plana. La regla por defecto de todo componente es una sola clase, 0-1-0. Nada más. Las variantes y los estados no se expresan encadenando clases sino con atributos, que valen lo mismo:

.boton { padding-inline: 1rem; }
.boton[data-variante="fantasma"] { background: transparent; }
.boton[data-estado="cargando"] { pointer-events: none; }

La versión encadenada equivalente, .boton.boton--fantasma, vale 0-2-0, y a partir de ahí todo lo que quiera sobrescribirla tiene que igualar dos clases. La diferencia parece menor y no lo es: con atributos, el sistema entero se queda en una columna B de uno, y sobrescribir cualquier cosa desde una capa posterior cuesta una clase.

Segunda: :where() para todo lo que sea un valor por defecto. Si una regla existe para poner un punto de partida razonable —un reset, un estilo base de tipografía, los valores iniciales de un componente—, envuélvela y déjala a cero:

:where(.prosa) :where(h2, h3, h4) {
  margin-block-start: 1.5em;
  text-wrap: balance;
}

Ese selector acota con precisión quirúrgica y no puntúa nada, con lo que cualquier regla posterior, por simple que sea, lo sobrescribe. Es la forma correcta de escribir cualquier CSS destinado a ser usado por otros, incluido el tuyo de dentro de un año.

Tercera: capas para expresar autoridad. Cuando lo que quieres decir es “este bloque debe perder siempre contra aquel”, no lo digas con selectores: dilo con @layer, que es el criterio que existe exactamente para eso y que se evalúa antes que la especificidad.

Cuarta: variables como superficie de sobrescritura. En lugar de exponer selectores para que otros los sobrescriban, expón entradas.

.tarjeta {
  --tarjeta-fondo: oklch(97% 0.01 250);
  --tarjeta-radio: 0.75rem;
  --tarjeta-relleno: 1.25rem;

  background: var(--tarjeta-fondo);
  border-radius: var(--tarjeta-radio);
  padding: var(--tarjeta-relleno);
}

Quien quiera una tarjeta distinta no tiene que ganarle nada a nadie: define la variable donde le convenga y el componente la lee.

.panel-lateral { --tarjeta-fondo: oklch(92% 0.03 250); }

Quinta y última: utilidades importantes en la primera capa. Para el puñado de clases cuya razón de ser es forzar un valor concreto, la configuración correcta es una capa declarada la primera con declaraciones importantes, por la inversión que ya conoces. Es el único uso de !important que no genera deuda, porque está acotado, documentado y es intencionado.

Sobrescribir declaraciones es competir; sobrescribir variables es no competir

Las cuatro primeras tácticas mitigan el conflicto; la cuarta lo elimina, y merece entenderse bien porque cambia la forma de diseñar componentes. Cuando expones un selector para que otros lo sobrescriban, estás invitando a una pelea en la cascada: el otro tiene que producir una declaración que gane a la tuya para la misma propiedad y el mismo elemento, y para eso necesita conocer tu especificidad, tu capa y tu orden. Has convertido un detalle interno de tu implementación en parte de tu contrato público, y cualquier cambio tuyo de selector rompe a quien te sobrescribía. Cuando expones una variable, en cambio, la pelea nunca ocurre: la cascada resuelve el valor de --tarjeta-fondo allí donde se declaró, siguiendo el árbol, y tu componente se limita a leer el valor que le haya llegado por herencia en el punto de uso. No hay dos declaraciones compitiendo por background, porque solo hay una: la tuya. La consecuencia práctica es que puedes reescribir por completo el interior del componente sin romper a nadie, siempre que respetes los nombres de las variables. Ese es exactamente el sentido de “superficie pública” que en otros lenguajes se consigue con módulos y que en CSS, que no tiene módulos, se consigue así. Hay dos cautelas que conviene incorporar desde el principio. La primera es de nomenclatura: prefija las variables públicas con el nombre del componente, porque las custom properties se heredan y un nombre genérico como --fondo colisionará con el de otro componente antes de lo que crees. La segunda es de robustez: recuerda que un valor inválido en una variable no cae a la declaración anterior sino que se comporta como unset, así que da siempre un valor de reserva en el punto de uso, y registra con @property las que sean críticas para que el tipo se valide en la declaración.

Antipatrones que parecen tácticas

Repetir la clase. .boton.boton vale 0-2-0 y funciona. Es la escalada más barata que existe y por eso circula como truco. Lo que hace en realidad es exactamente lo mismo que un !important en pequeño: gana hoy y obliga a quien venga a repetir tres veces. Si te descubres escribiéndolo, la pregunta correcta es por qué la otra regla vale 0-2-0.

Prefijar con body o con html. Añade una columna C que no acota nada, porque todos los elementos del documento están dentro de body. Es escalada pura disfrazada de contexto, y además envenena cualquier intento posterior de reutilizar esas reglas dentro de un árbol de sombra, donde body no existe.

Encadenar el contenedor “por si acaso”. .pagina .seccion .tarjeta .titulo cuando .tarjeta .titulo bastaba. Cada eslabón de más es una columna B de más que alguien tendrá que igualar. Acota solo hasta donde el problema lo exija, y si necesitas acotar mucho, hazlo con :where().

Usar !important “temporalmente”. No existe el !important temporal. Existe el !important que nadie se atreve a quitar porque no sabe qué se rompería.

Plan de saneamiento para código que ya existe

El error clásico al heredar un proyecto con especificidad descontrolada es empezar a bajar selectores uno a uno. Es lento, es arriesgado —cada cambio puede alterar un ganador en cualquier página— y no termina nunca. El plan que sí funciona tiene cuatro pasos y no toca ni un selector antiguo.

Uno. Declara el orden de capas en un fichero que se cargue siempre el primero. Una sola línea, sin contenido, que fija la jerarquía de autoridad de todo el proyecto:

@layer heredado, base, componentes, utilidades;

Dos. Mete todo el CSS existente en la capa heredado, sin tocarlo. Si se importa, con @import ... layer(heredado). Si está en el mismo fichero, envolviéndolo en un bloque. A partir de este momento, todo el código nuevo escrito en cualquier capa posterior gana con una sola clase, aunque enfrente haya identificadores y selectores de siete niveles.

Tres. Escribe lo nuevo en las capas que le tocan y no vuelvas a mirar el CSS antiguo. Ya no puede ganarte.

Cuatro. Retira lo heredado por trozos, cuando toques una zona por otro motivo. La capa heredado va menguando sola, y en ningún momento hay una migración grande que revisar de una vez.

Lo importante del plan es que el problema deja de crecer en el paso dos, que cuesta media hora. Todo lo demás es limpieza sin urgencia. Las capas, que hasta aquí solo han aparecido de refilón, son el asunto del nivel siguiente.

⚔️ Baja el suelo de un proyecto real
  1. Ejecuta el informe de la lección anterior sobre un proyecto tuyo y anota las tres reglas con más identificadores.
  2. Neutraliza una de ellas con :where() y comprueba en el navegador que ninguna página cambia de aspecto.
  3. Elige un componente que tenga variantes encadenadas y conviértelas a atributos, verificando que la especificidad baja a 0-1-0.
  4. Expón como variables las tres propiedades de ese componente que más veces has sobrescrito desde fuera, y elimina las sobrescrituras.