A glossy 3D breaker panel feeds neatly wired appliances under green-cyan neon light while one rogue appliance runs a frayed red-lit wire straight to a utility pole, beside the bold title "Two-Tier CSS Custom Properties."

Two-Tier CSS Custom Properties: Theme Tokens vs. Component APIs

Introduction

A while back I wrote about using CSS custom properties in Astro—specifically, how to sneak an override past Astro’s style scoping by passing a class to a child component. That post was about how to move custom properties around. This one is about how to organize them, because that first technique has a failure mode I didn’t warn you about: it works beautifully for one component and then collapses into chaos once you have a site’s worth of them.

The gist first, so you can stop reading early if you want. Split your custom properties into two tiers. Tier 1 is your global theme tokens—the color palette, spacing scale, and type scale that make up your brand’s vocabulary, defined once at :root. Tier 2 is each component’s local API—the small set of knobs the component exposes for per-instance tweaks. And the convention that makes the two tiers hold together: theme colors stay out of the local tier. Components consume color tokens; they don’t offer color as a knob (with one narrow exception I’ll get to).

The rest of this post is the why, the code, and the places I got it wrong first.

The Flat Pile Problem

My first pass at custom properties looked the way most first passes do. For example, a component needed a configurable color, so I made a variable: --author-name-color. Another component needed one, so I made another. Six months later I had dozens of variables, each invented at the moment of need, no two named the same way, some holding raw color values and some pointing at other variables that also held raw color values. There was no system. There was a junk drawer.

Junk drawers have a specific property: each item made sense when you put it in. The batteries, the takeout menus, the single allen wrench—each one had a reason. No single item is the problem. The problem is that none of them has an assigned place, so none of them can be found, and when you finally need to change something (say, the brand’s accent color, everywhere) you’re pulling the drawer out and dumping it on the counter.

Two tiers is the drawer organizer. Let’s build it.

Tier 1: Global Theme Tokens

Tier 1 is the design language itself: colors, spacing, type sizes, radii, shadows. These are usually called design tokens—named values that represent a design decision rather than a raw number. There’s now even a formal specification for them: the Design Tokens Community Group published the first stable version of its token format in October 2025 (a community group report, not a full W3C standard, but the closest thing the industry has to an agreed vocabulary).

We define tokens once, at :root, so they inherit everywhere:

:root {
--ds-color-surface: oklch(99% 0.002 90);
--ds-color-text: oklch(22% 0.01 90);
--ds-color-accent: oklch(62% 0.17 155);
--ds-color-on-accent: oklch(99% 0.002 155);
--ds-color-danger: oklch(55% 0.19 25);
--ds-color-on-danger: oklch(99% 0.002 25);
--ds-space-3: 0.75rem;
--ds-space-5: 1.25rem;
--ds-radius-control: 8px;
}

Two quick notes on that block. First, the --ds- prefix is deliberate—namespace your tokens so they don’t collide with a third-party stylesheet’s idea of --radius. Second, I write colors in oklch() these days; Chrome, Firefox, and Safari have shipped oklch since 2023, and the color space makes “same lightness, different hue” something you can compute instead of guess at. Color spaces are a separate post, but the examples here reflect that convention.

The naming, I think, matters more than the values. Name tokens for what they mean, not what they are: --ds-color-accent beats --ds-color-green-500. Nathan Curtis wrote the essay I keep sending people on this (Naming Tokens in Design Systems), but the short version is an analogy from the paint store.

When a designer specs a room, they don’t write down a pigment recipe. They pull a chip from the fan deck—“Agreeable Gray”—and each room, trim board, and touch-up bucket references the chip by name. Semantic tokens are your fan deck. Components spec “Surface” and “Accent,” not the pigment recipe underneath. That one level of indirection is what makes theming almost embarrassingly easy: redefine what the chip means, and every room that referenced it repaints itself. Dark mode stops being a find-and-replace project and becomes one ruleset:

[data-theme='dark'] {
--ds-color-surface: oklch(18% 0.01 90);
--ds-color-text: oklch(93% 0.005 90);
}

