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.
-
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 {
--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.
- Global: names the palette or scale entry:
--{scale}-{value}, such as --blue-600 or --radius-md.
- Semantic: names the usage, never the color:
--color-accent rather than --color-blue. If blue becomes green, the name must stay true.
- Component: prefixed by the component name:
--button-bg, --notice-icon-color. Never crosses the component boundary.
- Proportionate precision:
--space-3 reads at a glance. --layout-density-action-inline asks for effort that is justified only if the token is truly shared at scale.
- Prefix when publishing: custom properties share a global namespace.
--blue-600 works in a closed application, but as soon as CSS is distributed or coexists with a library, a prefix (--acme-blue-600) limits the risk of silent token collisions.
Complete Example
The three levels, a theme layer, and a component that consumes them.
@layer base, theme, components;
@layer base {
:root {
--blue-600: #155eef;
--green-600: #0e7c66;
--space-3: 1.2rem;
--radius-md: 0.4rem;
--color-accent: var(--blue-600);
}
}
@layer theme {
[data-theme="calm"] { --color-accent: var(--green-600); }
}
@layer components {
.button {
--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;
--white: #ffffff;
--gray-100: #f3f4f6;
--gray-200: #e4e6eb;
--gray-800: #2a2d34;
--gray-900: #1a1d23;
--gray-950: #16181d;
--blue-600: #155eef;
--blue-400: #6f9bff;
--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.
button {
background: var(--switcher-bg, var(--color-surface-raised, Canvas));
color: var(--switcher-color, var(--color-text, CanvasText));
}
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.
-
Audit
Hard-coded colors, unlayered imports, semantic tokens without a dark pair, misplaced @property.
-
Measure
Resolves var() and light-dark(), then checks contrast in both themes, against two thresholds.
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.
mkdir -p ~/.claude/skills
cp -R skills/cascade-tokens ~/.claude/skills/cascade-tokens
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.
/cascade-tokens audit src/styles/ before the refactor
$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.