Módulo 2: Design Tokens

CSS custom properties: los tokens en el navegador

Descripción

Hasta ahora los tokens vivieron en un objeto de JavaScript, y la cadena de tres capas la recorrió resolveToken. Pero en una app real el navegador no ejecuta tu resolveToken en cada elemento: los tokens tienen que existir como CSS, en un formato que el navegador entienda nativamente. Ese formato son las CSS custom properties —también llamadas variables de CSS—: nombres que empiezan con -- y que guardan un valor reutilizable. --color-primary: #2563eb; declara un token; background: var(--color-primary); lo referencia. Esta lección es el puente entre el modelo mental de las tres capas y el CSS que de verdad corre en el navegador.

La pieza clave es entender que las custom properties son el vehículo, no el concepto. Un design token es la idea (un valor nombrado por su rol, en tres capas); una custom property de CSS es cómo esa idea se escribe para que el navegador la use. Y traen tres regalos que las hacen perfectas para tokens: se declaran una vez en un lugar central (:root), se heredan por todo el árbol del documento (todo elemento las ve), y se pueden redefinir en un contexto más específico —lo que, en la lección 6, hará posible el tema oscuro con una sola regla extra—. En esta lección las conoces y ejecutas un emisor que genera el CSS real a partir de tu set de tokens.

Conexión con el módulo. La lección 3 estructuró los tokens en tres capas dentro de un objeto de JavaScript; esta los baja al navegador como CSS custom properties. Es el paso de "modelo" a "código que corre": el --color-primary: #2563eb que emites aquí es, literalmente, lo que Tailwind consumirá en el módulo 3 y lo que el theming de la lección 6 redefinirá para el tema oscuro. Aquí ves el vehículo real de los tokens; las lecciones siguientes lo conducen.

Una analogía: el tablero de interruptores de la casa

Imagina una casa con muchas lámparas. Hay dos formas de cablearla.

La primera: cada lámpara tiene su propio interruptor pegado al lado, y cada uno está cableado directo a su bombilla con su propio ajuste de intensidad. Si quieres bajar la luz de toda la casa, recorres cuarto por cuarto ajustando cada interruptor. Es el CSS ad-hoc: cada elemento con su valor escrito al lado.

La segunda: la casa tiene un tablero central en la entrada, con un interruptor rotulado por función —"luz principal", "luz de ambiente", "luz de trabajo"—. Cada lámpara no está cableada a un valor fijo, sino conectada al circuito "luz de ambiente". Ajustas el tablero central una vez y todas las lámparas de ese circuito responden juntas. Ese tablero central es el bloque :root; cada interruptor rotulado es una custom property (--color-primary, --color-surface); y cada lámpara conectada a un circuito es un elemento que usa var(--color-primary).

La analogía tiene dos consecuencias que son, tal cual, por qué las custom properties sirven para tokens. La conexión llega a toda la casa: el tablero de la entrada gobierna las lámparas de todos los cuartos, porque el circuito recorre la casa entera —esa es la herencia: declaras en :root y todo el documento lo ve—. Y puedes poner un sub-tablero en un cuarto: si el estudio necesita luces más cálidas, instalas ahí un tablero local que redefine "luz de ambiente" solo para ese cuarto, sin tocar el resto —esa es la redefinición en contexto, la que hará el tema oscuro—. Un tablero central, rótulos por función, y sub-tableros que redefinen sin recablear: exactamente lo que un sistema de tokens necesita.

Cómo se escribe: declarar, referenciar, redefinir

Tres gestos, y con ellos tienes toda la mecánica.

Declarar. Una custom property se define como cualquier propiedad de CSS, pero su nombre empieza con dos guiones (--). Se suele declarar en el selector :root —que representa el elemento raíz del documento (<html>)— para que quede disponible en todas partes:

:root {
  /* tokens semanticos como custom properties */
  --color-primary: #2563eb;
  --color-surface: #ffffff;
  --color-text:    #111827;
}

Referenciar. Para usar el valor de una custom property, se envuelve su nombre en la función var(). Donde antes escribías un valor crudo, ahora escribes una referencia al token:

.button {
  background: var(--color-primary);   /* en vez de: background: #2563eb; */
  color:      var(--color-surface);
}
.card {
  background: var(--color-surface);
  color:      var(--color-text);
}

El navegador, al pintar .button, busca --color-primary (la encuentra en :root, porque se hereda) y usa su valor. El botón "sabe" que su fondo es --color-primary; qué vale eso hoy lo decide el tablero central. Es la misma referencia-en-vez-de-copia del módulo entero, ahora en CSS nativo.