If you want a raw palette layer underneath the semantic layer—--green-500-style primitives that semantic tokens point at—that’s a legitimate pattern, and a pretty common one in bigger systems. In my design system’s token package I split Tier 1 itself into two files: structural.css holds the fixed stuff themes don’t get to touch (the 4px spacing scale, type sizes, motion timing, z-index, focus-ring geometry), and theme.css holds the re-skin surface (color, radius, border, shadow, font family). That file split, however, is a refinement inside Tier 1. The tier boundary this post is about sits somewhere else entirely.

Tier 2: Local Component APIs

Tier 2 lives inside a component. The local API is the component’s configurable surface—the handful of custom properties a consumer is allowed to override per instance. For example, --button-padding-inline or --card-gap—structural knobs, not colors.

The analogy that finally made the split click for me is electrical. Your theme tokens are the house’s electrical system: standardized outlets, standardized voltage, one breaker panel. Your components are appliances. Each appliance has its labeled dials—the toaster has a darkness knob, the space heater has a thermostat—but for power, they all just plug into the wall. No appliance runs a wire straight to the utility pole.

A hard-coded hex value inside a component is an appliance wired straight to the pole. The wire works, right up until you need to change the electrical service—reskin the brand, for example, or add a dark mode or ship a client theme—and discover that one component isn’t on the panel. You’ll be hunting through component internals with a flashlight.

So the convention I settled on: a component’s local properties don’t hard-code a theme color, and theme colors aren’t part of the local API. Colors flow in from tokens. The local API exposes structure. The one exception I allow is a component whose entire job is color—a chart series, a status dot, an avatar background—and even there the knob takes a token reference, not a raw value, so the wire still runs to the panel.

A trimmed-down version of the Button from my design system will make this concrete:

.button {
/* internal locals—theme flows in through tokens */
--c: var(--ds-color-accent);
--on-c: var(--ds-color-on-accent);
/* local API—structural knobs a consumer may override */
--button-padding-block: var(--ds-space-3);
--button-padding-inline: var(--ds-space-5);
background: var(--c);
color: var(--on-c);
padding: var(--button-padding-block) var(--button-padding-inline);
border-radius: var(--ds-radius-control);
}
/* variant classes are the component's own business */
.button.is-danger {
--c: var(--ds-color-danger);
--on-c: var(--ds-color-on-danger);
}
.button.is-ghost {
background: transparent;
color: var(--c);
}
.button.is-ghost:hover {
background: color-mix(in oklab, var(--c) 8%, transparent);
}

Let’s look at what --c and --on-c are doing. Both are custom properties, but neither is API. They’re internal plumbing: the base rule wires them to the accent tokens, and the is-danger variant re-wires them to the danger tokens. Each visual state of the button—hover tints included, courtesy of color-mix(), CSS’s built-in color blender—derives from --c, so a variant swaps one wire and the rest of the appliance follows. The consumer doesn’t touch --c; they ask for the variant, usually through a prop that maps to the class.

I’ll admit my first draft of this pattern got it wrong. I exposed --button-bg as a consumer-facing knob, on the theory that flexibility is generous. A color knob is an invitation for each call site to invent a color, which is the junk drawer again, one instance at a time. The moment a consumer can write --button-bg: oklch(60% 0.2 30), your danger buttons and your “the marketing team liked this red better” buttons will be indistinguishable in the codebase. Under this scheme, colors are decisions the theme makes once. Padding is a decision a layout gets to make locally. That asymmetry is the two-tier split.

Why the Split Matters

Three payoffs, in descending order of how often they save me.

Theming becomes a stylesheet swap. Change Tier 1 and the system re-skins top to bottom, because no component downstream references a raw value. My token package ships alternate theme files, and swapping a brand is literally loading a different stylesheet against the same components. My acid test for whether a component is clean: hand it a deliberately absurd theme—garish colors, chunky radii, the works—and see if anything refuses to change. Any element still wearing the old brand has a wire to the pole.

