El día que una marca cambia su color de acento empieza la cacería. Alguien abre el buscador del IDE, escribe #b14a2d y salen cuarenta resultados. Prueba con #B14A2D y salen doce más. Faltan todavía el Sass del portal viejo, la app de Android y una plantilla de email que nadie toca desde 2019. Cuando el equipo termina, dos meses después, aparece la pregunta que nadie sabe responder: ¿esos cuarenta usos eran el mismo color por la misma razón, o cuatro decisiones distintas que casualmente coincidían en hexadecimal?

Esa pregunta es el coste real. No el rastreo, que es tedioso pero acotado, sino la incertidumbre que queda después. En un sistema sin tokens nadie puede demostrar que dos valores iguales significan lo mismo, y a partir de ahí cada cambio de marca, cada tema nuevo y cada revisión de contraste vuelve a empezar desde cero.

En esta web hay 630 declaraciones de token repartidas en diez archivos, de las cuales 136 son valores literales, y ninguno de los catorce componentes de interfaz contiene un color escrito a mano. Para explicar por qué eso convierte un rebranding en un cambio de dos líneas no hace falta recorrer el sistema entero: basta con un componente. El botón es el mejor caso porque concentra casi todo lo difícil, tiene fondo, texto, icono, borde, estados, variantes, tipografía propia y anillo de foco, y porque es el componente que más veces se forkea en los sistemas mal montados.

Las dos situaciones, la de la cacería y la del sistema con la cadena montada, puestas una al lado de la otra:

Diagrama en dos mitades. A la izquierda, «sin tokens»: el hexadecimal #b14a2d repetido y con distintas mayúsculas en fragmentos de CSS, Sass, plantillas de email y una app de Android, rodeado de interrogaciones, con el pie «2 meses buscando hexadecimales y plantillas olvidadas». A la derecha, «con tokens»: la cadena de cuatro niveles, core, static, theme y componente, resolviendo el fondo de un botón de ejemplo; el cambio de marca de terracota a lima se aplica en la capa de tema con dos líneas de CSS y su verificación de contraste de 13,88:1, sin tocar ningún componente.

Un token es un nombre estable para una decisión

--p4-color-action-base-bg no significa «negro». Significa «el fondo de la acción principal en el tema activo». Que hoy resuelva a #050505 es un detalle de implementación, y por eso puede cambiar sin que nadie abra el componente.

La distinción suena de manual hasta que se aplica. Casi todos los sistemas que he visto de cerca fallan en el mismo punto: el botón consume el negro en vez de la intención. Funciona igual de bien durante seis meses, y luego llega el rebranding, o el modo oscuro, o una landing con fondo invertido.

Lo que convierte el nombre en algo útil no es su existencia, sino que sea el único camino. Un token al que se puede dar la vuelta escribiendo el hexadecimal al lado no es una fuente de verdad, es una sugerencia con nombre largo.

La cadena que resuelve el fondo de un botón

Entre el valor literal y la propiedad CSS que pinta el fondo del botón hay cuatro saltos, y cada uno responde a una pregunta distinta.

/* 1. Core: un valor, ninguna decisión de producto */
--p4-color-slate-970: #050505;

/* 2. Static: qué papel juega dentro de la paleta */
--p4-color-neutral-970: var(--p4-color-slate-970);

/* 3. Theme: qué usa la acción principal en tema claro */
--p4-color-action-base-bg: var(--p4-color-neutral-970);

/* 4. Componente: la variable privada que pinta */
--_color-bg: var(--p4-button-color-bg, var(--p4-color-action-base-bg));

El primer nivel es la paleta cruda. Un #050505 no sabe si es un fondo, un borde o un texto, y ahí está su valor: es auditable. La pregunta «cuántos neutros hay en producción» tiene respuesta exacta porque todos viven en un archivo de 136 declaraciones y ninguno se referencia fuera de la capa siguiente.

El segundo nivel nombra el papel dentro de la paleta sin comprometerse con un uso. --p4-color-neutral-970 es el extremo oscuro de la escala, sea slate hoy o una familia de neutros cálidos mañana. Es la capa que permite cambiar de paleta sin tocar los temas.

