Skip to main content
Anvil2 3.0 ships tier 3 component tokens, completing our 3-tier design token architecture. If you use Anvil2 components out of the box, this requires no changes on your end — components already consume the new tokens internally. For teams that customize component styles or build custom themes, tier 3 tokens give you fine-grained control over individual component colors through a predictable cascade — override a semantic token and every component that references it updates automatically, or target a single component without affecting anything else. This post walks through the architecture, shows how the cascade works with code examples, and covers what you need to know as a consumer.

The 3-tier token architecture

Our token system is built on three layers, where each tier references the one below it:
Tier 1: Primitives       →  Raw values (hex colors, px sizes)
        ↑ referenced by
Tier 2: Semantic tokens   →  Purpose-driven (background, foreground, border)
        ↑ referenced by
Tier 3: Component tokens  →  Component-specific (button, checkbox, listbox)

Tier 1 — Primitives

Raw, context-free design values. These are the foundation — color scales, spacing, radii, and typography values with no semantic meaning.
// color.tokens.json
{
  "color": {
    "blue": {
      "600": { "$type": "color", "$value": "#0265dc" },
      "700": { "$type": "color", "$value": "#1d4ca3" }
    }
  }
}

Tier 2 — Semantic tokens

Purpose-driven tokens that reference primitives and include light/dark mode support. These answer the question “what is this color for?” rather than “what color is it?”
// background.tokens.json
{
  "background": {
    "color": {
      "primary": {
        "$type": "color",
        "$value": "{color.blue.600}",
        "$extensions": {
          "appearance": {
            "light": { "$type": "color", "$value": "{color.blue.600}" },
            "dark": { "$type": "color", "$value": "{color.blue.300}" }
          }
        }
      }
    }
  }
}

Tier 3 — Component tokens (new in 3.0)

Component-specific tokens that reference semantic tokens. Each component’s visual properties — foreground, background, and border colors across all variants and states — are defined through dedicated token files.
// component/button.tokens.json
{
  "button": {
    "primary": {
      "background": {
        "color":        { "$value": "{background.color.primary}" },
        "color-hover":  { "$value": "{background.color.primary-hover}" },
        "color-active": { "$value": "{background.color.primary-active}" }
      }
    }
  }
}
The full resolution chain for a button’s primary background looks like this:
--a2-button-primary-background-color
  → references --a2-background-color-primary (tier 2)
    → references --a2-color-blue-600 (tier 1)
      → resolves to #0265dc

How the theming cascade works

Tier 3 tokens enable two levels of customization:

Bulk theming via tier 2 overrides

Override a semantic token and every component referencing it updates automatically. Change --a2-background-color-primary and Button, Chip, Tab, and any other component using that semantic meaning all pick up the new value.

Targeted component overrides

Override a specific component token to customize just that component without touching anything else. Change --a2-button-primary-background-color and only Button is affected.

Override method 1: CSS variable overrides

Set tier 2 semantic tokens directly as CSS custom properties via inline styles:
const cssVarOverrides: React.CSSProperties = {
  "--a2-background-color-primary": "light-dark(#9333ea, #a855f7)",
  "--a2-background-color-primary-hover": "light-dark(#7e22ce, #c084fc)",
  "--a2-background-color-primary-active": "light-dark(#6b21a8, #d8b4fe)",
};

<Flex style={cssVarOverrides}>
  <Button appearance="primary">Purple Button</Button>
  <Chip selected>Also purple</Chip>
</Flex>
Use the ThemeProvider component’s theme prop for a type-safe, structured approach. ThemeProvider accepts theme.semantic and theme.component objects and handles light/dark mode values automatically:
import { ThemeProvider, type CustomThemeType } from "@servicetitan/anvil2";

const customTheme: CustomThemeType = {
  semantic: {
    BackgroundColorPrimary: {
      value: "#9333ea",
      extensions: { appearance: { dark: { value: "#a855f7" } } },
    },
    BackgroundColorPrimaryHover: {
      value: "#7e22ce",
      extensions: { appearance: { dark: { value: "#c084fc" } } },
    },
    BackgroundColorPrimaryActive: {
      value: "#6b21a8",
      extensions: { appearance: { dark: { value: "#d8b4fe" } } },
    },
  },
};