Redefinir en contexto. Una custom property se puede volver a declarar en un selector más específico, y ahí adentro toma el nuevo valor —sin tocar los elementos que la usan—. Esto es el sub-tablero del estudio, y es el mecanismo del tema oscuro (lección 6):

.dark {
  /* mismos nombres, otros valores: el tema oscuro */
  --color-primary: #60a5fa;
  --color-surface: #111827;
  --color-text:    #f9fafb;
}

Cualquier elemento dentro de un contenedor con class="dark" verá --color-surface valiendo #111827 en vez de #ffffff —y como .card usa var(--color-surface), su fondo se vuelve oscuro sin cambiar una línea de .card—. El .button y la .card no saben que existe un tema oscuro; solo referencian tokens, y el token cambió de valor bajo ellos. Guarda esta imagen; la lección 6 la desarrolla entera.

Una nota sobre las tres capas de la lección 3. En CSS puedes reflejar la cadena literalmente, haciendo que un token de componente referencie el semántico con var():

:root {
  --blue-600:      #2563eb;                 /* primitivo */
  --color-primary: var(--blue-600);         /* semantico -> primitivo */
  --button-bg:     var(--color-primary);    /* de componente -> semantico */
}
.button { background: var(--button-bg); }   /* la pieza -> de componente */

Ahí tienes las tres flechas de la lección 3 escritas en CSS: --button-bg → --color-primary → --blue-600 → #2563eb. El navegador resuelve esa cadena de var() igual que lo hizo tu resolveToken. En la práctica, muchos sistemas escriben en :root solo los valores ya resueltos por tema (más simple y rápido de leer) y guardan la cadena de tres capas en la fuente de los tokens; eso es lo que hará el emisor del ejemplo trabajado. Las dos formas son válidas: lo que nunca cambia es que la pieza referencia un token, no un valor crudo.

Ejemplo trabajado: emitir el CSS de cada tema

El navegador no corre en un agente, así que no podemos "pintar" el CSS. Pero sí podemos generarlo: tomamos el set de tokens de la lección 3 y escribimos un emitVars(selector, theme) que, para cada token semántico, lo resuelve a su valor final y emite la línea --nombre: valor;. Correrlo para ('root', 'light') y ('.dark', 'dark') produce el CSS exacto que escribirías a mano —el tablero central y su sub-tablero—:

// L4 — de tokens a CSS: emitir las custom properties reales de cada tema.
const tokens = {
  primitives: {
    'blue.600': '#2563eb',
    'blue.400': '#60a5fa',
    'gray.50':  '#f9fafb',
    'gray.900': '#111827',
    'white':    '#ffffff',
  },
  semantics: {
    'color.primary': { light: 'blue.600', dark: 'blue.400' },
    'color.surface': { light: 'white',    dark: 'gray.900' },
    'color.text':    { light: 'gray.900', dark: 'gray.50'  },
  },
};

function resolveToken(name, theme) {
  if (name in tokens.semantics)  return resolveToken(tokens.semantics[name][theme], theme);
  if (name in tokens.primitives) return tokens.primitives[name];
  throw new Error('Token desconocido: ' + name);
}

// color.primary -> --color-primary ; una custom property por token semantico.
function emitVars(selector, theme) {
  const lines = [selector + ' {'];
  for (const name of Object.keys(tokens.semantics)) {
    const cssName = '--' + name.split('.').join('-');
    lines.push('  ' + cssName + ': ' + resolveToken(name, theme) + ';');
  }
  lines.push('}');
  return lines.join('\n');
}

console.log(emitVars(':root', 'light'));
console.log(emitVars('.dark', 'dark'));

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

:root {
  --color-primary: #2563eb;
  --color-surface: #ffffff;
  --color-text: #111827;
}
.dark {
  --color-primary: #60a5fa;
  --color-surface: #111827;
  --color-text: #f9fafb;
}

Lee la salida como lo que es: el CSS de tus tokens, generado desde el modelo. Esas siete líneas no las escribió una persona; las emitió el emitVars recorriendo el set de tokens y resolviendo cada semántico con la misma lógica de la lección 3. Y son, carácter por carácter, el CSS que pegarías en tu hoja de estilos. Fíjate en el nombre: color.primary (el token en el modelo) se volvió --color-primary (la custom property en CSS) —el punto pasó a guion, se antepuso --—; es la traducción directa del identificador del token a su forma de CSS.