El tercero es el primero que puede variar por contexto, y ahí está la decisión de tema. En claro, la acción principal es fondo casi negro con texto casi blanco. En oscuro, exactamente lo contrario:

:root, [data-theme='light'] {
  --p4-color-action-base-bg: var(--p4-color-neutral-970);  /* #050505 */
  --p4-color-action-base-fg: var(--p4-color-neutral-30);   /* #fafafb */
}

[data-theme='dark'] {
  --p4-color-action-base-bg: var(--p4-color-neutral-30);
  --p4-color-action-base-fg: var(--p4-color-neutral-970);
}

El cuarto nivel es el componente, y merece su propia sección porque no es una capa de tokens sino una API.

Los roles viajan en familia

--p4-color-action-base-bg no está solo. La familia completa son cuatro propiedades, fondo, texto, icono y borde, duplicadas para el estado hover, y hay tres familias de acción en cada tema: base, ghost y accent. Veinticuatro tokens por tema, definidos en pares.

Que el par fondo/texto viaje junto es lo que hace que el sistema sea auditable en accesibilidad. El acento del sistema es un lima #c6e070, y su token de texto está fijado a --p4-color-neutral-970 en los dos temas, no heredado del color de texto genérico. La razón es aritmética: ese lima con texto casi negro da 13,88:1, y con el texto claro del tema oscuro daría 1,41:1, ilegible. Si el botón consumiera «el color de acento» y «el color de texto» como dos decisiones independientes, el modo oscuro produciría exactamente esa combinación y nadie lo notaría hasta la primera auditoría.

De paso, ese token ilustra una regla de la arquitectura que confunde a casi todo el mundo la primera vez: un token vive en la capa de tema porque puede variar por tema, no porque varíe. El acento vale hoy lo mismo en claro y en oscuro. Sigue estando en los dos archivos de tema, porque el día que la marca decida un acento distinto para oscuro no habrá que mover el token de sitio, solo cambiarle el valor.

La API del botón son veintiséis variables

Esta es la cabecera real del componente, recortada a las declaraciones que importan para el argumento:

.p4-button {
  --_color-fg: var(--p4-button-color-fg, var(--p4-color-action-base-fg));
  --_color-icon: var(--p4-button-color-icon, var(--p4-color-action-base-icon));
  --_color-bg: var(--p4-button-color-bg, var(--p4-color-action-base-bg));
  --_color-border: var(--p4-button-color-border, var(--p4-color-action-base-border));

  --_font-family: var(--p4-button-font-family, var(--p4-typo-action-m-font-family));
  --_font-size: var(--p4-button-font-size, var(--p4-typo-action-m-font-size));
  --_line-height: var(--p4-button-line-height, var(--p4-typo-action-m-line-height));

  --_min-height: var(--p4-button-min-height, var(--p4-spacing-2xl));
  --_padding-block: var(--p4-button-padding-block, var(--p4-spacing-3xs));
  --_padding-inline: var(--p4-button-padding-inline, var(--p4-spacing-md));
  --_gap: var(--p4-button-gap, var(--p4-spacing-xs));

  --_color-bg-hover: var(--p4-button-color-bg-hover, var(--p4-color-action-base-bg-hover));

  display: var(--_display);
  padding: var(--_padding-block) var(--_padding-inline);
  border: solid var(--_border-width) var(--_color-border);
  background: var(--_color-bg);
  color: var(--_color-fg);
  font-family: var(--_font-family);
  font-size: var(--_font-size);
  min-height: var(--_min-height);
  transition: background-color var(--p4-motion-duration-base) var(--p4-motion-easing-base),
    border-color var(--p4-motion-duration-base) var(--p4-motion-easing-base),
    color var(--p4-motion-duration-base) var(--p4-motion-easing-base);
}

Son veintiséis declaraciones con la misma forma, y esa forma es el patrón entero:

--_algo: var(--p4-button-algo, var(--token-del-sistema));
 ↑          ↑                     ↑
 privada    puerta de escape      valor por defecto

