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

No son variables: son propiedades que se heredan

Por qué una custom property no se parece a una variable de preprocesador, qué significa que su valor sea un flujo de tokens, y por qué tiene un valor distinto en cada elemento.

⏱ 17 min

Llamarlas variables ha costado caro. Una variable, en cualquier lenguaje que conozcas, tiene un nombre y un valor. Una custom property tiene un nombre y un valor por cada elemento del documento, porque no es una variable: es una propiedad de CSS que resulta que tú has inventado, y como toda propiedad de CSS vive en la cascada, se hereda y se recalcula. Cambiar el nombre en tu cabeza cambia lo que se te ocurre hacer con ellas.

🎯 Al terminar esta lección sabrás
  • Explicar la diferencia entre una variable de preprocesador y una custom property.
  • Describir qué acepta el valor declarado de una custom property.
  • Aprovechar que el valor depende del elemento y no del archivo.
  • Enumerar los sitios donde no se pueden usar y por qué.

Son propiedades, no variables

--acento: crimson es una declaración. Tiene un nombre de propiedad, un valor, un selector que decide a qué elementos se aplica, y participa en la cascada exactamente igual que color o margin: gana la de mayor origen, capa, especificidad y orden.

La diferencia con una variable de Sass es de naturaleza, no de sintaxis. Una $variable es un enlace léxico que existe durante la compilación y desaparece del archivo emitido; el navegador nunca sabe que existió. Una custom property existe en el navegador, tiene valor calculado en cada elemento, y se puede consultar y modificar en tiempo de ejecución.

La demostración es de tres líneas y conviene hacerla una vez:

:root      { --hueco: 1rem; }
.compacto  { --hueco: 0.5rem; }
.holgado   { --hueco: 2rem; }

.pila { display: grid; gap: var(--hueco); }

Un único .pila escrito una sola vez produce tres separaciones distintas según dónde esté. Con $hueco de Sass eso es imposible: al compilar hay un solo valor y las tres reglas emitirían el mismo número. La custom property no tiene un valor: tiene un valor por elemento.

Y como es una propiedad normal, todo lo que sabes de la cascada se aplica sin excepciones. Se puede redeclarar en una consulta de medio, en una consulta de contenedor, dentro de una capa, en un @scope, en un :hover, en el atributo style.

.boton {
  --fondo: canvas;
  background: var(--fondo);

  &:hover { --fondo: color-mix(in oklch, canvas 90%, currentColor); }
  &:disabled { --fondo: color-mix(in oklch, canvas 96%, currentColor); }
}

Fíjate en que background se declara una sola vez. Los estados no cambian el fondo: cambian el parámetro del que el fondo depende. Esa inversión es el patrón central de todo el nivel.

El valor es un flujo de tokens

El valor declarado de una custom property es casi cualquier secuencia de tokens. No se valida contra ninguna gramática al analizarlo, porque no hay gramática contra la que validarlo: el motor no sabe dónde vas a usarlo.

:root {
  --color: crimson;
  --borde: 1px solid currentColor;
  --tres-cuartos: 75%;
  --texto: "Hola, que tal";
  --nada-en-particular: esto no significa nada todavia;
  --media: (min-width: 48rem);
}

Las seis son declaraciones válidas. La quinta no significa nada, y aun así se almacena, se hereda y se puede leer. La sexta guarda un fragmento de sintaxis que solo cobra sentido si lo pegas en el sitio correcto.

Los límites de lo que se acepta son estructurales, no semánticos: no puedes dejar corchetes, llaves o paréntesis sin cerrar, ni un punto y coma suelto en el nivel superior, ni un ! que no sea !important. Todo lo demás pasa.

Dos detalles que se olvidan y muerden:

Los nombres distinguen mayúsculas de minúsculas. --Acento y --acento son dos propiedades distintas. Es la única parte de CSS donde esto ocurre, porque el resto del lenguaje es insensible a la caja, y produce depuraciones muy tontas.

El espacio inicial del valor se descarta, el final se conserva. Da igual para casi todo, salvo para un truco que verás en esta misma serie y que se apoya justo en esa asimetría.

Un valor por elemento

Aquí está lo que de verdad las diferencia de cualquier sistema de variables, y lo que casi nadie explota: las custom properties se heredan. Una declarada en un ancestro llega a todos sus descendientes sin que ninguno la mencione.

Eso convierte al árbol del DOM en un canal de comunicación descendente. Un componente enterrado a diez niveles puede leer un parámetro que fijó su abuelo, sin acoplarse a él y sin que nadie escriba un selector que los relacione.