<ThemeProvider theme={customTheme}>
  <Button appearance="primary">Purple Button</Button>
  <Chip selected>Also purple</Chip>
</ThemeProvider>
We recommend using ThemeProvider for token overrides. It generates the correct CSS custom properties for you, handles light/dark mode automatically, and provides type safety through the CustomThemeType interface.

What tier 3 covers today

Tier 3 currently covers color tokens only — foreground, background, and border colors for each component’s variants and interactive states (hover, active, disabled). All stable (non-beta) components now consume tier 3 tokens when defined. We ship 47 component token files with 3.0. Each file follows the same predictable naming convention: {component}.{variant}.{property}.{attribute}. For example, button.primary.background.color-hover or listbox.option.selected.foreground.color.

CSS variable cascading: what works and what doesn’t

An important caveat about runtime CSS variable overrides. Overriding a tier 1 primitive (e.g., --a2-color-blue-600) does not cascade to tier 2 or tier 3 tokens at runtime. This happens because the build process inserts the light-dark() CSS function at the semantic tier to support dark mode. The light-dark() function creates isolated branches that inline resolved values rather than preserving live var() references back to primitives.
/* You might expect a live chain: */
--a2-button-primary-background-color
  → var(--a2-background-color-primary)
    → var(--a2-color-blue-600)

/* But the built output inlines light-dark() at the semantic level: */
--a2-button-primary-background-color: var(--a2-background-color-primary,
  light-dark(var(--a2-color-blue-600, #0265dc), var(--a2-color-blue-300, #70b1ff)));
Because light-dark() resolves its branches independently, overriding --a2-color-blue-600 at runtime does not propagate upward. What does work:
  • Override tier 2 (semantic): Set --a2-background-color-primary to affect all components referencing that semantic token
  • Override tier 3 (component): Set --a2-button-primary-background-color to customize a single component
Our recommendation: Use ThemeProvider for overrides. It accepts theme.semantic and theme.component objects that correctly generate the CSS variable overrides for you, handling light/dark mode values automatically. This is the safest and most reliable way to customize token values.
This tradeoff exists because light-dark() insertion is necessary for dark mode support. The current design intentionally optimizes for dark mode correctness. Override at tier 2 or tier 3 — not tier 1.

What’s coming next

This release establishes the tier 3 architecture for colors. In future releases, we plan to expand tier 3 to cover additional properties:
  • Border radius — component-specific corner rounding
  • Padding and spacing — internal component spacing
  • Other visual properties — as the system matures
This will give teams even more granular control over component styling through the same predictable token cascade.

Breaking changes to tier 2 tokens

If you use Anvil2 components out of the box without custom token overrides, these changes do not affect you — components already consume the updated tokens internally. However, if you reference tier 2 tokens directly in custom components or overrides, review these changes: Overlay tokens removed — interactive states now use direct background colors. In 2.x, interactive states (hover, active) were implemented using a pseudo-element with an overlay color layered on top of the base background. In 3.0, each background color token now defines its own interactive state variants directly (e.g., background.color.primary-hover, background.color.primary-active). This eliminates the need for the overlay token category entirely:
2.x3.0
overlay.color.hover.defaultbackground.color.transparent.default-hover
overlay.color.active.defaultbackground.color.transparent.default-active
overlay.color.hover.primarybackground.color.transparent.primary-hover
$root pattern removed. Interactive states are now flat hyphenated siblings instead of nested children:
// 2.x
foreground.color.primary         (was $root)
foreground.color.primary.hover
foreground.color.primary.active

// 3.0
foreground.color.primary
foreground.color.primary-hover
foreground.color.primary-active
For the full migration guide, see Migrating from 2.0 to 3.0.

Questions, feedback, or ideas? Drop us a line in #ask-designsystem.
Last modified on April 21, 2026