wandres.dev
CUSTOM PROPERTIES · Variables que viven en la cascada

El valor vacío, el interruptor de espacio y las guardas

La diferencia entre una custom property vacía y una puesta a initial, cómo esa asimetría produce condicionales en CSS, y por qué el truco se rompe en cuanto registras la propiedad.

⏱ 17 min

Hay dos formas de que una custom property “no valga nada” y no significan lo mismo. Una la deja vacía, y al sustituirla desaparece sin dejar rastro; la otra la deja sin valor, y al sustituirla deja pasar el fallback. De esa asimetría de un solo carácter la comunidad extrajo el único mecanismo condicional que CSS ha tenido durante años, y hoy sigue siendo lo más portable que existe para expresar un booleano dentro de una hoja de estilos.

🎯 Al terminar esta lección sabrás
  • Distinguir el valor vacío del valor garantizadamente inválido.
  • Construir un interruptor de dos estados con custom properties.
  • Componer varias condiciones sin multiplicar selectores.
  • Saber cuándo el truco se rompe y qué lo va a sustituir.

El valor vacío y el valor garantizadamente inválido

Estas dos declaraciones se parecen y hacen cosas opuestas:

.a { --marca: ; }        /* valor VACIO: existe y no contiene nada */
.b { --marca: initial; } /* SIN valor: garantizadamente invalido */

Al sustituir con var(--marca, RESERVA) el comportamiento diverge:

/* En .a: --marca existe, asi que NO se usa el fallback.
   Se sustituye por nada, y el resultado es lo que quede alrededor. */

/* En .b: --marca no existe, asi que SI se usa el fallback.
   Se sustituye por RESERVA. */

Escrito en un caso concreto:

.a { --marca: ;        padding: 1rem var(--marca, 2rem); }  /* padding: 1rem */
.b { --marca: initial; padding: 1rem var(--marca, 2rem); }  /* padding: 1rem 2rem */

La regla que hay que grabar es de dos líneas. Vacío borra: la sustitución no aporta tokens y el resto de la declaración sigue siendo lo que era. initial deja pasar: la sustitución aporta exactamente el fallback.

Nótese que el espacio inicial del valor se descarta al analizar, así que --marca: ; y --marca: ; son lo mismo, y ambas son válidas. Esa es la razón del nombre popular del truco.

El interruptor de espacio

Con esas dos piezas se construye un booleano. La forma robusta usa dos propiedades complementarias, porque así cada rama tiene su propio fallback y no dependes de que “nada” sea válido en el sitio donde sustituyes.

.chip {
  /* Por defecto esta inactivo: la rama inactiva es la que deja pasar. */
  --activo: ;            /* vacio: borra su rama */
  --inactivo: initial;   /* sin valor: deja pasar su rama */

  background:    var(--activo, oklch(70% 0.15 145)) var(--inactivo, oklch(94% 0.01 260));
  border-color:  var(--activo, oklch(50% 0.15 145)) var(--inactivo, oklch(80% 0.01 260));
}

.chip[data-activo] {
  /* Se intercambian y con ellos las dos declaraciones de arriba. */
  --activo: initial;
  --inactivo: ;
}

Sigue la sustitución en el estado por defecto: --activo está vacío, así que var(--activo, verde) desaparece sin dejar nada; --inactivo no existe, así que var(--inactivo, gris) deja pasar el gris. Resultado: gris. Con el atributo puesto ocurre lo simétrico y sale verde. Una sola declaración por propiedad, dos estados, cero duplicación de reglas.

⚠️
La polaridad es contraintuitiva: ponle nombres que la delaten

initial suena a “apagado” y es justo lo que deja pasar el fallback; el vacío suena a “nada” y es lo que borra la rama. Quien lea tu código dentro de seis meses se va a equivocar de sentido a menos que los nombres lo digan. Nombra las propiedades por la rama que gobiernan —--activo, --inactivo— y úsalas siempre en pareja, nunca una suelta.

Componer condiciones

Lo que hace valioso el mecanismo es que se hereda, así que el booleano se puede fijar arriba y consumirse abajo sin que ninguna regla intermedia lo mencione. Y como es un simple par de propiedades, se compone con lo que ya sabes: :has(), consultas de medio, consultas de contenedor.

:root {
  --compacto: ;         /* borra la rama compacta */
  --amplio: initial;    /* deja pasar la rama amplia */
}

@media (max-width: 40rem) {
  :root { --compacto: initial; --amplio: ; }
}

html:has(#modo-compacto:checked) {
  --compacto: initial; --amplio: ;
}

.tarjeta {
  padding:   var(--compacto, 0.5rem)   var(--amplio, 1.5rem);
  gap:       var(--compacto, 0.25rem)  var(--amplio, 0.75rem);
  font-size: var(--compacto, 0.875rem) var(--amplio, 1rem);
}

