Override levels

The five levels at which tokens compose, from theme defaults to the per-instance tokens prop, with a worked example of each.

2026-11-07

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.