La variable con guion bajo es interna. No es un token, no se documenta y nadie de fuera debería escribirla; su trabajo es que cada propiedad CSS del bloque se declare una sola vez en todo el archivo. Las variantes y los estados no repiten background ni padding: reasignan la privada y dejan que la declaración de abajo haga el resto.

El --p4-button-* del medio es la puerta de escape. Es la capa de tokens de componente, la que en la mayoría de los sistemas se descontrola hasta llegar a ochocientos nombres muertos. Aquí existe con una regla estricta: no se declara en ningún sitio. No hay un archivo de tokens de botón con valores. Solo aparece como primer argumento del var(), sin valor asignado, así que resuelve al segundo argumento salvo que alguien la defina desde fuera para un caso concreto.

El resultado es que el componente se puede ajustar sin forkearlo:

.hero .p4-button {
  --p4-button-padding-inline: var(--p4-spacing-2xl);
  --p4-button-min-height: var(--p4-spacing-3xl);
}

Ese botón sigue siendo el mismo componente, con el mismo tema, el mismo foco y el mismo comportamiento. Solo tiene más aire.

Aquí está el dato que más me convence de que la capa está bien dimensionada: siete archivos del sitio consumen el botón, entre cabecera, pie, cajón lateral, menú móvil, panel de temas y dos páginas, y ninguno ha necesitado escribir un --p4-button-*. La capa de componente vale precisamente cuando casi nunca se usa. Si estuviera llena, significaría que la capa de tema no cubre lo que debería.

Las variantes son una reasignación, no un componente nuevo

Un botón acento no es otro botón. Es el mismo, con otras ocho líneas:

.p4-button[data-variant='accent'] {
  --_color-fg: var(--p4-color-action-accent-fg);
  --_color-icon: var(--p4-color-action-accent-icon);
  --_color-bg: var(--p4-color-action-accent-bg);
  --_color-border: var(--p4-color-action-accent-border);
  --_color-fg-hover: var(--p4-color-action-accent-fg-hover);
  --_color-icon-hover: var(--p4-color-action-accent-icon-hover);
  --_color-bg-hover: var(--p4-color-action-accent-bg-hover);
  --_color-border-hover: var(--p4-color-action-accent-border-hover);
}

La variante no toca una sola propiedad CSS. Cambia de familia de rol dentro del tema activo, y todo lo demás, el espaciado, la tipografía, la transición, el foco, sigue siendo el del componente base. Añadir una variante nueva de color es declarar la familia en los dos temas y escribir este bloque de ocho líneas.

La variante naked es la que enseña dónde está el límite del patrón. Un botón que tiene que comportarse como un enlace dentro de un párrafo no puede llevar su propia tipografía:

.p4-button[data-variant='naked'] {
  --_color-fg: var(--p4-color-link);
  --_text-decoration-line: underline;
  --_font-family: inherit;
  --_font-size: inherit;
  --_line-height: inherit;
  --_padding-block: 0;
  --_padding-inline: 0;
  --_min-height: 0;
  --_display: inline;
}

El bloque completo son veintidós reasignaciones y ninguna propiedad nueva, porque inherit es un valor legítimo para una variable privada. El coste está en la última línea: al pasar a display: inline, ese botón deja de comportarse como caja flexible, y un <button> en línea no reparte el texto entre líneas igual que un <a>. Lo comprobé intentando que un botón naked envolviera exactamente como un enlace dentro de un párrafo largo, y no hay manera limpia de conseguirlo. Cuando el control tiene que fluir con el texto y romper en varias líneas, la respuesta correcta es un enlace real, no un botón disfrazado. El token no arregla la semántica del elemento, y no debe pretenderlo.

El foco y el movimiento tampoco son decisiones del componente

.p4-button:focus-visible {
  outline: var(--p4-focus-ring);
  outline-offset: var(--p4-focus-ring-offset);
}

--p4-focus-ring es un token compuesto, grosor más estilo más color, y el color es themeable: casi negro en claro, casi blanco en oscuro, 19,54:1 contra el fondo base en ambos casos. El botón no elige el grosor del anillo ni decide qué pasa en oscuro. Consume una decisión tomada una vez para todo el sistema, la misma que aplica la regla global de :focus-visible a cualquier otro elemento enfocable.

