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

Custom properties en tiempo de ejecución

Cómo se leen y se escriben desde JavaScript, qué cuesta de verdad cambiar una, los tres patrones que valen la pena y dónde poner la frontera entre CSS y código.

⏱ 17 min

Escribir el.style.setProperty('--x', '3px') parece una asignación a una variable y cuesta como una invalidación de estilos. Como las custom properties se heredan, tocar una en la raíz del documento marca como sucio todo lo que cuelga de ella; tocarla en el elemento correcto marca cuatro nodos. La línea de código es idéntica en los dos casos y la diferencia de coste es de tres órdenes de magnitud.

🎯 Al terminar esta lección sabrás
  • Leer y escribir custom properties desde JavaScript sin sorpresas de formato.
  • Explicar por qué el coste de una escritura depende de dónde se haga.
  • Implementar los tres patrones de tiempo de ejecución que compensan.
  • Decidir qué estado pertenece a CSS y cuál a JavaScript.

Leer y escribir desde JavaScript

Para leer, el valor calculado en un elemento concreto:

const raiz = document.documentElement;
const acento = getComputedStyle(raiz).getPropertyValue('--acento');

Dos avisos sobre lo que devuelve. Primero, es una cadena, no un color ni un número, y puede venir con espacios alrededor: aplica .trim() antes de compararla con nada. Segundo, es el valor calculado, así que si la propiedad no está registrada te devuelve el flujo de tokens tal cual lo escribiste —calc(2 * 1rem) sigue siendo calc(2 * 1rem), sin resolver— mientras que si está registrada te devuelve el valor ya computado. Esa diferencia sorprende y es la primera razón práctica para registrar.

Para escribir, sobre el estilo en línea del elemento:

el.style.setProperty('--acento', 'oklch(60% 0.18 260)');
el.style.removeProperty('--acento');

El nombre lleva los dos guiones y distingue mayúsculas de minúsculas. Y setProperty no valida nada: si escribes 'patata' en una propiedad que se usa como color, la llamada tiene éxito, y el fallo aparece después como una invalidez en tiempo de computación, lejos del sitio donde se causó. Cuando el valor venga de fuera —una API, un formulario, una preferencia guardada—, compruébalo antes o registra la propiedad para que lo compruebe el navegador.

Qué cuesta cambiar una custom property

Una custom property sin registrar se hereda siempre. Por tanto, cambiarla en un elemento obliga al motor a recalcular el estilo de ese elemento y de todo su subárbol, porque cualquier descendiente podría estar usándola.

De ahí sale la regla más importante de toda la lección: escribe en el elemento más bajo que sirva.

/* Invalida el documento entero. */
document.documentElement.style.setProperty('--brillo', '1.2');

/* Invalida solo lo que cuelga del panel. */
panel.style.setProperty('--brillo', '1.2');

Las dos líneas son igual de legibles y no lo son en absoluto de igual de caras. El cambio de tema en la raíz es aceptable porque ocurre una vez cada mucho; un efecto que sigue al puntero escrito en la raíz es un desastre, porque repite la invalidación del documento entero en cada fotograma.

Hay una sola forma de que el cambio no se propague hacia abajo, y es registrar la propiedad con inherits: false. Es el tema del nivel siguiente, y merece adelantarlo aquí porque es la palanca de rendimiento menos conocida del asunto: una propiedad registrada como no heredable invalida únicamente el elemento donde se escribe.

💡
Agrupa las escrituras en un fotograma

Escribir varias custom properties seguidas en el mismo elemento cuesta prácticamente lo mismo que escribir una, porque la invalidación se agrupa hasta el siguiente cálculo de estilos. Lo que sí es caro es intercalar lecturas geométricas —getBoundingClientRect, offsetWidth— entre escrituras, porque cada lectura fuerza al motor a resolver lo pendiente. Lee todo primero, escribe todo después.

Tres patrones que compensan

El efecto que sigue al puntero. El canónico. Todo el trabajo visual lo hace CSS; JavaScript solo transporta dos números, y lo hace en el elemento correcto y una vez por fotograma.

const panel = document.querySelector('.panel');
let pendiente = false;
let ultimoX = 0, ultimoY = 0;

panel.addEventListener('pointermove', (evento) => {
  const caja = panel.getBoundingClientRect();
  ultimoX = evento.clientX - caja.left;
  ultimoY = evento.clientY - caja.top;
  if (pendiente) return;
  pendiente = true;
  requestAnimationFrame(() => {
    panel.style.setProperty('--px', ultimoX + 'px');
    panel.style.setProperty('--py', ultimoY + 'px');
    pendiente = false;
  });
});
.panel {
  --px: 50%;
  --py: 50%;
  background:
    radial-gradient(
      12rem circle at var(--px) var(--py),
      color-mix(in oklch, canvastext 12%, transparent),
      transparent 70%
    );
}