Components stay portable. A component whose only inputs are tokens (implicit, from the environment) and a small documented local API (explicit, per instance) is a component you can drop into any project that defines the tokens. Portability here is really the same idea as props as a component’s public API, one layer down the stack. A prop list you’d be embarrassed to document is a bad API; so is a variable list.

The split blocks the worst CSS-variable smell. Which is, of course, hex codes sprinkled through component styles—themeable in theory, greppable in practice, fixable never.

How This Plays with Astro’s Scoping

Quick recap of the mechanism from the previous post. Astro scopes component styles by compiling each selector to include a generated attribute—selector[data-astro-cid-<hash>]—so a parent can’t reach into a child’s styles by name. (That attribute strategy is the default; there are opt-in alternatives, but the attribute is what you get out of the box.) The workaround: the child accepts a class prop and puts it on its root element, and the parent passes a class that sets the child’s custom properties. In that post the class was named, with great imagination, override-css-variables.

The two tiers map onto that mechanism cleanly. Tier 1 needs no help at all—:root styles are global, custom properties inherit, and inheritance sails straight through Astro’s scoping because it isn’t selector matching. Tier 2 is exactly what the pass-a-class pattern is for: per-instance structural overrides, delivered as a class that sits on the component’s root.

Now the snag, because the cascade has a rule here that will bite you the first time you meet it. A value declared on an element always beats a value merely inherited from an ancestor. Our button declares --button-padding-inline: var(--ds-space-5) on .button itself. So writing this in the parent has no effect:

/* parent component—looks right, does nothing */
.toolbar {
--button-padding-inline: var(--ds-space-3);
}

The toolbar’s value inherits down to the button, arrives, and loses—the button’s declaration wins, no matter how specific the ancestor’s selector was. Inherited values don’t compete on specificity; they compete on proximity, and they always lose to a declaration on the element itself. The override has to sit on the same element, which is why the pass-a-class pattern works:

/* passed into the component as a class—applied ON .button */
.toolbar-compact {
--button-padding-inline: var(--ds-space-3);
}

Same property, same value, very different outcome. When a token override mysteriously doesn’t take, a declaration on the element itself is the first thing I check now. That was not the first thing I checked the day I learned it.

Practical Guidelines

A few rules I’ve settled on after living with this for a while:

  • Namespace and keep Tier 1 small. A prefix (--ds-, --kd-, whatever) keeps you clear of collisions, and a short token list stays meaningful. If your color tokens outnumber the colors a designer could name from memory, some of them are junk drawer items with better handwriting.
  • Document each component’s local API like you’d document props. The overridable variables, their defaults, what they affect. Undocumented variables aren’t API; they’re internals someone will depend on anyway.
  • Variants over knobs for anything theme-shaped. If consumers keep asking for a color override, that’s a request for a variant (is-danger), not a variable. Add it to the component once, wire it to tokens, and each consumer gets the same red.
  • Don’t over-tokenize. Not every value deserves a variable. Tokenize what varies across themes or instances; inline what’s truly fixed. A 2px underline offset that will never, ever change is allowed to just be 2px. Each token you mint commits you to maintaining a name for as long as anything consumes it, so mint the ones that earn it.

Conclusion

Two tiers, one boundary rule. Tier 1 is your theme vocabulary—it’s where color lives, defined once at :root and swapped wholesale for dark mode or a new brand. Tier 2 is each component’s structural API—it defaults to tokens, exposes spacing-and-layout knobs, and leaves color to the theme. Variants handle the color-shaped requests, internally, wired to tokens.

The payoff is that your CSS variables stop being a junk drawer and start being a system: reskin the panel and every appliance follows, because no appliance is wired to the pole.

The part I find really satisfying is this. The scoping technique from post one plus the organization from this post is, functionally, a design system built from CSS custom properties alone—no preprocessor, no CSS-in-JS runtime, no build step beyond what Astro already does. There’s one more layer to cover, though: how tokens, primitives, and components stack into a publishable package you can share across projects. That’s the next post.