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:
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.