El cambio de tema con persistencia. JavaScript hace lo mínimo: leer la preferencia guardada y ponerla en un atributo. A partir de ahí manda la cascada.

const guardado = localStorage.getItem('tema');
if (guardado) document.documentElement.dataset.tema = guardado;

document.querySelector('#conmutar').addEventListener('click', () => {
  const actual = document.documentElement.dataset.tema === 'oscuro' ? 'claro' : 'oscuro';
  document.documentElement.dataset.tema = actual;
  localStorage.setItem('tema', actual);
});
:root { color-scheme: light dark; --superficie: canvas; }
[data-tema="claro"]  { color-scheme: light; }
[data-tema="oscuro"] { color-scheme: dark; }

Fíjate en que JavaScript no toca ni una custom property: escribe un atributo y deja que las reglas hagan el resto. Es más barato, es inspeccionable y sobrevive a que el script falle.

Leer un token de diseño desde código. Cuando algo que no es CSS —un canvas, una librería de gráficas, un metaetiqueta de color de tema— necesita el mismo color que el resto de la interfaz, la fuente de verdad debe seguir siendo la hoja de estilos:

const estilos = getComputedStyle(document.documentElement);
const acento = estilos.getPropertyValue('--acento').trim();
contexto.strokeStyle = acento;

La frontera entre CSS y JavaScript

Con todo lo anterior, la regla de reparto se puede escribir en tres líneas.

CSS decide cómo se ve. Todas las relaciones entre parámetros y apariencia viven en la hoja de estilos. Si JavaScript está calculando un color, un espaciado o una opacidad, casi seguro que hay una declaración que debería estar haciéndolo.

JavaScript aporta lo que CSS no puede saber. Coordenadas del puntero, medidas del layout, datos del servidor, preferencias persistidas. Números y hechos, no decisiones visuales.

El estado va en atributos, no en custom properties. Un atributo es inspeccionable, se puede seleccionar, lo lee la tecnología de asistencia y se restaura con la sesión. Una custom property escrita en línea no aparece en ningún selector, no significa nada para un lector de pantalla y es invisible para cualquiera que inspeccione el DOM buscando por qué algo está de una manera. Reserva las custom properties para valores continuos —posiciones, factores, medidas— y deja los estados discretos en atributos.

Una escritura de custom property es una invalidación, y su alcance está en el receptor

Lo que hace peligroso a setProperty no es su coste sino que su coste no se ve en la línea. En cualquier otro lenguaje, una asignación cuesta lo mismo se haga donde se haga; aquí, la misma llamada puede invalidar cuatro nodos o cuarenta mil, y la diferencia está enteramente en qué objeto tienes delante, que muchas veces es un parámetro que llegó de otra función. Esto convierte a las custom properties en la única API de la plataforma donde el rendimiento depende de la posición en el árbol del receptor y no de lo que hace la llamada, y explica por qué tantas migraciones a “animar con variables CSS” acaban siendo más lentas que lo que sustituyeron: alguien leyó que era la técnica moderna, la escribió sobre document.documentElement porque era lo cómodo, y convirtió cada fotograma en un recálculo de estilo global. La contramedida no es evitar la técnica, que es excelente, sino adoptar dos hábitos. El primero es escribir siempre en el contenedor más cercano al efecto, y si el efecto necesita alcanzar a varios sitios lejanos del árbol, revisar el diseño antes que subir la escritura a la raíz. El segundo es registrar como no heredables todas las propiedades que solo consume el propio elemento, porque eso corta la propagación de la invalidación de raíz y es gratis: la declaración de registro no cambia ni una línea del CSS que la usa. Entre las dos, el mismo código que hacía saltar el perfil pasa a no aparecer en él. Y de paso, esa disciplina te obliga a saber en todo momento quién consume cada parámetro, que es exactamente la información que hace mantenible un sistema de tokens.

⚔️ Mide la escritura
  1. Escribe una custom property en :root en cada fotograma y mira el tiempo de recálculo de estilo.
  2. Repite escribiéndola en el contenedor del efecto y compara.
  3. Lee un token con getPropertyValue y comprueba que necesita .trim().
  4. Monta el efecto de puntero completo y verifica que no hay lecturas geométricas entre escrituras.
  5. Convierte un estado que hoy guardas en una custom property a un atributo y enumera qué ganas.