Cascade Tokens logo A native CSS methodology

Cascade Tokens is a methodology for organizing CSS variables directly in the browser, without JSON, without a build step, and without unnecessary JavaScript.

Why CasT

Most design token methodologies rely on a processing pipeline: a JSON file, a build tool, a JavaScript layer that regenerates CSS. These tools have their place, but for a Web interface, CSS already has a mechanism designed to carry design decisions: CSS variables (custom properties).

CasT is neither a tool nor a library, but a convention: a way to name, organize and circulate tokens by relying only on what the browser already knows how to do: the cascade, inheritance, @layer layers and, when useful, @property.

The idea in one sentence: a token stabilizes a primitive or a usage decision, and the browser, not an external tool, makes it cascade, inherit and override.

The 6 Principles

1. A token stabilizes a decision, not an isolated value

Not every value deserves a shared name: a token is worth creating when the value must be shared, adapted, documented or overridden. Global tokens name stabilized primitives, such as a palette or scale entry, while semantic tokens name a usage decision. A purely local layout detail can remain a raw value in its rule.

A semantic token that nobody consumes is a warning sign. An unused palette entry, on the other hand, can be legitimate: the base also publishes what variants or future themes will need.

2. Three levels, one dependency direction

Each token belongs to one of these three levels, and the level is visible in the name, with dependencies flowing only one way: component → semantic → global.

  • Global

    The system's raw palette: colors, spacing, radii.

    • --blue-600
    • --space-4
  • Semantic

    The usage intention, independent from the raw value.

    • --color-accent
    • --color-danger
  • Component

    What a specific component agrees to expose for customization.

    • --button-bg
    • --field-border

For colors and visual intentions, a component goes through a semantic token: --button-bg can be fed by --color-danger, never by --blue-600. Neutral primitives such as spacing, radius, font size or duration can still be consumed directly when they do not hide a usage intention.

The anti-pattern to recognize fits on one line: .button { background: var(--blue-600); }. Nothing is wrong as long as there is only one theme, until the day a second one arrives and forces every component open to correct a color, when a single semantic token would have been enough.

3. The cascade does the work, not you

A CSS variable inherits, is redefined on an element and propagates to its descendants without rewriting any rule. Changing a theme, a zone or a state means redefining a few tokens as close as possible to the need, not duplicating rendering properties.

.danger-zone {
  --button-bg: var(--color-danger);
}

Even in an override, --button-bg remains fed by a semantic token, never by a raw value or by a global color token consumed directly.

4. Explicit layers for arbitration

@layer layers reduce specificity wars and the need for !important. A base publishes default values in a weak layer, then a theme or application overrides them in a stronger layer, without making selectors heavier.

@layer reset, base, theme, components;

@layer base {
  :root {
    /* Global: the whole palette, including colors not used yet */
    --blue-600: #155eef;
    --green-600: #0e7c66;
    --color-accent: var(--blue-600);
  }
}

@layer theme {
  [data-theme="calm"] { --color-accent: var(--green-600); }
}

The base publishes the whole available palette, including colors reserved for alternate themes. The theme only reconnects the semantic token to an existing entry; it never declares a new color itself.

The reset layer is for external dependencies. A library imported without a layer remains outside the layers, and an unlayered rule beats every layer: the project would lose arbitration against its own dependency. Placing it explicitly is enough to reverse that balance of power.

@import "a-library.css" layer(reset);

The limit to keep in mind: this only works for CSS whose import you control. A library that injects its stylesheet at runtime, as many Web Components do, arrives outside the layers and therefore beats everything you have organized. In that case, the only winning move is to take the override out of the layers too.

5. A local fallback before a global dependency

A reusable component declares its tokens with a fallback in var(). It remains usable if nothing is provided, and a variant only overrides what actually changes.

.badge {
  --badge-bg: var(--color-surface-raised, Canvas);

  background: var(--badge-bg);
}

The fallback chain reads from left to right: the project's semantic token if it exists, otherwise the system color Canvas, which already follows the browser theme. The component never breaks, even when dropped into a page that knows none of its tokens.

6. @property for cases that require it, not by default

When a token must be validated, isolated from inheritance or animated, @property lets you give it an explicit syntax, initial value and inheritance behavior. For an animation, it gives the browser enough information to interpolate the value; a regular custom property remains opaque. For everything else (colors, spacing, static values), a classic CSS variable is enough.

