The day a brand changes its accent colour, the hunt begins. Someone opens the IDE search, types #b14a2d and gets forty hits. Tries #B14A2D and gets twelve more. Still missing: the Sass in the old portal, the Android app, and an email template nobody has touched since 2019. When the team is done, two months later, a question shows up that nobody can answer: were those forty uses the same colour for the same reason, or four different decisions that happened to agree on a hexadecimal?

That question is the real cost. Not the hunt, which is tedious but bounded, but the uncertainty left behind. In a system without tokens nobody can prove that two identical values mean the same thing, and from then on every rebrand, every new theme and every contrast review starts from scratch.

This site has 630 token declarations spread across ten files, 136 of them literal values, and not one of its fourteen interface components contains a hand-written colour. Explaining why that turns a rebrand into a two-line change does not require a tour of the whole system: one component is enough. The button is the best case, because it concentrates almost everything that is hard, it has a background, text, icon, border, states, variants, its own typography and a focus ring, and because it is the component most often forked in badly built systems.

A token is a stable name for a decision

--p4-color-action-base-bg does not mean “black”. It means “the background of the primary action in the active theme”. That it resolves to #050505 today is an implementation detail, which is exactly why it can change without anyone opening the component.

The distinction sounds like textbook stuff until you apply it. Almost every system I have seen up close breaks at the same spot: the button consumes the black instead of the intent. Works just as well for six months, and then the rebrand lands, or dark mode does, or a landing page with an inverted background.

What makes the name useful is not that it exists, but that it is the only route. A token you can bypass by writing the hexadecimal next to it is not a source of truth, it is a suggestion with a long name.

The chain that resolves a button background

Between the literal value and the CSS property that paints the button background there are four hops, and each one answers a different question.

/* 1. Core: a value, no product decision */
--p4-color-slate-970: #050505;

/* 2. Static: what role it plays within the palette */
--p4-color-neutral-970: var(--p4-color-slate-970);

/* 3. Theme: what the primary action uses in light theme */
--p4-color-action-base-bg: var(--p4-color-neutral-970);

/* 4. Component: the private variable that paints */
--_color-bg: var(--p4-button-color-bg, var(--p4-color-action-base-bg));

The first level is the raw palette. A #050505 does not know whether it is a background, a border or a text colour, and that is where its value lies: it is auditable. The question “how many neutrals are in production” has an exact answer because they all live in a 136-declaration file and none of them is referenced outside the next layer.

The second level names the role within the palette without committing to a use. --p4-color-neutral-970 is the dark end of the scale, slate today or a family of warm neutrals tomorrow. It is the layer that lets the palette change without touching the themes.

The third is the first one that can vary by context, and that is where the theme decision lives. In light theme the primary action is a near-black background with near-white text. In dark theme, exactly the opposite:

: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);
}

The fourth level is the component, and it deserves its own section because it is not a token layer but an API.

Roles travel as a family

--p4-color-action-base-bg does not stand alone. The full family is four properties, background, text, icon and border, duplicated for the hover state, and there are three action families in each theme: base, ghost and accent. Twenty-four tokens per theme, defined in pairs.

That the background/text pair travels together is what makes the system auditable for accessibility. The system accent is a lime #c6e070, and its text token is pinned to --p4-color-neutral-970 in both themes rather than inherited from the generic text colour. The reason is arithmetic: that lime against near-black text gives 13.88:1, and against the light text of the dark theme it would give 1.41:1, unreadable. If the button consumed “the accent colour” and “the text colour” as two independent decisions, dark mode would produce exactly that combination and nobody would notice until the first audit.

That token also illustrates a rule of the architecture that confuses almost everyone the first time: a token lives in the theme layer because it can vary by theme, not because it does. The accent is worth the same in light and dark today. It still sits in both theme files, because the day the brand decides on a different accent for dark mode there will be no token to relocate, only a value to change.

The button API is twenty-six variables

This is the real header of the component, trimmed to the declarations that matter for the argument:

.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);
}

Twenty-six declarations with the same shape, and that shape is the whole pattern:

--_something: var(--p4-button-something, var(--system-token));
 ↑              ↑                          ↑
 private        escape hatch               default value

