Skip to content

Theming

Theme Matrix

Every theme is a combination of three dimensions:

DimensionOptions
Mode

light, dark

Contrast

default, dimmer, high-contrast

Color vision

standard, protanopia, deuteranopia, tritanopia

This produces 24 theme variants. Token JSON files live in mode/{dark,light}/:

default.json → dark + default contrast + standard vision
dimmer.json → dark + dimmer + standard vision
high-contrast.json → dark + high-contrast + standard vision
default-protanopia.json → dark + default + protanopia
dimmer-deuteranopia.json → dark + dimmer + deuteranopia
high-contrast-tritanopia.json → dark + high-contrast + tritanopia
...

Build Pipeline

Terminal window
cd packages/core-design-system
pnpm build # → node scripts/build.js

The build uses Style Dictionary + @tokens-studio/sd-transforms:

  1. Reads $themes.json and permutates all theme combinations
  2. Resolves token references ({gray.500} → actual LCH value)
  3. Applies custom transforms: alpha, darken, lighten in LCH space; dropShadow for data-viz
  4. Marks tokens as themeable or core (determines which output file they land in)
  5. Outputs to dist/:
dist/
├── styles/
│ ├── core-imports.css # @imports for all core files
│ ├── colors.css # Core color primitives (:root)
│ ├── core.css # Dimensions, typography (:root)
│ ├── breakpoint.css # Layout tokens (:root)
│ ├── components.css # Component tokens (:root)
│ ├── themes.css # @imports for all 24 theme files
│ ├── dark.css # Semantic tokens under .dark selector
│ ├── light.css
│ ├── dark-dimmer.css
│ ├── ... # 24 theme files total
│ └── mfe-themes.css # Subset for micro-frontend consumers
└── styles-esm/
├── index.ts # Theme map + re-exports
├── colors.ts # Programmatic access to primitives
└── ... # TypeScript module per file

All CSS properties use the cn prefix: --cn-bg-0, --cn-text-1, --cn-set-brand-primary-bg, etc.


CSS Selectors

The build script maps each theme file to CSS class selectors in the format .{mode}-{color}-{contrast}:

Token fileCSS selectors
dark/default.json.dark, .dark-std-std
light/default.json.light, .light-std-std
dark/dimmer.json.dark-dimmer, .dark-std-low
light/high-contrast.json

.light-high-contrast, .light-std-high

dark/default-protanopia.json

.dark-protanopia, .dark-pro-std

light/dimmer-tritanopia.json

.light-dimmer-tritanopia, .light-tri-low

To switch themes at runtime, set the class on <body>:

document.body.className = "dark-std-std";

All CSS custom properties update instantly — no JS rerender needed for color changes.


Contrast Modes

Three contrast levels let users tune the visual intensity of the interface. Each level shifts shade steps and alpha values uniformly — no layout or component changes needed.

Standard (default)

The baseline. Optimized for typical displays in well-lit environments. Text-to-background ratios comfortably exceed WCAG AA across all semantic roles.

Low Contrast (dimmer)

Reduces visual intensity for low-light or eye-strain situations. Every token shifts ~1 shade step toward the background: text softens, borders lighten, interactive-state alphas decrease. In dark mode, backgrounds are additionally darkened via LCH transforms to create a deeper, less luminous base.

High Contrast

Amplifies separation between foreground and background for bright environments, small screens, or users who need stronger differentiation. Text shifts ~1 step darker (light mode) or brighter (dark mode), borders become more prominent, and interactive-state alphas increase.

Light mode

TokenStandardDimmerHigh Contrast
bg.0 gray.25 gray.50 gray.25
bg.1 pure.white gray.25 pure.white
bg.2 gray.25 gray.50 gray.25
bg.3 pure.white gray.25 pure.white
text.1 pure.black gray.900 pure.black
text.2 gray.850 gray.800 gray.850
text.3 gray.700 gray.700 gray.800
text.4 gray.600 gray.600 gray.700
border.1 gray.500 gray.400 gray.600
border.2 gray.150 gray.150 gray.300
border.3 gray.100 gray.100 gray.200
border.success lime.500 lime.400 lime.600
border.danger red.500 red.400 red.600
border.warning yellow.400 yellow.300 yellow.500
state.hover gray.200 alpha 0.23 gray.200 alpha 0.16 gray.200 alpha 0.30
state.selected gray.300 alpha 0.26 gray.300 alpha 0.19 gray.300 alpha 0.34