@property --notice-border-width {
  syntax: "<length>";
  inherits: false;
  initial-value: 1px;
}

One caveat before mixing it with principle 4: @property is not subject to layers. Placing it inside an @layer neither weakens nor strengthens it, and a second declaration with the same name overwrites the first regardless of the declared order. Better to keep it outside layers, in one place.

Naming Convention

The name alone should say which level a token belongs to, without requiring a trip back to :root to check.

Complete Example

The three levels, a theme layer, and a component that consumes them.

@layer base, theme, components;

@layer base {
  :root {
    /* Global */
    --blue-600: #155eef;
    --green-600: #0e7c66;
    --space-3: 1.2rem;
    --radius-md: 0.4rem;

    /* Semantic */
    --color-accent: var(--blue-600);
  }
}

@layer theme {
  [data-theme="calm"] { --color-accent: var(--green-600); }
}

@layer components {
  .button {
    /* Component */
    --button-bg: var(--color-accent);
    --button-radius: var(--radius-md);

    background: var(--button-bg);
    border-radius: var(--button-radius);
    padding: var(--space-3) 1rem;
  }
}

Dark Mode: a theme, not a separate component

Dark mode introduces no new rule in components: it only adds a value at the semantic level, so component tokens never need to know that a dark theme exists.

Resolve each pair inside the token itself

light-dark() declares both values of a semantic token in the same place. The browser chooses which one to apply based on the computed value of color-scheme on the element, without writing a media query.

:root {
  color-scheme: light dark;

  /* Global: each light/dark pair, named like a palette entry */
  --white: #ffffff;
  --gray-100: #f3f4f6;
  --gray-200: #e4e6eb;
  --gray-800: #2a2d34;
  --gray-900: #1a1d23;
  --gray-950: #16181d;
  --blue-600: #155eef;
  --blue-400: #6f9bff;

  /* Semantic: each pair is injected into light-dark() */
  --color-text: light-dark(var(--gray-900), var(--gray-100));
  --color-surface: light-dark(var(--white), var(--gray-950));
  --color-border: light-dark(var(--gray-200), var(--gray-800));
  --color-accent: light-dark(var(--blue-600), var(--blue-400));
}

light-dark() accepts any color value, including a var(); nothing forces you to write raw values there. Each pair remains a normal palette entry, at the same level as the rest of the global tokens.

The limitation is in the name: light-dark() resolves only colors, so a token that carries a full shadow, a gradient or an entire border cannot use it. Two options: make the token carry only the colored part (box-shadow: 0 1px 3px var(--color-shadow)), or redefine that specific token in the theme layer.

In this approach, color-scheme: light dark is enough to let light-dark() follow prefers-color-scheme. It is principle 3 (the cascade does the work) applied to the theme rather than to a zone.

Force a manual choice

A theme selector only needs to touch one property. color-scheme inherits: redefining it on html switches all descendant light-dark() values, without restating a single token.

@layer theme {
  html[data-theme="dark"]  { color-scheme: dark; }
  html[data-theme="light"] { color-scheme: light; }
}
document.documentElement.setAttribute("data-theme", userChoice);

JavaScript sets an attribute; CSS applies all visual consequences.

Without light-dark(): the media query version

For broader support, the same result can be achieved by redefining semantic tokens twice: once for the system preference, once for the manual choice, in the theme layer.

@layer theme {
  @media (prefers-color-scheme: dark) {
    :root {
      --color-text: var(--gray-100);
      --color-surface: var(--gray-950);
    }
  }

  [data-theme="dark"] {
    --color-text: var(--gray-100);
    --color-surface: var(--gray-950);
  }
}

The palette (--gray-100, --gray-950,...) remains the one declared in the base, and only the theme layer decides which entry applies.

What never changes: button { --button-bg: var(--color-accent); }. The component references only the semantic layer, so it moves across both themes without a single line dedicated to dark mode. That is the concrete benefit of never letting a component consume a global color token directly (principle 2).

Web Components: the token is the API

A custom property crosses the Shadow DOM boundary. It is almost the only thing that does: a selector written on the page cannot reach inside a component, but an inherited variable can.

The component therefore declares, inside its shadow root, the tokens it agrees to expose for customization, each with its local fallback (principle 5). The page redefines them from outside, without knowing anything about its markup or specificity.

