Override levels
The five levels at which tokens compose, from theme defaults to the per-instance tokens prop, with a worked example of each.
The five override levels
Tokens compose at five well-defined levels. Each is a strict override of the one before it, so the highest level that sets a value wins. Understanding the ladder is the whole story of how a value gets from Figma to the pixel, and where the Euro League theme or a consumer can intercept it. On this site level 2 has a scoped variant, 2a: a competition's layer, applied over the EuroLeague layer on that competition's section pages only.
| Level | What it is | Who sets it |
|---|---|---|
| 1 | Theme defaults | Each component's .theme.ts |
| 2 | Euro League global layer | @v4/tokens-euro-league (tokens.global) |
| 2a | Competition layer (section pages only) | @v4/tokens-euro-league competitionThemes |
| 3 | Per-component overrides (components) |
Escape hatch — empty by design here |
| 4 | Raw CSS custom properties | Non-TS consumers |
| 5 | Per-instance tokens prop |
A single component instance |
Level 1 — theme defaults
Each component's .theme.ts registers its contract with buildGlobalTheme. The values are
contract paths into the global theme (g.semantic.*, g.colourSets.*, g.components.*),
whose defaults are generated from Token Studio into packages/tokens/src/__generated__/tokens.ts.
A component never imports raw tokens and freezes a literal — that would put the value
beyond every Euro League override.
// packages/ghost-core/src/components/button-common/button-common.theme.ts (shape)
const g = globalTheme.themeContract;
export const buttonCommonTheme = registerComponentTheme(
buildGlobalTheme("buttonCommon", {
borderRadius: g.semantic.borderRadius.full, // S.BorderRadius.Full
sizes: {
small: {
borderRadius: g.components.core.borderRadius.commonButton.small, // C.BorderRadius.CommonButton.Small
// …
},
},
}),
);
Level 2 — the Euro League global layer
@v4/tokens-euro-league re-points the global contract. Because the Token Studio Component/* namespaces live
in the global contract, component-owned colours and radii re-skin here too — this is the
level ELB's brand actually uses. The app declares its global layer once, on :root, so every element inherits it rather
than re-declaring ~7,600 properties per component root:
// apps/web/src/theme/tokens.css.ts
import { createClubThemePart, globalTheme } from "@v4/ghost-core/theme";
import { tokens } from "@v4/tokens-euro-league";
globalTheme.createScoped(":root", tokens.global);
createClubThemePart(
{ global: tokens.global, components: tokens.components },
{
components: [...CORE_THEMES, ...CONTENT_THEMES],
// emitted with each component's own stylesheet instead, unless overridden here
selfEmitted: [...CORE_SELF_EMITTED, ...CONTENT_SELF_EMITTED],
},
);
Most component themes are not in this file at all: each precompiled package emits a
component's theme defaults with that component's own stylesheet, at :where(.ph__x-theme),
so a page loads only the themes of the components its route imports. A level-3 override
below is still emitted here, at the class, and beats that default. The rest of the parts
are emitted with the routes that render them — see
Euro League's theme.
Level 2a — the competition layer
On a competition's section pages (/eurocup/…, /news/eurocup, /teams/eurocup, …) the
root layout puts a class on <html>, and apps/web/src/theme/competition-themes.css.ts
declares that competition's Token Studio theme (EuroCup, NextGen) over the EuroLeague layer with
createScopedOverrides(":root.ph__competition-<slug>", …) — only the leaves that differ from
EuroLeague's. Every other page wears the EuroLeague layer. The mapping is
apps/web/src/lib/competition-theme.ts.
Level 3 — per-component overrides
A hand-authored escape hatch, keyed by component-theme name against that component's own
contract shape. A Token Studio export cannot generate these, so ELB's data flows through
level 2 instead; components is empty by design (see
packages/tokens-euro-league/README.md).
createClubThemePart(
{ global: tokens.global, components: { buttonCommon: { borderRadius: "16px" } } },
{ components: ["buttonCommon"] },
);
Level 4 — raw CSS custom properties
The escape hatch for non-TypeScript consumers. Every token is emitted as a
--ph__{component}-* custom property (the theme name kebab-cased, so buttonCommon emits
--ph__button-common-*) that can be set from plain CSS. Set it on the component's own
element: a wrapper's value is shadowed, because the component's theme class re-declares the
variable on the element itself.
.promo-region [data-component="ButtonCommon"] {
--ph__button-common-border-radius: 16px;
}
Level 5 — per-instance tokens prop
Every component takes tokens?: DeepPartial<{Component}Tokens>, applied once via
theme.assignInlineTokens(tokens). Inline custom properties win over the emitted theme
values, so this overrides a single instance without touching the Euro League theme:
<ButtonCommon {...props} tokens={{ borderRadius: "16px" }} />
What the tokens prop cannot do
A prop is fixed at render, so the level-5 tokens prop cannot follow a :hover or a
media query, and it reaches only its own component's contract — not a composed child's.
When a parent's CSS state must repaint a composed child (a card hover darkening the button
inside it), the house mechanism is a typed assignVars over the child's contract subtree in
a .css.ts, not the tokens prop. See the composition patterns section and each
component's theme.mdx for the details.
A default must sit where the inline style lands
Shared _internal/* layouts hand per-consumer values down as inline custom properties on
the ROOT, with a safety-net default in the layout's stylesheet. Declare that default on the
same element the inline style targets and let descendants inherit it: a default declared on
the element that reads the variable silently beats the inherited inline value, and the
component renders the default for ever.