Switching to Semantic Design Tokens

rubydesign-systemsbuilding-in-public

Most design systems have a color palette. Fourteen color families, a bunch of lightness steps, nicely organized CSS custom properties. --palette-blue-5, --palette-red-3, --palette-gray-8. Pick a color, use it in your component. Done.

This is how we built our first ViewComponents library, and it’s how most component libraries work. It’s also wrong. Or rather, it solves the wrong problem. A shared palette gives you shared colors. It doesn’t give you shared decisions.

The problem with direct token use

Say you’re building a button. The default background is --palette-blue-5. The hover state is --palette-blue-6. The active state is --palette-blue-7. You picked those values because they looked right, and they do look right. Ship it.

Now someone else builds a card header. They want a blue tint, not a button, just a subtle background. They pick --palette-blue-2. Looks good. Ship it.

Now someone builds a status badge. They want “info” to be blue. They pick --palette-blue-4. Ship it.

Every choice was reasonable. Every choice was independent. And now you have three features using blue at three different lightness levels with no relationship between them. When someone asks “can we make the blue warmer?” the answer is: change the palette and hope nothing looks wrong. Some things will look wrong, because the choices were made in isolation and there’s no way to know which blues were meant to be the same and which were meant to be different.

Multiply this across five products and four developers and you get exactly the visual drift we were trying to solve by building a design system in the first place.

Semantic tokens fix this

The insight is adding a layer of meaning between the component and the palette. Instead of a button using --palette-blue-5, it uses --color-bg-primary. Instead of a hover state using --palette-blue-6, it uses --color-bg-primary-hovered. The palette values are still there underneath. --color-bg-primary maps to --palette-blue-5 in the theme definition. But the component never sees them.

This changes what it means to make a color decision. With raw palette values, a developer is choosing a color. With semantic tokens, a developer is choosing a role: this is a primary action, this is a success state, this is a critical warning, this is a surface background. The design system decides what those roles look like.

In Sirius2, the semantic layer covers everything a component needs:

Surfaces: --color-bg-surface, --color-bg-surface-secondary, --color-bg-surface-hovered. A Card uses surface tokens. It doesn’t know or care what color “surface” is. It could be white, it could be a light gray, it could be dark blue in a future dark theme.

Status: --color-bg-surface-success, --color-text-critical, --color-icon-warning. A Banner showing an error uses the critical tokens. A success toast uses the success tokens. The meaning is in the token name, not in the color value.

Interactive states: Every interactive element needs default, hovered, active, focused, and disabled states. That’s five semantic tokens per visual property per interactive context. A primary button alone needs --color-bg-primary, --color-bg-primary-hovered, --color-bg-primary-active, --color-bg-primary-disabled, plus the text color equivalents. It’s a lot of tokens. It’s also a lot of decisions that are now made once instead of per-component.

Shadows and borders: --shadow-button, --shadow-card, --shadow-popover, --border-radius-200. Even these are semantic. A button’s shadow serves a different purpose than a card’s shadow, so they’re separate tokens even when they happen to have the same value today.

Three layers

The full architecture has three layers:

Layer 1: Palette. Fourteen color families, sixteen lightness steps each, generated using HSLuv (perceptually uniform color space) so that blue-3 and red-3 actually look like they belong at the same lightness level. Plus spacing, sizing, and typography scales. These are raw values. Components never reference them directly.

Layer 2: Semantic tokens. Names like --color-bg-primary-active or --color-text-critical that map to palette values through a theme definition. This is where the design decisions live. The theme is a Ruby class that defines every mapping (background, surface, status, interactive states) with HSLuv transformations for generating light and dark variants.

Layer 3: Components. Only reference semantic tokens. A Button doesn’t know it’s blue. It knows it’s primary. A Badge doesn’t know it’s red. It knows it’s critical. If we swap the palette or change the theme, every component adapts because none of them made their own color decisions.

What this actually unlocks

Dark theme becomes a palette swap plus theme adjustments, not a component-by-component rewrite. The semantic names stay the same. --color-bg-surface just maps to a different palette value.

Brand customization works the same way: swap the palette file, rebuild the CSS. We already have three palettes (original, Sirius-branded, Tailwind-compatible) to prove the mechanism works.

Consistency is structural. A developer using Sirius2 can’t accidentally pick the wrong blue because they never pick a blue at all. They pick a role, and the system handles the rest. Five products, four developers, one set of visual decisions.

That’s the actual point of a design system. Not shared components. Shared decisions.