Dark mode

TokenStandardDimmerHigh Contrast
bg.0 pure.black pure.black darken 0.80 pure.black
bg.1 gray.1000 gray.1000 darken 0.60 gray.1000
bg.2 gray.950 gray.950 darken 0.56 gray.950
bg.3 gray.950 gray.950 darken 0.56 gray.950
text.1 gray.25 gray.150 pure.white
text.2 gray.200 gray.300 gray.150
text.3 gray.500 gray.500 gray.400
text.4 gray.600 gray.600 gray.500
text.brand blue.400 blue.500 blue.300
border.1 gray.500 gray.800 gray.400
border.2 gray.850 gray.900 gray.800
border.3 gray.900 gray.950 gray.850
border.success forest.500 forest.600 forest.400
border.danger red.600 red.700 red.500
border.warning yellow.500 yellow.600 yellow.400
state.hover gray.850 alpha 0.20 gray.850 alpha 0.16 gray.850 alpha 0.36
state.selected gray.850 alpha 0.25 gray.850 alpha 0.20 gray.850 alpha 0.40

Color Vision Adaptations

Color-blind modes remap semantic hues so that information is conveyed through brightness hierarchy rather than hue alone. The human retina has three cone types — L (red), M (green), and S (blue). When one type is absent, certain color pairs become indistinguishable. Our adaptations shift the affected hues to perceptually distinct alternatives.

Each CVD type is a separate theme mode (e.g. light-std-protanopia, dark-std-deuteranopia). The remapping is applied at the token level — components need no changes.

Protanopia (red-blind)

Missing L-cones (long wavelength). Red appears dark and muddy, indistinguishable from dark green or brown. Affects ~1.3% of males, ~0.02% of females. Because red is severely darkened, we shift danger to the brighter yellow and cascade other warm hues down. Brand (indigo) remains unchanged since blue-violet perception is intact.

RoleDefaultAdaptedRationale
brand

indigo

indigo

Unchanged, blue-violet visible
success

lime

indigo

Green invisible, shift to blue-violet

danger

red

yellow

Red invisible, shift to bright warm

warning

yellow

orange

Darker than danger for hierarchy
set.blue

blue

cyan

Differentiate from brand (indigo)

set.forest-green

forest

cyan

Green invisible, shift to cool blue

set.mint

mint

indigo

Green-teal invisible
set.orange

orange

brown

Freed for warning
set.pink

pink

purple

Red component invisible

Deuteranopia (green-blind)

Missing M-cones (medium wavelength). Green merges with red into a brownish-khaki range. Affects ~1.2% of males, ~0.01% of females. Similar confusion axis to protanopia, but reds are not darkened — only hue is lost. Brand shifts from indigo to blue for stronger separation from the success role (also remapped to indigo).

RoleDefaultAdaptedRationale
brand

indigo

blue

Separate from success (indigo)
success

lime

indigo

Green invisible, shift to blue-violet

danger

red

yellow

Red merges with green, shift to warm

warning

yellow

orange

Darker than danger for hierarchy
set.forest-green

forest

blue

Green invisible, shift to blue
set.mint

mint

indigo

Green-teal invisible
set.orange

orange

brown

Freed for warning
set.pink

pink

purple

Red component invisible

Tritanopia (blue-yellow blind)

Missing S-cones (short wavelength). Blue merges with green, and yellow becomes indistinguishable from light pink. Very rare — affects ~0.003% of the population, equally across genders (not X-linked). Brand (indigo) stays unchanged since even without S-cones, the luminance channel still differentiates blue from other hues.

RoleDefaultAdaptedRationale
warning

yellow

orange

Yellow invisible
set.orange

orange

brown

Freed for warning
set.purple

purple

pink

Blue component confused with green

set.violet

violet

red

Blue component confused with green