Dos fuentes distintas —el ancho de la ventana y una casilla del usuario— alimentan el mismo par de propiedades, y las tres declaraciones del componente no conocen ninguna de las dos. Añadir una tercera fuente no exige tocar el componente; añadir un componente no exige tocar las fuentes.

También sirve para partes opcionales de un valor compuesto, que es donde el vacío luce sin necesidad de pareja:

.boton {
  --anillo: ;   /* sin anillo por defecto */
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.15) var(--anillo);
}

.boton:focus-visible {
  --anillo: , 0 0 0 3px color-mix(in oklch, currentColor 40%, canvas);
}

La sombra base se escribe una vez, y el estado de foco añade una segunda sombra sin repetir la primera. Sin este truco habría que redeclarar box-shadow entero en cada estado, con la duplicación que eso arrastra.

Por qué es un hack y qué lo sustituirá

Conviene ser honesto sobre lo que es. El interruptor de espacio no usa ningún mecanismo condicional del lenguaje: explota la invalidez en tiempo de computación que viste en la lección anterior, convirtiendo en característica lo que es un modo de error. Funciona, es estable, lo usan sistemas de diseño en producción, y aun así tiene tres inconvenientes que hay que conocer.

Es ilegible para quien no lo conozca. No hay ninguna pista en la sintaxis de que ahí hay un condicional. Documéntalo donde lo uses, en serio.

Se rompe al registrar la propiedad. Si más adelante registras --si con @property y una sintaxis concreta —<color>, <length>—, la propiedad deja de poder contener el valor vacío y el interruptor deja de funcionar. Y lo peor es que no falla ruidosamente: falla eligiendo siempre la misma rama. Es una trampa clásica cuando alguien decide “tipar los tokens” meses después.

No expresa comparaciones. Un interruptor de dos estados es lo único que da. En cuanto necesitas “si el valor es mayor que”, ya no sirve.

Lo que va a sustituirlo son las consultas de estilo de contenedor, que preguntan directamente por el valor de una custom property, y la función condicional que la especificación ha añadido para escribir alternativas dentro de un valor. Ambas cosas existen y han empezado a implementarse, pero en 2026 ninguna está en los cuatro motores, así que el interruptor de espacio sigue siendo la opción portable. Escríbelo sabiendo que es temporal y aíslalo en pocos sitios para que la migración futura sea un cambio localizado.

Un modo de error convertido en el único condicional del lenguaje

Hay una lectura de este truco que dice más sobre CSS que el truco en sí. Durante veinte años, la respuesta oficial a “cómo escribo un condicional en CSS” fue que no lo necesitas: para eso están los selectores, escribe otra regla. La respuesta es correcta mientras la condición se pueda expresar como una propiedad del elemento, y deja de serlo en cuanto la condición es un parámetro que viene de arriba, porque entonces la regla que quiere reaccionar no puede nombrarla: tendría que escribir un selector descendiente para cada combinación de contextos, y ahí es donde nacen las explosiones combinatorias de .tema-oscuro .compacto .tarjeta .titulo. El interruptor de espacio resuelve eso porque mueve la condición al canal que sí baja por el árbol —la herencia— y usa la única bifurcación que el lenguaje ofrecía: la que separa “existe” de “no existe”. Y esa bifurcación existía por accidente, como consecuencia de haber tenido que definir qué pasa cuando una sustitución falla. Es decir: la comunidad encontró un condicional escondido dentro de un mecanismo de recuperación de errores. Eso explica tanto su fealdad como su robustez. Es feo porque no se diseñó para esto y no hay sintaxis que lo delate; es robusto porque lo que explota no es un detalle de implementación sino un comportamiento especificado con precisión y que ningún motor puede cambiar sin romper la web. La conclusión que llevarse no es “usa el hack” ni “no lo uses”, sino un criterio: úsalo cuando la alternativa sea multiplicar selectores por combinaciones de contexto, que es exactamente el problema que resuelve, y no lo uses para ahorrarte una segunda regla que se leería perfectamente. Y cuando lo uses, escribe al lado el comentario que explique la polaridad, porque el coste de este patrón no lo pagas tú al escribirlo: lo paga quien lo lea.

⚔️ Enciende y apaga
  1. Demuestra con un ejemplo que --x: ; y --x: initial producen resultados distintos.
  2. Monta un interruptor de dos propiedades y cámbialo desde una consulta de medio.
  3. Alimenta el mismo interruptor desde :has() y comprueba que las dos fuentes conviven.
  4. Añade una sombra de foco a una box-shadow existente sin redeclararla entera.
  5. Registra una de las propiedades del interruptor con @property y observa cómo deja de funcionar.