Con el movimiento pasa igual. La transición del botón usa --p4-motion-duration-base y --p4-motion-easing-base, y no lleva su propia regla de prefers-reduced-motion porque el reset del sistema ya reduce a 0,01 ms cualquier transición y animación cuando el usuario lo pide. Un componente que reimplementa esa regla no está siendo cuidadoso, está creando una segunda fuente de verdad para la misma decisión.

Modos: el botón no escribe media queries

La tipografía del botón sale de --p4-typo-action-m-*, y esa familia se declara tres veces, una por escalón de viewport: 12 px en móvil, 13 px de tableta en adelante. Son 65 tokens por escalón, en tres archivos superpuestos por cascada.

El componente no sabe nada de eso. Escribe var(--p4-typo-action-m-font-size) y el valor cambia al cruzar el breakpoint, igual que --p4-color-action-base-bg cambia al cruzar el tema. Tema y resolución son dos ejes independientes tratados con la misma mecánica: archivos que reasignan tokens ya existentes, nunca condicionales dentro del componente.

Consideré resolver la tipografía con clamp(), que ahorra dos archivos y es lo que hace hoy media industria. Lo descarté por trazabilidad con diseño: en Figma los tamaños viven en modos discretos de una colección, y una interpolación fluida no tiene correspondencia con eso. Cuando el token CSS y la variable de Figma son la misma lista de valores, revisar un diseño consiste en comparar dos tablas; con clamp() consiste en discutir sobre curvas. El coste es un salto perceptible al cruzar el breakpoint y un archivo más por cada eje nuevo.

Qué cuesta de verdad un rebranding

Cambiar el color de la acción principal de este sistema son dos declaraciones, una por tema. Todos los botones primarios del sitio, los del pie, los del cajón lateral, los del menú móvil y los de las páginas, cambian con ellas. El hover cambia porque su token pertenece a la misma familia. El texto sobre el botón cambia porque el par viaja junto. Ningún archivo de componente se toca.

Lo que hay que revisar después no es el código, es el contraste: comprobar que el nuevo par fondo/texto sigue por encima de 4,5:1 en los dos temas. Eso son diez minutos, y en el caso de los temas locales lo verifica un test que corre en npm test.

Compárese con el mismo cambio en un sistema donde el botón escribe sus colores. No es que haya que editar más archivos, que también. Es que hay que decidir, en cada uno, si ese negro concreto era «la acción principal» o «un texto oscuro que casualmente coincidía», y esa decisión no está escrita en ninguna parte. Ahí es donde se van los meses.

Un tema entero por región de página

El caso que mejor demuestra la arquitectura es el de las islas de tema: una región de la página con su propio contrato de color, anidada dentro del tema global.

<ThemeIsland theme="editorial" name="portada">
  <Button variant="accent">Leer el artículo</Button>
</ThemeIsland>

Por debajo hay dos atributos. data-theme aplica un tema completo del sistema, con su color-scheme, sus familias de acción y su anillo de foco ya verificados. data-island-theme sobrescribe encima solo las familias de superficie, con una indirección que hace que un tema local incompleto herede en lugar de romperse:

[data-island-theme] {
  --p4-color-surface-base-bg: var(--p4-island-color-surface-base-bg, inherit);
  --p4-color-surface-base-fg: var(--p4-island-color-surface-base-fg, inherit);
  /* … el resto de familias de superficie */
}

[data-island-theme='editorial'] {
  --p4-island-color-surface-base-bg: #f3dfba;
  --p4-island-color-surface-base-fg: #7a571c;
  /* … hasta dieciséis valores */
}

Añadir el tema «editorial» costó dieciséis valores y una línea en un registro de TypeScript que declara su esquema base. Esa línea no es decorativa: el esquema es la única metadata que el componente necesita para saber qué tema del sistema aplicar por debajo, y tenerla en TypeScript convierte el nombre del tema en un tipo, así que una errata en theme="editoral" falla en el build y no en producción.

