
CSS custom properties for scalable component systems
How CSS custom properties scale from single sites to multisites, and what happens when you try to scale them too far.
Set --bs-primary to a new colour on a Bootstrap 5.3 site and nothing Bootstrap styles changes colour. Set --bs-border-radius to 0 and every button goes square. Both are global tokens on :root, and they behave differently because of how Bootstrap wires its component layer, which makes it a useful public example of where a token system holds and where it leaks.
A token layer lets developers write components against a shared set of variables and change site-wide colour, spacing, or typography from one file. The risk shows up at scale: sites start overriding individual tokens locally, the overrides accumulate, and the file that was supposed to prevent drift becomes the place where drift lives.
What CSS variables do well
A component built on CSS variables does not need to know about colours, spacing, or typography. It references tokens. A button uses var(--button-background) instead of var(--color-primary) or a hardcoded hex. When the design system changes the button’s background, every button follows from one edit to the token file. That edit still ships through a normal deploy, but no component file changes and nothing in the component needs recompiling.
.button { background-color: var(--button-background); color: var(--button-foreground); padding: var(--button-padding); border-radius: var(--button-radius); font-weight: var(--button-font-weight);}
.button:hover { background-color: var(--button-background-hover); color: var(--button-foreground-hover);}This works in a single-site context. Every page gets the same token set. A product team that wants to adjust button styling changes the tokens and the change propagates.
The same system holds in multisite contexts too, as long as drift stays the exception. A medical school might want slightly different button styling than the business school, but both use the same underlying components, and the differences fit in configuration.
How Bootstrap splits the layers
Bootstrap 5.2 moved its components onto local CSS variables, and 5.3 kept the pattern. The compiled .btn rule declares its own variables, and each .btn-* modifier reassigns them:
.btn { --bs-btn-padding-x: 0.75rem; --bs-btn-padding-y: 0.375rem; --bs-btn-font-size: 1rem; --bs-btn-font-weight: 400; --bs-btn-border-radius: var(--bs-border-radius);}
.btn-primary { --bs-btn-color: #fff; --bs-btn-bg: #0d6efd; --bs-btn-border-color: #0d6efd;}The radius points back at the global --bs-border-radius, so it follows the global token at runtime. The background is a literal hex that Sass wrote at compile time. Nothing in the compiled stylesheet reads --bs-primary itself: utilities such as .text-primary read a separate --bs-primary-rgb token, and .btn-primary reads neither. To recolour buttons without recompiling, you reassign --bs-btn-bg and its hover and active siblings on .btn-primary itself, which is the component layer doing its job: a deliberate, local decision that you have to make on purpose.
That split is the model I want for a shared component library: global tokens for decisions that should travel everywhere, component variables for decisions a site makes about one component, and a visible seam between them.
Where it fractures
The problem arrives when sites override individual tokens locally. A site sets --button-radius: 0. Another sets --button-font-weight: 700. Another changes four tokens at once because a new brand guideline arrived. Given enough time, every site has overridden something, and the shared token set becomes a suggestion.
That is an architecture failure, and CSS variables expose it. They are flexible: you can set them globally, at the component level, on a specific instance. That flexibility becomes a problem when the expectation is that shared tokens will stay shared.
Treating tokens as layers helps: a base layer that is canonical and never overridden, a component layer where sites can adjust if a component needs different treatment, and an instance layer for one-offs, with the cost of each override documented next to it, something like “overriding this breaks multisite coherence.”
The layering only holds while the people touching it know what an override costs: when the shared library changes that value in a later release, the overriding site keeps its old one and nobody notices. Without that knowledge, setting a local token is the fastest fix for the request in front of you, and the convention erodes once nobody is around to explain it.
How I think about scoping now
Some overrides are legitimate. A site whose brand guideline differs from the network’s central branding has a real reason to diverge. Treating every override as equally cheap is what causes drift.
// Base layer: never override these. They define what the system is.:root { --color-bg-base: #f5f1e5; --color-text-base: #1a1a1a; --color-accent-base: #b8860b;}
// Component layer: site-wide adjustments to components.// Overriding here is still one decision, replicated everywhere.:root { --button-background: var(--color-accent-base); --button-foreground: white; --card-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);}
// Instance layer: one-off adjustments.// Every override here is a site-specific decision that lives forever..hero-banner .button { --button-background: var(--color-text-base);}Someone reading the CSS written this way can see what is decided once (system, component) and what is decided per instance. The cost of the override becomes visible.
What I still don’t have
The unresolved part is how to make tokens expressive without making them infinite. A component that needs colour, padding, border-radius, font-size, font-weight, line-height, and text-transform needs seven tokens. Multiply that across fifty components and the count runs into the hundreds. Nobody can reason about a token file that size.
Compositional tokens are the approach I lean toward: small, specific pieces that compose into larger decisions. Instead of --card-shadow, you have --shadow-sm, --shadow-md, --shadow-lg, and a card uses var(--shadow-md). Instead of --button-font-weight, you have --weight-regular and --weight-bold, and a button uses var(--weight-bold). The token count shrinks because the tokens are orthogonal.
// Compositional tokens: a small shared scale.:root { --space-xs: 0.5rem; --space-sm: 1rem; --space-md: 1.5rem; --space-lg: 2rem;
--weight-regular: 400; --weight-bold: 700;
--radius-sm: 2px; --radius-md: 4px; --radius-lg: 8px;}
// Components compose them..button { padding: var(--space-sm) var(--space-md); font-weight: var(--weight-bold); border-radius: var(--radius-md);}The token set stays small enough to comprehend. Components declare what they compose from. A site that wants different spacing overrides the four spacing tokens instead of every component’s padding variable.
This requires discipline. A developer who does not understand the compositional approach will create component-specific tokens anyway, and you are back where you started. Bootstrap’s own buttons show the opposite failure: the component variables exist, but because the colour values were baked in by Sass, the global colour token cannot reach them.
CSS variables work up to a scale. Beyond it, you need something around them: a token processor that validates override decisions, a design-system engine that generates tokens from a config file, or a different architecture entirely. The variables themselves do not get worse. The system of decisions around them does.
If you have a multisite platform where token overrides stay the exception and components remain coherent, I would like to understand how you enforced it.
What this came from
- ArticleCSS custom properties
- ArticleBootstrap 5.3 buttons, CSS variables
- ArticleBootstrap 5.3 CSS variables