Implementar la capa de tokens
Dónde viven los tokens en la cascada, cómo se tipan con @property y qué gana el sistema al hacerlo, y por qué la capa de tokens debe ser la primera y no producir ni una regla visible.
Los tres niveles de token describen una estructura conceptual; ahora hay que colocarla en la cascada sin que estorbe. Las decisiones son pocas pero no son obvias: en qué capa van, qué se hereda y qué no, cuáles conviene registrar con @property y cuáles no, y dónde exactamente se sobrescriben los valores cuando llega un tema. Hacerlo mal produce un sistema que funciona hasta el primer subárbol con tema propio y ahí se rompe de una forma difícil de depurar.
- Colocar los tokens en la capa correcta y saber por qué es la primera.
- Elegir el selector de declaración según si el token debe heredarse o no.
- Registrar tokens con
@propertyy enumerar las tres cosas que se ganan. - Sobrescribir tokens en un subárbol sin romper el resto del sistema.
Dónde van en la cascada
La capa de tokens es la primera capa declarada, y esa posición no es simbólica.
@layer tokens, reset, base, componentes, utilidades;
El motivo es que una declaración de custom property es una declaración como cualquier otra y participa en la cascada. Si la capa de tokens fuera la última, cualquier sobrescritura hecha desde un componente perdería contra ella, y el mecanismo de configuración de componentes que se explica en la lección sobre contratos dejaría de funcionar. Al ponerla primera, todo lo demás puede sobrescribir tokens, que es exactamente el comportamiento que quieres.
La capa de tokens tiene además una propiedad que conviene declarar en voz alta y hacer cumplir: no produce ni una sola regla visible. Solo asignaciones de custom properties. Nada de body { background: ... }, nada de tipografía, nada de reset. Si un token necesita aplicarse a algo, eso va en la capa base. Mantener la capa de tokens estéril es lo que permite generarla desde un fichero de definiciones sin que nadie tenga que revisar CSS a mano.
@layer tokens {
:root {
/* primitivos */
--azul-600: oklch(0.55 0.18 265);
--gris-50: oklch(0.98 0.004 265);
--gris-900: oklch(0.22 0.012 265);
--esp-4: 1rem;
/* semanticos */
--color-accion: var(--azul-600);
--superficie: var(--gris-50);
--texto: var(--gris-900);
}
}
@layer base {
body {
background: var(--superficie);
color: var(--texto);
}
}
:root no siempre es el sitio
Declarar todo en :root es lo correcto para primitivos y para el conjunto semántico por defecto, porque las custom properties se heredan y desde la raíz llegan a todo el documento. Pero hay dos casos donde :root es el sitio equivocado.
Cuando el token debe cambiar por subárbol. Un panel con tema oscuro dentro de una página clara necesita sus propios semánticos. La forma correcta es redeclarar los semánticos —nunca los primitivos— en el selector del subárbol:
[data-tema="oscuro"] {
--superficie: var(--gris-900);
--texto: var(--gris-50);
--borde: var(--gris-700);
color-scheme: dark;
}
Como los componentes leen semánticos y los semánticos se heredan, todo lo que haya dentro de ese subárbol cambia sin que ninguna regla de componente sepa nada. Esa es la propiedad que hace que las custom properties sean cualitativamente distintas de las variables de preprocesador: se resuelven en el elemento donde se usan, no donde se declaran.
Cuando el token no debe heredarse. Un token de componente que configura un botón no tiene ningún motivo para filtrarse a los hijos del botón. Si lo declaras en .boton se hereda hacia dentro, y un icono dentro del botón que también use --boton-fondo por casualidad recibirá el valor. Es raro que importe, pero cuando importa es un bug difícil de ver. La herramienta para evitarlo es registrar el token con inherits: false.
@property: qué se gana al tipar un token
Registrar una custom property con @property la convierte de una cadena de texto opaca en un valor con tipo. Se ganan tres cosas concretas.
@property --color-accion {
syntax: "<color>";
inherits: true;
initial-value: oklch(0.55 0.18 265);
}
@property --sombra-elevacion {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
Primera: validación. Sin registrar, --color-accion: 16px es perfectamente válido como custom property; el error solo aparece más abajo, donde se usa, y allí el valor es “inválido en tiempo de computación”, lo que hace que la propiedad tome su valor heredado o inicial. El resultado es un color que viene de un sitio que no esperas y un rastro imposible de seguir. Registrada, la asignación inválida se rechaza en el punto de asignación y el token conserva su valor inicial. El fallo pasa de misterioso a local.
Segunda: valor inicial garantizado. Un token registrado siempre tiene valor, incluso si nadie lo declara. Eso elimina la necesidad de escribir cadenas de reserva por todas partes y, más importante, elimina el estado “esta propiedad no existe”, que es el que produce elementos invisibles.
Tercera: interpolación. Un token registrado con un tipo animable se puede animar y transicionar. Sin registrar, un cambio de --color-accion salta; registrado como <color>, transiciona. Es lo que hace posible animar gradientes y ángulos, y es la razón por la que @property existió antes de que a nadie le importara la validación.
Registrar tiene un coste: cada @property obliga al motor a validar y a mantener un valor calculado por elemento para esa propiedad. En una escala de espaciado de doce pasos que nunca se anima y nunca se asigna dinámicamente, registrar no aporta nada. El criterio útil es registrar lo que se asigna desde fuera —los tokens de componente y los que toca JavaScript— y lo que se anima. El resto, no.
Sobrescribir sin romper
El error clásico al aplicar un tema o una variante es sobrescribir el nivel equivocado. Merece la pena tener la tabla clara:
| Qué quieres cambiar | Qué sobrescribes | Dónde |
|---|---|---|
| El color de marca del producto | primitivos | :root |
| El aspecto de una zona (tema) | semánticos | el selector de la zona |
| Un componente en un sitio concreto | tokens de componente | el selector del contexto |
| Un componente en todo el producto | los valores por defecto del componente | su propia regla |
La violación típica es sobrescribir primitivos para lograr un tema: [data-tema="oscuro"] { --gris-50: #111 }. Funciona a la primera vista y es una bomba de relojería, porque ahora --gris-50 contiene un gris oscuro y cualquier uso legítimo de ese primitivo en el subárbol —una sombra, un borde decorativo, un degradado— se invierte sin motivo. Los primitivos son constantes; los semánticos son asignaciones. Solo se reasigna lo segundo.
Y una nota sobre JavaScript: cuando hay que tocar un token en runtime, se toca el semántico o el de componente, en el elemento más cercano posible, nunca en :root salvo que el cambio sea global de verdad.
tarjeta.style.setProperty('--tarjeta-acento', color);
Escribir en :root desde JavaScript invalida el estilo calculado de todo el documento, porque cualquier elemento podría depender de esa propiedad. Escribir en el elemento afectado invalida su subárbol. En una interfaz que actualiza tokens con frecuencia —un selector de color en vivo, un ajuste de densidad— esa diferencia es medible.
Este es el modo de fallo que más tiempo hace perder en sistemas de tokens, y es sutil porque no produce ningún error. Cuando escribes color: var(--texto-tenue) y --texto-tenue no está declarada en ningún ancestro, no ocurre nada dramático: var() no resuelve, la declaración pasa a ser inválida en tiempo de computación, y la propiedad color toma su valor heredado. Es decir, el texto se pinta con el color del padre. En una tarjeta sobre fondo claro eso suele parecerse bastante a lo correcto, y nadie nota nada. El día que esa tarjeta se coloca dentro de un panel oscuro, el texto que debía ser tenue se pinta con el color heredado del panel —blanco— y aparece un bug que se manifiesta solo en un contexto, que no reproduce nadie, y que en las herramientas se ve como un valor calculado correcto porque es correcto: es exactamente lo que la cascada manda hacer. La defensa tiene dos capas y conviene poner las dos. La primera es @property con initial-value para todos los tokens que un componente pueda leer sin garantía de que estén declarados: eso convierte el agujero en un valor conocido. La segunda es que los tokens de componente lleven siempre una reserva explícita en la propia llamada, var(--boton-fondo, var(--color-accion)), de modo que el componente sea usable aunque lo saques de tu sistema y lo pegues en otro proyecto. La regla general, que vale para cualquier sistema con indirección: si el valor por defecto de una referencia rota es “lo que haya heredado”, el sistema no falla, miente, y una mentira que se parece a la verdad en el noventa por ciento de los casos es mucho peor que un error.