Fíjate en las dos mitades, porque son el tablero y el sub-tablero de la analogía. El bloque :root es el tablero central del tema claro: ahí --color-surface vale #ffffff (blanco). El bloque .dark es el sub-tablero del tema oscuro: los mismos tres nombres, otros tres valores —--color-surface ahora vale #111827 (casi negro)—. Ningún componente aparece en esta salida. El .button y la .card que referencian estas variables no cambian entre un bloque y otro; lo único que cambia es el valor que las variables tienen según el elemento esté o no dentro de .dark. Ese es el corazón del theming, y lo viste emitido: dos bloques, mismos nombres, valores distintos.

Una pregunta para razonar el mecanismo: si un .card usa background: var(--color-surface) y lo metes dentro de un <div class="dark">, ¿qué fondo pinta, y por qué no tuviste que tocar el .card? (Pinta #111827, porque dentro de .dark la custom property --color-surface fue redefinida a ese valor, y .card simplemente lee "lo que valga --color-surface aquí". El .card referencia el token; el token cambió de valor por el contexto. Es la redefinición en contexto —el sub-tablero— haciendo su trabajo.)

Por qué custom properties y no las variables de Sass o un objeto de JS

Quizás te preguntes por qué usar custom properties de CSS y no, por ejemplo, variables de un preprocesador como Sass ($color-primary) o el objeto de JavaScript que hemos usado. La diferencia es cuándo existe la variable, y es decisiva para el theming.

Las variables de Sass y las de JavaScript se resuelven antes de que el CSS llegue al navegador: en el momento de compilar, $color-primary se reemplaza por #2563eb y desaparece —el navegador nunca ve la variable, solo el valor final ya "horneado"—. Eso significa que no puedes cambiarlas en vivo: para un tema oscuro tendrías que generar dos hojas de estilo completas y cargar una u otra. Las custom properties de CSS, en cambio, viven en el navegador: el --color-primary sigue siendo una variable mientras la página corre, y por eso puede tener un valor en :root y otro dentro de .dark, o cambiar al vuelo con JavaScript. Son dinámicas. Para tokens que necesitan cambiar por tema —o por contexto— esa cualidad es justo la que hace falta, y por eso son el vehículo estándar de los design tokens en la web moderna. (Los detalles de rendimiento —cómo el navegador purga y aplica estas variables a escala— son de fullstack-performance-and-deployment; aquí basta con saber por qué las elegimos.)

Errores comunes

Confundir el token con su custom property y creer que "ya sé tokens porque sé variables de CSS". Qué pasa: alguien declara --blue: #2563eb y --padding: 16px sueltos en :root y cree que tiene un sistema de tokens. Por qué pasa: la forma final de un token sí es una custom property, así que parece que ahí se acaba. Cómo detectarlo: tus custom properties están nombradas por valor (--blue) y no por rol (--color-primary), y no reflejan ninguna estructura de capas. Cómo corregirlo: la custom property es el vehículo; el token es la idea —un rol, en capas, resoluble—. Escribir --blue: #2563eb es tener el vehículo sin el diseño: el día que la marca sea verde, tendrás una variable --blue que vale verde. La lección 5 fija esto como regla dura; por ahora, recuerda que declarar en CSS no te exime de nombrar por rol.

Escribir el valor crudo en el componente "y de paso" también como variable. Qué pasa: se declara --color-primary: #2563eb en :root, pero en un componente se escribe background: #2563eb directo en vez de var(--color-primary). Por qué pasa: teclear el hex es más rápido que recordar la variable, y "total, es el mismo valor". Cómo detectarlo: buscas #2563eb en tu CSS y aparece fuera de :root. Cómo corregirlo: un valor crudo escrito en un componente rompe la conexión con el tablero central —ese elemento ya no responde al cambio de tema ni al rebranding, porque no está "conectado al circuito", tiene su propio cable—. Fuera de la declaración del token en :root (y su override de tema), nunca aparece el valor crudo; solo var(--nombre). Un #hex en un componente es una lámpara desconectada del tablero.

Declarar las custom properties fuera de :root sin querer. Qué pasa: se declaran los tokens dentro de un componente específico (.header { --color-primary: ...; }) en vez de en :root, y luego otros componentes no "ven" la variable. Por qué pasa: se copia la declaración al lugar donde se usa por primera vez. Cómo detectarlo: var(--color-primary) funciona en unos elementos y en otros sale vacío o con el valor por defecto. Cómo corregirlo: las custom properties se heredan hacia abajo en el árbol —un elemento solo ve las declaradas en sí mismo o en un ancestro—. Los tokens globales van en :root (el ancestro de todo), para que todo el documento los herede. Declararlas en un componente las encierra ahí: el resto de la casa se queda sin ese circuito. Reserva las declaraciones locales para lo que quieres que sea local —justamente, el override de .dark—.

Ejercicios

Ejercicio 1 — Traduce el token a CSS. Para cada token del modelo, escribe (a) su nombre como custom property y (b) cómo lo referenciaría un componente:

  • (a) color.primary
  • (b) color.surface
  • (c) space-4 (imagina que también lo emites como variable)
Ver solución

Aplicando la traducción del ejemplo (punto → guion, anteponer --, envolver en var() para usarlo):

TokenCustom propertyReferencia en un componente
color.primary--color-primarybackground: var(--color-primary);
color.surface--color-surfacebackground: var(--color-surface);
space-4--space-4padding: var(--space-4);

La regla mecánica: el identificador del token se vuelve el nombre de la custom property (con -- delante y los puntos como guiones), y usarlo siempre pasa por var(...). El token space-4 ya venía con guion, así que solo se le antepone --.

Ejercicio 2 — Predice el CSS emitido. Sin correr nada, di qué imprimiría emitVars(':root', 'light') si al set de tokens le agregamos este semántico:

tokens.semantics['color.muted'] = { light: 'gray.50', dark: 'gray.900' };

(Recuerda que gray.50 = #f9fafb.)

Ver solución

emitVars recorre todos los semánticos en orden, así que el bloque :root tendría una línea más al final:

:root {
  --color-primary: #2563eb;
  --color-surface: #ffffff;
  --color-text: #111827;
  --color-muted: #f9fafb;
}

La línea nueva es --color-muted: #f9fafb; —el nombre color.muted se volvió --color-muted, y en tema light el semántico apunta a gray.50, que vale #f9fafb—. No hubo que tocar emitVars: agregar un token es agregar una entrada al set, y el emisor lo recorre solo. Ese es el punto de generar el CSS desde el modelo en vez de escribirlo a mano.

Ejercicio 3 — Conecta la lámpara al circuito. Aquí está el CSS de un .badge escrito con valores crudos, y el bloque :root de tokens ya declarado. Reescribe el .badge para que referencie los tokens en vez de copiar sus valores, y explica qué gana con el cambio para el tema oscuro.

:root {
  --color-primary: #2563eb;
  --color-surface: #ffffff;
}
.badge {
  background: #2563eb;
  color: #ffffff;
}
Ver solución

Cada valor crudo se reemplaza por var() del token cuyo valor coincide:

.badge {
  background: var(--color-primary);
  color: var(--color-surface);
}

Qué gana: el .badge deja de tener valores propios y queda conectado al tablero central. En el tema claro se ve igual que antes (--color-primary vale #2563eb). Pero el día que agregues un bloque .dark { --color-primary: #60a5fa; --color-surface: #111827; }, el .badge dentro de un contenedor oscuro cambiará de color solo, sin tocar su regla —porque referencia los tokens y estos cambian de valor por contexto—. Con los valores crudos escritos, el .badge habría quedado azul-sobre-blanco incluso en tema oscuro: una lámpara desconectada del tablero, insensible al interruptor. Referenciar es lo que lo hace parte del sistema.

Resumen y siguiente paso

En esta lección bajaste los tokens al navegador: las CSS custom properties son el vehículo real de los design tokens. Se declaran con --nombre: valor (normalmente en :root, el tablero central), se referencian con var(--nombre), y se pueden redefinir en contexto —el sub-tablero, que en la lección 6 será el tema oscuro—. Con la casa de interruptores viste sus dos regalos: la herencia (declaras en :root y todo el documento lo ve) y la redefinición local (un .dark cambia el valor sin recablear los componentes). Ejecutaste emitVars y viste el CSS de tus tokens generado desde el modelo: el bloque :root del tema claro y el .dark del oscuro, mismos nombres y valores distintos. Y viste por qué custom properties y no variables de Sass: viven en el navegador, así que pueden cambiar por tema en vivo.

Antes de avanzar deberías poder: declarar, referenciar y redefinir una custom property; explicar por qué van en :root; traducir un token del modelo a su forma de CSS; y decir por qué las custom properties (y no las de Sass) sirven para el theming.

La lección 5 vuelve sobre la capa del medio con una regla que ya rozamos tres veces: nombrar los tokens por su rol, no por su valor. Viste el peligro asomando —--blue que un día vale verde, blue = #16a34a—; ahora lo convertimos en la regla dura que decide si tu capa semántica sobrevive a un rebranding o se rompe con él. Es corta pero es la que más veces te va a salvar en la vida real de un sistema.

Recursos