Esto se puede comprobar en esta misma página. El icono de paleta de la cabecera abre el editor de temas, que enumera las islas del documento; el pie es una de ellas, con su propio tema. Al cambiárselo desde el desplegable, las superficies se reasignan en vivo, sin recargar y sin que ningún componente del pie sepa que ha ocurrido algo: los botones de navegación siguen consumiendo las mismas familias de rol que consumían hace un segundo.

El panel lleva además un constructor de sets. Los dieciséis campos de color van etiquetados con el nombre exacto del token que escriben, --p4-island-color-surface-base-bg y compañía, se eligen con un selector de color o escribiendo el hexadecimal, y hay un desplegable para el esquema base, que es la decisión que en el repositorio ocupa una línea del registro de TypeScript. Cada cambio se inyecta al momento sobre las islas que tengan seleccionado el set propio, así que el tema se ve aplicado sobre componentes reales mientras se elige el color. El bloque inferior genera el CSS resultante y lo deja listo para copiar: sale como [data-island-theme='custom'], y llevarlo al repositorio consiste en renombrar ese selector y pegarlo en tokens-island.css.

El botón no se entera de nada de esto. Y aquí aparece el límite real del diseño, que prefiero contar antes de que lo descubra alguien leyendo el CSS: los temas locales redefinen superficies, no acciones. Un botón dentro de una isla «editorial» sigue usando la familia action-* del esquema base de esa isla, que es el tema claro. Es una decisión consciente, porque las combinaciones de acción son las que sostienen el contraste y no quiero que un tema decorativo las reescriba a mano. El día que una isla necesite su propio color de acción, la familia action-* tendrá que entrar en el espejo de tokens de isla y pasar por la misma verificación de contraste que las superficies. No antes.

Las preguntas que dejan de hacerse

El ahorro no se mide en líneas de CSS. Se mide en decisiones que nadie vuelve a tomar.

Sin tokens, cada componente nuevo arrastra una tanda de preguntas pequeñas: qué negro lleva este botón, si su padding es el mismo que el del botón de al lado, cuánto mide de alto, qué le pasa en hover, qué le pasa en oscuro, si el anillo de foco es el de la marca o el del navegador. Cada una vale dos minutos para quien la resuelve solo y media hora para quien la resuelve en un hilo con tres personas. Multiplicado por los componentes de un producto y por los meses de un roadmap, ahí está el trimestre.

Con la cadena montada, la respuesta se escribe sola. El fondo es --p4-color-action-base-bg, el alto mínimo es --p4-spacing-2xl, el hover está en la misma familia que el reposo y en oscuro pasa lo que decidieron los dos bloques de tema. Lo que queda por decidir es lo que de verdad merece discusión, que casi siempre es jerarquía y comportamiento, no valores.

Dónde deja de compensar

Nada de esto es gratis. Cuatro niveles y una doble indirección por propiedad cuestan legibilidad: depurar el fondo de un botón en el inspector obliga a seguir la cadena hasta el literal, y a alguien que llega nuevo hay que explicarle por qué el mismo negro tiene tres nombres. Ese es el precio, y lo pago porque el sistema base tiene que sobrevivir a esta web y funcionar en otros proyectos con otras marcas.

En un producto único, con una sola identidad y dos personas de frontend, este montaje es burocracia. Ahí basta una capa semántica y la disciplina de no escribir un hexadecimal dentro de un componente. La arquitectura empieza a pagarse con el segundo consumidor del sistema y se vuelve obligatoria con la segunda marca.

Queda una pieza sin resolver, y prefiero decirlo que fingir que el sistema está cerrado. La regla que sostiene todo esto, que ningún componente referencia un primitivo directamente, hoy la sostiene la revisión y no una regla de lint. Funciona en un repositorio de una persona. En uno de quince, un token sin filtro automático dura lo que tarde alguien con prisa en escribir un hexadecimal.

Lo siguiente que quiero contar es la parte que más discusiones genera y menos artículos tiene: cómo se nombra un token sin acabar en --color-brand-primary-main-default-2. Ese nombre lo he visto en producción, y el problema que lo produjo no era de CSS.