Inválido al analizar frente a inválido al computar
Por qué una declaración rota por var() no se descarta como cualquier otra sino que se comporta como unset, por qué no puede ser de otra manera, y qué patrón de mejora progresiva deja de funcionar por su culpa.
CSS tiene un mecanismo de recuperación de errores del que depende toda la mejora progresiva: una declaración que el motor no entiende se descarta, y la declaración anterior sigue viva. Escribes el valor de reserva, escribes el moderno debajo, y cada navegador se queda con el último que sepa leer. var() abre un agujero en ese mecanismo, y el agujero no es un defecto de implementación: es matemáticamente inevitable en cuanto un valor puede depender del elemento.
- Distinguir los dos momentos en que una declaración puede ser inválida.
- Explicar por qué la inválida al computar toma
unseten lugar de ceder a la anterior. - Reconocer el síntoma en el inspector, que es engañoso.
- Dejar de usar el patrón de dos declaraciones cuando hay custom properties de por medio.
Dos momentos, dos destinos
Inválida en tiempo de análisis. El motor lee la declaración y comprueba el nombre de la propiedad y la gramática del valor. Si algo no encaja, la declaración se tira como si nunca se hubiera escrito. La cascada ni la ve.
.a {
color: red;
color: 1px; /* 1px no es un color: se descarta al analizar */
}
/* Resultado: rojo. */
Inválida en tiempo de computación. La declaración se analizó bien, porque una declaración que contiene var() es sintácticamente válida diga lo que diga la custom property. El problema aparece al sustituir, mucho después, cuando la cascada ya ha terminado. Entonces la especificación dice algo muy concreto: la propiedad toma el valor unset, es decir, el heredado si la propiedad se hereda y el inicial si no.
.b {
--tono: 1px;
color: red;
color: var(--tono); /* se analiza bien; al sustituir da color: 1px */
}
/* Resultado: NO es rojo. Es el color heredado del padre. */
Y con una propiedad que no se hereda, el destino es el valor inicial:
.c {
--fondo: patata;
background-color: pink;
background-color: var(--fondo);
}
/* Resultado: transparente. Ni rosa, ni patata. */
flowchart TB
A[Declaracion escrita] --> B{Contiene var}
B -- No --> C{La gramatica encaja al analizar}
C -- Si --> D[Entra en la cascada]
C -- No --> E[Se descarta. Sobrevive la anterior]
B -- Si --> F[Se acepta siempre y entra en la cascada]
F --> G[Gana la cascada y se sustituye]
G --> H{El resultado encaja con la gramatica}
H -- Si --> I[Valor aplicado]
H -- No --> J[Invalida en tiempo de computacion]
J --> K[La propiedad toma unset]
style E fill:#a6e3a1,color:#11111b
style I fill:#a6e3a1,color:#11111b
style J fill:#f38ba8,color:#11111b
style K fill:#f9e2af,color:#11111bLas dos ramas verdes son las que se comportan como esperas. La roja es la que hay que interiorizar: no vuelve al camino de la izquierda. No existe ningún punto en el que el motor reconsidere la declaración anterior.
Por qué unset y no la declaración anterior
La pregunta natural es por qué no se hace lo lógico: si color: var(--tono) no sirve, que gane el color: red de arriba. Hay dos razones, y la segunda es la que cierra el asunto.
La cascada ya ha terminado. Cuando llega el momento de sustituir, el motor tiene una única declaración ganadora por propiedad y por elemento; las perdedoras se descartaron antes. Volver atrás exigiría conservar la lista completa de declaraciones candidatas de todas las propiedades de todos los elementos hasta después de la sustitución, y reejecutar la ordenación por elemento. El coste en memoria y en tiempo sería enorme para cubrir un caso de error.
No hay una única respuesta correcta. Ésta es la definitiva. El valor de --tono puede ser distinto en cada elemento, así que la misma declaración puede ser válida en unos nodos e inválida en otros. Si el motor “cediera a la anterior”, la declaración anterior podría a su vez ser otra distinta en cada elemento, y el resultado sería que la cascada efectiva depende del contenido de una variable. Eso convierte el modelo en algo imposible de razonar y de depurar: la misma hoja de estilos produciría jerarquías de precedencia diferentes en partes distintas del documento.
unset es la única respuesta determinista: no depende de qué más hubiera escrito, no depende del orden del archivo, y significa lo mismo para todos los elementos. Es fea a propósito, porque es una condición de error.
Cómo se manifiesta
El síntoma es de los peores que da CSS, porque todo parece correcto. La regla está escrita, la propiedad no aparece tachada en el panel de estilos —se analizó bien, así que técnicamente es válida—, y sin embargo el elemento no tiene el aspecto que dice la regla.
Tres pistas para reconocerlo rápido:
El valor calculado no se parece a nada de lo que has escrito. Si el panel de valores calculados dice rgb(0, 0, 0) y tú no has escrito negro en ningún sitio, es que estás viendo un valor heredado, que es lo que produce unset en una propiedad heredable.
El elemento hereda de su padre una propiedad que tú habías fijado. Cambiar el color del padre cambia el del hijo aunque el hijo tenga su propia regla. Eso solo puede pasar por unset.
Una propiedad no heredable desaparece del todo. Fondos transparentes, bordes ausentes, display que vuelve a su inicial. El valor inicial casi nunca es lo que querías, así que suele verse.
Las herramientas han mejorado y algunas ya señalan las sustituciones fallidas, pero no cuentes con ello para diagnosticar: la comprobación fiable es mirar el valor calculado y compararlo con lo declarado. Si no coinciden y la declaración no está tachada, tienes una inválida en tiempo de computación.
Esto es lo más importante de la lección en términos prácticos. El idioma de toda la vida —valor de reserva primero, valor moderno después— solo funciona cuando la invalidez es de análisis. En cuanto la segunda declaración contiene var(), el navegador la acepta siempre, y si la sustitución falla no recuperas la primera: te quedas con unset, que casi siempre es peor que cualquiera de las dos.
/* Esto SI es mejora progresiva. */
.x { background: #333; background: color-mix(in oklch, black 80%, blue); }
/* Esto NO lo es. Si --fondo falla, no queda #333: queda transparente. */
.y { background: #333; background: var(--fondo); }Cómo protegerse
Cuatro defensas, de más a menos eficaz.
Registrar la propiedad con @property. Es la única protección real, y es el tema del nivel siguiente. Al declarar una sintaxis, un valor que no encaja deja de envenenar la declaración: se descarta en la propia custom property, que cae a su valor inicial registrado, y la sustitución produce algo válido. El error se contiene en el origen en vez de propagarse al punto de uso.
Declarar el valor por defecto en el componente. Si .tarjeta declara --radio: 0.5rem en su propia regla, la ausencia se vuelve imposible. No cubre el caso de un valor malo, pero elimina de golpe la causa más frecuente, que es usar un componente fuera del contexto que definía su parámetro.
Usar longhand en lugar de abreviadas. Reduce el radio de daño. Una font: var(--x) rota se lleva por delante seis propiedades; una font-size: var(--x) rota se lleva una.
Validar en la frontera con JavaScript. Cuando el valor entra desde código, el.style.setProperty('--x', v) acepta cualquier cadena sin rechistar. Si v viene de una API, de un formulario o de una preferencia guardada, compruébalo antes de escribirlo, o registra la propiedad para que el navegador lo compruebe por ti.
Merece la pena ver este comportamiento como lo que realmente es: no una rareza de var(), sino el primer caso de un patrón que se repite en todo el CSS moderno. Durante décadas, CSS tuvo una propiedad preciosa y poco apreciada: todo lo que podía saberse sobre una declaración se sabía al analizarla. Si era válida o no, cuánto pesaba, en qué orden iba. El analizador podía tirar lo malo y quedarse con lo bueno de forma total y definitiva, y sobre esa garantía se construyó el modelo de mejora progresiva del que ha vivido la web entera. var() es el primer punto donde esa garantía se rompe, y se rompe por una razón muy concreta: el valor pasa a depender del elemento, así que ya no hay una respuesta única que dar en el momento del análisis, y la validación tiene que aplazarse hasta que haya un elemento contra el que resolver. Una vez aplazada, el descarte limpio ya no es posible, porque el resto de candidatas se han tirado, y solo queda unset. Fíjate ahora en que el mismo intercambio aparece en todo lo demás que has aprendido en estos niveles. La proximidad de @scope hace que quién gana la cascada dependa de dónde esté el elemento, y con ello el análisis estático de CSS deja de poder decidir qué regla gana. Las consultas de contenedor hacen que la aplicabilidad de una regla dependa del tamaño de un ancestro, y con ello se pierde la posibilidad de saber qué reglas se usan sin renderizar. :has() hace que el estilo de un elemento dependa de su subárbol, y con ello se pierde la invalidación acotada hacia abajo. En los cuatro casos, la moneda con la que se paga el dinamismo es una garantía estática. No es un accidente ni una serie de errores de diseño: es el precio estructural de que el lenguaje deje de describir documentos y empiece a describir componentes cuyo comportamiento depende del contexto. Saberlo cambia dos cosas en cómo trabajas. Deja de esperar que las herramientas de análisis estático te den respuestas completas sobre CSS moderno, porque ya no pueden dártelas. Y cuando adoptes una característica nueva, pregúntate siempre qué garantía estás vendiendo a cambio, porque siempre hay una.
- Reproduce el caso de
color: var(--x)con--x: 1pxy comprueba que hereda en lugar de quedarse en rojo. - Repite con
background-colory observa que el destino es el valor inicial y no el heredado. - Explica por qué el motor no puede volver a la declaración anterior, en términos de dependencia del elemento.
- Busca en tu proyecto un patrón de dos declaraciones donde la segunda use
var()y arréglalo. - Escribe un valor inválido desde JavaScript con
setPropertyy localiza el fallo en el inspector.