The underscore variable is internal. It is not a token, it is not documented and nobody outside should write it; its job is to make every CSS property in the block appear exactly once in the whole file. Variants and states do not repeat background or padding: they reassign the private variable and let the declaration below do the rest.

The --p4-button-* in the middle is the escape hatch. It is the component token layer, the one that in most systems grows out of control until it reaches eight hundred dead names. Here it exists under a strict rule: it is never declared anywhere. There is no button token file with values in it. It only appears as the first argument of a var(), unassigned, so it resolves to the second argument unless somebody defines it from the outside for a specific case.

The result is that the component can be adjusted without forking it:

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

That button is still the same component, with the same theme, the same focus behaviour and the same interaction. It just has more room.

Here is the figure that convinces me the layer is correctly sized: seven files across the site consume the button, between the header, the footer, the drawer, the mobile menu, the theme panel and two pages, and none of them has needed to write a --p4-button-*. The component layer earns its place precisely when it is almost never used. If it were full, that would mean the theme layer is not covering what it should.

Variants are a reassignment, not a new component

An accent button is not another button. It is the same one, with eight more lines:

.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);
}

The variant does not touch a single CSS property. It switches role family within the active theme, and everything else, the spacing, the typography, the transition, the focus ring, is still the base component’s. Adding a new colour variant means declaring the family in both themes and writing this eight-line block.

The naked variant is the one that shows where the pattern ends. A button that has to behave like a link inside a paragraph cannot carry its own typography:

.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;
}

The full block is twenty-two reassignments and no new property, because inherit is a legitimate value for a private variable. The cost is in the last line: once it becomes display: inline, that button stops behaving as a flex box, and an inline <button> does not wrap text across lines the way an <a> does. I found that out trying to make a naked button wrap exactly like a link inside a long paragraph, and there is no clean way to get there. When the control has to flow with the text and break across lines, the correct answer is a real link, not a button in disguise. The token does not fix the semantics of the element, and it should not pretend to.

Focus and motion are not the component’s decisions either

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

--p4-focus-ring is a composite token, width plus style plus colour, and the colour is themeable: near-black in light, near-white in dark, 19.54:1 against the base background in both cases. The button does not pick the ring width or decide what happens in dark mode. It consumes a decision taken once for the whole system, the same one the global :focus-visible rule applies to every other focusable element.

Motion works the same way. The button transition uses --p4-motion-duration-base and --p4-motion-easing-base, and it carries no prefers-reduced-motion rule of its own because the system reset already cuts every transition and animation to 0.01 ms when the user asks for it. A component that reimplements that rule is not being careful, it is creating a second source of truth for the same decision.

Modes: the button writes no media queries

The button typography comes from --p4-typo-action-m-*, and that family is declared three times, once per viewport step: 12 px on mobile, 13 px from tablet upwards. That is 65 tokens per step, across three files layered by the cascade.

The component knows none of it. It writes var(--p4-typo-action-m-font-size) and the value changes when the breakpoint is crossed, exactly as --p4-color-action-base-bg changes when the theme is. Theme and resolution are two independent axes handled with the same mechanism: files that reassign existing tokens, never conditionals inside the component.

I did consider solving typography with clamp(), which saves two files and is what half the industry does today. I ruled it out for traceability with design: in Figma sizes live in discrete modes of a collection, and fluid interpolation has no correspondence with that. When the CSS token and the Figma variable are the same list of values, reviewing a design means comparing two tables; with clamp() it means arguing about curves. The cost is a perceptible jump at the breakpoint and one more file per new axis.

What a rebrand actually costs

Changing the primary action colour of this system is two declarations, one per theme. Every primary button on the site, in the footer, the drawer, the mobile menu and the pages, changes with them. Hover changes because its token belongs to the same family. The text on the button changes because the pair travels together. No component file is touched.

What has to be reviewed afterwards is not the code, it is the contrast: checking that the new background/text pair stays above 4.5:1 in both themes. That is ten minutes, and for local themes a test running under npm test verifies it.

Compare that with the same change in a system where the button writes its own colours. It is not that there are more files to edit, though there are. It is that in each one you have to decide whether that particular black was “the primary action” or “a dark text that happened to match”, and that decision is written down nowhere. That is where the months go.