/* El sistema declara el parametro. */
:root { --densidad: 1; }

/* Cualquier subarbol lo redefine para lo que cuelgue de el. */
.barra-lateral { --densidad: 0.75; }
.modo-tactil   { --densidad: 1.25; }

/* Los componentes lo consumen sin saber quien lo puso. */
.boton { padding: calc(0.5rem * var(--densidad)) calc(1rem * var(--densidad)); }
.campo { block-size: calc(2.5rem * var(--densidad)); }
.lista > li + li { margin-block-start: calc(0.75rem * var(--densidad)); }

Ninguna de las tres reglas de abajo menciona .barra-lateral ni .modo-tactil. Añadir un contexto nuevo no exige tocar ni un componente; añadir un componente nuevo no exige tocar ni un contexto. Es la única forma de acoplamiento débil que CSS ofrece de serie.

💡
Declara el valor por defecto donde se consume

La costumbre de meterlo todo en :root funciona para los tokens globales del sistema —colores, tipografía, espaciado base— y es un desperdicio para los parámetros de un componente. Si --radio-tarjeta solo lo usa la tarjeta, decláralo en .tarjeta: el componente sigue siendo autónomo, el valor sigue siendo sobrescribible desde fuera con una regla más específica o desde un ancestro, y no contaminas el espacio global con nombres que nadie más necesita.

Lo que no pueden hacer

Cuatro fronteras.

No sirven para nombres de propiedad. No existe forma de escribir “la propiedad cuyo nombre está en --p”. El nombre de la propiedad se decide al analizar; el valor de la custom property, mucho después.

No sirven dentro de selectores. No puedes construir .clase-var(--x). Misma razón.

No sirven en los preludios de las reglas arroba. Esta es la que más frustra, porque la tentación es enorme:

:root { --punto-ruptura: 48rem; }

/* NO funciona. La consulta no se resuelve con var(). */
@media (min-width: var(--punto-ruptura)) { }

Las consultas de medio se evalúan antes y fuera del cálculo de estilos de ningún elemento, así que no hay elemento contra el que resolver la custom property. La respuesta práctica sigue siendo escribir el número, o generarlo en la compilación si te importa mucho no repetirlo.

No se pegan a otros tokens. url(var(--ruta)) no funciona porque el analizador ve un url() con contenido literal. La forma correcta es guardar la función entera:

/* Mal. */
:root { --ruta: "/fondo.avif"; }
.hero { background-image: url(var(--ruta)); }

/* Bien. */
:root { --imagen: url("/fondo.avif"); }
.hero { background-image: var(--imagen); }
Deja de pensar en constantes y empieza a pensar en parámetros

El error de modelo que arrastra todo el mundo al venir de un preprocesador se resume en una palabra: constante. Una variable de Sass es una constante con nombre, se declara una vez arriba del archivo, sirve para no repetir un número, y su virtud es que hay un único sitio donde cambiarlo. Con ese modelo en la cabeza, lo natural es volcar todo en :root, no volver a tocarlo, y usar las custom properties como un buscar-y-reemplazar elegante. Eso funciona, y desperdicia el noventa por ciento de lo que son. El modelo correcto es parámetro: una custom property es un argumento que se pasa hacia abajo por el árbol, cuyo valor efectivo depende de dónde estés, y cuya gracia no es evitar la repetición sino permitir que un mismo componente se comporte distinto en contextos distintos sin que ninguno de los dos conozca al otro. En cuanto haces ese cambio, aparecen patrones que con el modelo de constante ni se te ocurren: componentes que exponen su superficie de configuración como una lista de propiedades documentadas, igual que un componente de JavaScript expone props; contextos que reconfiguran todo lo que cae dentro sin escribir un solo selector descendiente; estados que cambian un parámetro en lugar de repetir diez declaraciones. Y aparece también el criterio para decidir dónde declarar cada cosa, que con el modelo de constante no existía: un parámetro se declara en el elemento más bajo que tenga sentido, porque cuanto más arriba lo declares, más ancho es su alcance y menos capacidad tienes de reconfigurarlo después. :root no es el sitio por defecto: es el sitio para lo que de verdad es global.

⚔️ Cambia el modelo
  1. Escribe un componente cuyo espaciado dependa de una --densidad heredada y pruébalo en tres contextos.
  2. Comprueba que --A y --a son propiedades distintas.
  3. Guarda en una custom property un valor que no significa nada y léelo con getComputedStyle.
  4. Intenta usar una custom property en un @media y explica por qué no funciona.
  5. Coge un componente tuyo y convierte sus estados para que cambien parámetros en vez de declaraciones.