Component-Level Theme Overrides
Part of Multi-Brand Theming & White-Label Token Architecture. Global brand tokens cover most of what a tenant needs. This area covers the remainder: the cases where one component has to look different for one brand, and how to allow that without opening every component’s internals to the outside world.
Problem Framing Link to this section
A white-label product has forty tenants sharing one component library. Thirty-eight of them are served entirely by brand colour tokens. Two are not: one has a legal requirement that its primary button be square rather than rounded, and the other has a brand guideline that puts its logo mark inside the navigation bar in a way the shared component does not anticipate.
The pressure at this point is to fork. It is fast, it unblocks the customer, and it moves the problem into the future — where it reappears as two components that have to be kept in step with a library that keeps moving. Two forks become five, and the shared library stops being shared.
The alternative is to define, deliberately, what a brand may reach into. That means naming the parts of a component that are public, keeping everything else private, and accepting that the public surface is a commitment. The difference between this and a fork is that the commitment is bounded and reviewable, and the component can still be refactored behind it.
Three-Tier Architectural Trade-offs Link to this section
- Component tokens vs exposed parts. A component-scoped token is a contract about a value and leaves the internal structure free to change. An exposed part is a contract about an element, which pins that element’s existence.
- Shadow DOM encapsulation vs open styling. Shadow DOM makes the private surface genuinely private and forces every override through a declared channel. Light DOM components rely on convention, which holds until somebody writes a descendant selector.
- Per-component layers vs a single brand layer. One layer per component makes precedence explicit and produces a long layer list. A single brand layer is simpler and makes it harder to reason about which override wins where two brands touch the same component.
- Allowing structural overrides vs value-only. Value overrides are safe and cover most cases. Structural overrides — a different arrangement, an extra element — are where forks come from, and where a variant in the shared component is nearly always the better answer.
Build Pipeline / Workflow Steps Link to this section
- Start from the global token layer. Confirm the requirement genuinely cannot be met by a brand-level token; most “this component needs to be different” requests turn out to be a missing semantic role.
- Declare a component-scoped token if it can be.
--btn-radiusconsumed by the button and declared in the contract lets a brand set a square button without seeing the button’s internals. - Register the token in the theme contract. An override that is not in the contract is undocumented surface, which is how private internals become load-bearing for someone.
- Expose a part only where structure must be reached. Name it deliberately, document what it contains, and treat renaming it as a breaking change.
- Assign the override to a layer. Brand overrides sit above component styles and below tenant ones, so the precedence is decided by layer order rather than selector weight.
- Test each brand override in isolation and in combination. A brand override plus a dark theme is a different rendering path from either alone, and it is the one that breaks.
- Review the exposed surface quarterly. Every part and component token is a refactoring constraint; the list should be short and should shrink when a requirement disappears.
/* The component declares what may be reached, and nothing else. */
@layer components {
.ds-button {
--btn-radius: var(--ds-radius-md); /* overridable */
--btn-bg: var(--ds-color-action-primary); /* overridable */
border-radius: var(--btn-radius);
background: var(--btn-bg);
padding-block: var(--ds-space-inset-sm); /* not overridable */
}
}
@layer brand {
[data-brand="acme"] {
--btn-radius: 0; /* the square-button requirement */
}
}
Why this works: the brand block never mentions .ds-button. It sets a value the component chose to read, which means the button’s markup, class names and internal structure can all change without breaking the brand. That property is exactly what a fork gives up.
Validation & Quality Gates Link to this section
// scripts/check-brand-surface.mjs — brand files may only touch the declared surface.
import { readFileSync, globSync } from 'node:fs';
const contract = new Set(JSON.parse(readFileSync('contract.v1.json', 'utf8')).tokens);
let failed = 0;
for (const file of globSync('brands/*.css')) {
const css = readFileSync(file, 'utf8');
for (const match of css.matchAll(/^\s*(--[\w-]+)\s*:/gm)) {
if (!contract.has(match[1])) {
console.error(`${file}: "${match[1]}" is not in the theme contract`);
failed++;
}
}
// A brand file that names a component class is reaching past the contract.
for (const match of css.matchAll(/\.ds-[\w-]+/g)) {
console.error(`${file}: selector "${match[0]}" targets component internals`);
failed++;
}
}
process.exit(failed ? 1 : 0);
| Tool | Purpose | Integration point |
|---|---|---|
check-brand-surface.mjs |
Rejects brand CSS that sets undeclared tokens or names component classes | Runs per brand, before the brand bundle is built |
| Stylelint, brand config | Bans descendant selectors and !important in brand files |
Same job, applied only to brands/** |
| Part inventory | Lists every part attribute and fails when one is added without a contract entry |
Component build, alongside the type generation |
| Combination snapshots | Renders each brand in both themes for the components it overrides | Nightly, one representative brand per override |
The selector check is the one that does most of the work. Setting an undeclared token is usually a mistake that fails harmlessly; writing .ds-button__label { … } in a brand file works perfectly and creates a dependency on an internal class name that nobody knows exists until a refactor breaks that tenant.
Testing the Combinations That Actually Occur Link to this section
A component override multiplies the states a component can be in, and the multiplication is the part that catches teams out. A button with two variants, three states, two themes and one brand override is not eight cases — it is the product of all of them, and most of those combinations have never been looked at by anyone.
Testing all of them is neither possible nor useful. Testing the ones that occur in production is both. The practical approach is to derive the test matrix from deployment data rather than from the combinatorics: which brands are live, which themes each of them enables, and which components each brand actually overrides. That list is typically an order of magnitude smaller than the full product and covers everything a real visitor can encounter.
Two combinations deserve explicit coverage regardless of what the data says, because they are the ones where overrides interact rather than merely coexist.
Brand override plus dark theme. A brand that sets a colour tuned for a light surface will often be unreadable on a dark one, and the contrast gate only catches it if the brand’s values are included in the pair list. This is the single most common production defect in a multi-brand themed system.
Brand override plus a component variant. A ghost button whose background is transparent and a brand that overrides the background token can produce a ghost button with a solid brand background — technically correct, visually wrong. The interaction exists because the variant and the brand are both setting the same token from different directions.
Both cases are cheap to cover once the matrix is derived rather than enumerated, and both are nearly impossible to reason about in review. This is one of the places where a small, targeted snapshot suite earns its keep more clearly than anywhere else in a design system.
Cross-Domain Dependency Mapping Link to this section
| Parent Section | Sibling Area | Integration point | Validation strategy |
|---|---|---|---|
| Multi-Brand Architecture | Theme contract versioning | Component tokens are contract entries with the same guarantees | Conformance check per brand |
| Multi-Brand Architecture | Brand theme layering strategies | Component overrides need a layer position | Assert layer order in the built bundle |
| Token Fundamentals | Token aliasing and reference resolution | A component token is the last link in the chain | Depth limit includes the component hop |
| Theming & Dark Mode | Runtime theme switching | Brand and theme overrides compose at runtime | Snapshot the brand-plus-dark combination |
/* @depends contract: --btn-radius, --btn-bg (v1)
These two names are public API for this component. Renaming either is a
major contract change. Everything else in this file is private and may be
restructured freely. */
.ds-button { border-radius: var(--btn-radius); background: var(--btn-bg); }
Governance: Who Approves an Exposed Surface Link to this section
A component token or an exposed part is a commitment the whole organisation lives with, so the decision to add one should sit with the people who will carry that commitment — the component’s maintainers — rather than with whoever is unblocking a customer this week.
The workable process has three steps and takes a day, not a sprint. A request arrives naming the brand, the component and the visual difference required. A maintainer classifies it using the routing question above: is this a value, an element, or an arrangement. If it is a value, the token is added, documented in the contract, and the brand ships. If it is an element or an arrangement, the request goes to a short design conversation about whether the shared component should support the case as a variant.
That last conversation is the valuable one, and it is the step teams skip when the pressure is on. The reason it matters is that a brand requirement is almost never unique. The tenant asking for a square button is the first of three; the tenant asking for a logo slot in the navigation is describing a capability half the customer base would use. Handling the request as a shared variant serves everyone and costs roughly the same as handling it as a private exposure.
There is a corollary about who may say no. If the design system team cannot decline a request, the process is decorative and the surface will grow without limit. Declining needs an alternative attached — “not as a part, but we will add a token for the value” — and it needs the escalation path to be about priority rather than about bypassing the contract.
Recording the reason, not just the decision Link to this section
Every entry in the contract should carry the requirement that justified it. Six months later, when someone proposes removing a component token because nothing seems to use it, the recorded reason is what distinguishes “the tenant that needed this churned” from “this is load-bearing for a customer who has not deployed yet”. Without it, the safe choice is always to keep the entry, and the surface becomes a ratchet that only ever tightens.
A single sentence per entry is enough: who asked, what they needed, and what the alternative would have been. That makes the quarterly review a real review rather than a list nobody can act on.
Diagnostic Matrix Link to this section
| Diagnostic step | Execution detail |
|---|---|
| Confirm the brand attribute is present | Inspect the root element; a missing data-brand makes every override silently inert |
| Read the resolved token on the component | getComputedStyle(el).getPropertyValue('--btn-radius') — this is the value that actually applied |
| Check layer order in the built bundle | The @layer statement must list brand after components |
| Look for a competing declaration | A component declaring the token with a higher-specificity selector wins regardless of layer |
| Test the brand with the dark theme active | Overrides that reference a theme token behave differently per theme |
Three root causes cover most reports of “the brand override does not apply”. The attribute is missing on the element the selector expects, usually because it was set on body while the selector targets :root. The token is declared inside the component with a more specific selector, so the brand’s :root-level declaration never wins. Or the layer order in the built bundle differs from the order in the source, which happens when a bundler concatenates files before the @layer statement is parsed.
Why Forks Are So Expensive Link to this section
It is worth being concrete about the cost of the option this whole area exists to avoid, because “forking is bad” is easy to say and easy to override under delivery pressure.
A forked component starts as a copy. On day one it is identical apart from the change that justified it, and the cost is zero. The cost arrives with every subsequent change to the shared component: an accessibility fix, a keyboard handling improvement, a bug in the focus ring, a new required prop. Each of those has to be assessed against the fork, decided on, applied, and tested — by someone who remembers the fork exists.
That last condition is what makes forks so expensive in practice. The person fixing the shared button six months later has no reason to know about a fork in a tenant-specific package, so the fix simply does not reach it. The divergence is silent and accumulates, and the tenant with the fork gradually receives a worse product than everyone else — usually noticed first as an accessibility complaint, because those are the fixes that most often land in the shared component and never propagate.
There is also a second-order cost that surprises teams. A fork removes the pressure that would otherwise have improved the shared component. The requirement that justified the fork was real, and had it been met by adding a token or a variant, every other brand would have gained the option. Forking converts a shared capability into a private one.
None of this argues that forks are never correct. A component whose behaviour, not just appearance, differs fundamentally for one customer may genuinely be a different component. The test is whether the two would ever want the same bug fix: if yes, it is a variant; if genuinely no, it is a separate component and should be named as one rather than living as a copy with a suffix.
Budgeting the exposed surface Link to this section
A useful discipline is to treat the exposed surface as a fixed budget rather than an open-ended list. Pick a number — say, three overridable tokens per component and no more than two exposed parts across the whole library — and require a deliberate trade when the budget is exhausted: adding a fourth token means retiring one, or accepting that this component’s surface is genuinely larger and recording why.
The number matters less than the fact of having one. Without a budget, every individual request looks reasonable, and the surface grows monotonically because nothing ever pushes back. With one, the conversation shifts from “can we expose this” to “is this more valuable than what we would give up”, which is the question that actually needs answering.
Frequently Asked Questions Link to this section
When is exposing a part justified? Link to this section
When a brand needs to change something structural that a value cannot express, and the alternative is a fork. A logo slot, a custom badge position, a legally required disclaimer placement — these are genuine cases. What is not a justification is convenience: exposing a part because it is quicker than adding a token converts a five-minute decision into a permanent constraint on the component’s internals.
Should component tokens be namespaced by component? Link to this section
Yes, and the namespace should match the component name exactly. --btn-radius belonging to .ds-button is clear; --radius-small declared by the button and read by the card is a shared token wearing a component’s clothes. The namespace is what allows the surface check to attribute every declared token to a component, and it makes an accidental cross-component dependency visible in review.
How many component tokens is too many? Link to this section
If a component exposes more than a handful, the requirement is usually that the component needs a variant rather than more knobs. Fifteen overridable values on one component means every brand is effectively configuring a different component, with none of the guarantees a variant would provide — no name, no documentation, and no way to test the combinations anyone actually uses.
Related Link to this section
- Multi-Brand Theming & White-Label Token Architecture — the wider architecture these overrides sit inside
- Overriding Tokens Inside Shadow DOM with :host and ::part — the encapsulated implementation in full
- Scoping Brand Overrides with Cascade Layers per Component — making precedence explicit rather than accidental
- Theme Contract Versioning — how component tokens are published and deprecated