A whole theme per page region

The case that demonstrates the architecture best is theme islands: a region of the page with its own colour contract, nested inside the global theme.

<ThemeIsland theme="editorial" name="cover">
  <Button variant="accent">Read the article</Button>
</ThemeIsland>

Underneath there are two attributes. data-theme applies a complete system theme, with its color-scheme, its action families and its focus ring already verified. data-island-theme overrides only the surface families on top, through an indirection that makes an incomplete local theme inherit rather than break:

[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);
  /* … the rest of the surface families */
}

[data-island-theme='editorial'] {
  --p4-island-color-surface-base-bg: #f3dfba;
  --p4-island-color-surface-base-fg: #7a571c;
  /* … sixteen values in total */
}

Adding the “editorial” theme cost sixteen values and one line in a TypeScript registry declaring its base scheme. That line is not decorative: the scheme is the only metadata the component needs in order to know which system theme to apply underneath, and keeping it in TypeScript turns the theme name into a type, so a typo in theme="editoral" fails at build time rather than in production.

All of this can be checked on this very page. The palette icon in the header opens the theme editor, which lists the islands in the document; the footer is one of them, with a theme of its own. Changing it from the dropdown reassigns the surfaces live, with no reload and without any footer component knowing anything happened: the navigation buttons keep consuming the same role families they were consuming a second earlier.

The panel also ships a set builder. The sixteen colour fields are labelled with the exact name of the token they write, --p4-island-color-surface-base-bg and company, they take either a colour picker or a typed hexadecimal, and there is a dropdown for the base scheme, which is the decision that takes one line of the TypeScript registry in the repository. Every change is injected immediately into the islands set to the custom set, so the theme can be seen on real components while the colours are being chosen. The block at the bottom generates the resulting CSS ready to copy: it comes out as [data-island-theme='custom'], and taking it to the repository means renaming that selector and pasting it into tokens-island.css.

The button knows nothing about any of this. And here is where the real limit of the design shows up, which I would rather state than have someone discover it reading the CSS: local themes redefine surfaces, not actions. A button inside an “editorial” island still uses the action-* family of that island’s base scheme, which is the light theme. It is a deliberate decision, because action combinations are the ones holding up contrast and I do not want a decorative theme rewriting them by hand. The day an island needs its own action colour, the action-* family will have to enter the island token mirror and go through the same contrast verification as the surfaces. Not before.

The questions that stop being asked

The saving is not measured in lines of CSS. It is measured in decisions nobody takes again.

Without tokens, every new component drags a batch of small questions along with it: which black this button takes, whether its padding matches the button next to it, how tall it should be, what happens on hover, what happens in dark mode, whether the focus ring is the brand one or the browser one. Each is worth two minutes to whoever answers it alone and half an hour to whoever answers it in a thread with three people. Multiplied by the components of a product and the months of a roadmap, that is the quarter.

With the chain in place, the answer writes itself. The background is --p4-color-action-base-bg, the minimum height is --p4-spacing-2xl, hover belongs to the same family as rest, and in dark mode whatever the two theme blocks decided. What is left to decide is what actually deserves a discussion, which is almost always hierarchy and behaviour, not values.

Where this stops being worth it

None of this is free. Four levels and a double indirection per property cost readability: debugging a button background in the inspector means following the chain down to the literal, and a newcomer has to be told why the same black has three names. That is the price, and I pay it because the base system has to outlive this site and work in other projects with other brands.

On a single product, with one identity and two frontend people, this setup is bureaucracy. A semantic layer and the discipline of never writing a hexadecimal inside a component will do. The architecture starts paying for itself with the system’s second consumer and becomes mandatory with its second brand.

One piece is still unresolved, and I would rather say so than pretend the system is closed. The rule that holds all of this up, that no component references a primitive directly, is currently enforced by review and not by a lint rule. That works in a one-person repository. In a fifteen-person one, a token with no automatic filter lasts as long as it takes someone in a hurry to write a hexadecimal.

Next I want to write about the part that generates the most discussion and the fewest articles: naming a token without ending up at --color-brand-primary-main-default-2. I have seen that name in production, and the problem behind it was not a CSS problem.