/* Inside the component's shadow root */
button {
  background: var(--switcher-bg, var(--color-surface-raised, Canvas));
  color: var(--switcher-color, var(--color-text, CanvasText));
}
/* On the page, without knowing anything about the inside */
browserux-theme-switcher {
  --switcher-bg: var(--color-surface);
  --switcher-color: var(--color-accent);
}

This is where the method differs most clearly from a token pipeline: the customization contract is already written in the language. No properties to propagate, no theme to inject, no build to rerun just to change a color.

The corollary: a component token is part of its public contract. Renaming it breaks pages that override it, exactly like a signature change. One more reason to expose, as principle 1 says, only what truly deserves to be exposed.

Limits

CasT organizes tokens; it does not guarantee a successful interface. A well-chosen name does not fix insufficient contrast or an inconsistent typographic scale. The method helps decisions circulate, but it does not remove the need to measure contrast, states and combinations as they are actually displayed. And if a system must feed iOS, Android, Figma and the Web at the same time, a broader token pipeline is still necessary. CasT describes what happens once inside the browser, not before.

A Skill to Apply It

A convention without mechanical verification drifts, not through negligence, but because the essential things cannot be reviewed by eye: that a semantic token really has its dark counterpart, that an imported library does not beat the project's layers, that text at a 1.12:1 contrast ratio is invisible, not merely subtle.

That is why cascade-tokens exists: a skill that operationalizes the method instead of merely repeating it, working in both directions: starting from a blank project, or taking over existing CSS whose colors are copied everywhere and whose dark theme only half works. Five Python files, with no dependencies: four command-line tools and the shared library they build on.

The folder follows the Agent Skills standard: a SKILL.md, scripts and references next to it. Claude Code and Codex know how to read this format, each with its own installation and invocation conventions.

1. The scripts

  • Suggest

    Finds recurring values and derives a palette, semantic intentions and component tokens.

    • suggest_tokens.py
  • Audit

    Hard-coded colors, unlayered imports, semantic tokens without a dark pair, misplaced @property.

    • audit_tokens.py
  • Measure

    Resolves var() and light-dark(), then checks contrast in both themes, against two thresholds.

    • check_contrast.py

A fourth script, check_build_layers.py, works on the built CSS rather than the source: @import rules are flattened there, and that is the file that decides production behavior. It catches a library left outside the layers, or an override placed in a layer where it will lose.

The fifth file, cast_lib.py, is never called directly, but it runs every time: the other four import it. It holds what they all need, a small CSS scanner, colour conversion, and the resolution of var() and light-dark(). Without it each script would carry its own parser, four of them, drifting apart, and a bug fixed in one would survive in the other three.

It is also importable from outside, so a one-off check is worth writing on top of it rather than rebuilding a parser.

2. Installation

Copy the skills/cascade-tokens folder from the repository to the location expected by your tool.

# Claude Code
mkdir -p ~/.claude/skills
cp -R skills/cascade-tokens ~/.claude/skills/cascade-tokens
# Codex
mkdir -p ~/.codex/skills
cp -R skills/cascade-tokens ~/.codex/skills/cascade-tokens

It is the same folder in both cases; no adaptation of the skill itself is required.

3. Triggering it

Two ways. Explicitly, by naming it according to the tool's convention: this is the safest path when you already know what you want.

# Claude Code
/cascade-tokens audit src/styles/ before the refactor
# Codex
$cascade-tokens audit src/styles/ before the refactor

Or without invoking anything: the skill describes the situations it applies to, and the assistant picks it up when the request matches. "My colors are hard-coded everywhere", "dark mode is unreadable", "I want to organize my CSS variables" are enough to bring it into play.

The scripts can also be used on their own, without going through an assistant.

python3 skills/cascade-tokens/scripts/suggest_tokens.py src/styles/ --only-new
python3 skills/cascade-tokens/scripts/audit_tokens.py src/styles/
python3 skills/cascade-tokens/scripts/check_contrast.py src/styles/ --min 4.5 --min-ui 3

The skill repository is available on GitHub: Effeilo/cascade-tokens.git.

What it does not do: choose your visual identity, or rewrite CSS in one silent pass. It measures, suggests with the declarations that justify each deduction, and leaves the decision to you. A report that cannot be challenged is not a diagnosis.