Color
Components use semantic color tokens, like primary-background. Each token picks a shade from a palette, and every text color is checked for contrast before it ships.
The palettes
nxds has five neutral palettes, with eleven shades each. A shade number means the same lightness in every neutral palette, so every 500 looks equally light.
Brand and status colors come from the chromatic palettes (the palettes with a hue, like blue or red). Each hue has its own lightness curve, so yellow stays light and red runs deep. The color-vision filter shows every palette as someone with color blindness sees it.
Brand & status hues
Brand modes are blue, purple, pink, teal, orange, and black. The chromatic brand modes share the OKLCH pipeline; black is a monochrome semantic recipe built from black.base, white.base, and neutral support. Switch to a color-vision filter and watch the red/green pair (error/success) converge: that’s why status never relies on hue alone.
All color scales (17)
The full chromatic set. Brand uses blue, purple, pink, teal, and orange; status uses red, yellow, green, and blue. The rest are raw primitives available for data viz and one-off use.
Live tokens
These are the tokens components use. Change the theme with the theme picker, and every pair updates.
A foreground token names the surface it sits on, not what it paints. primary-foreground colors anything on primary-background: a button’s label, or a checked switch’s knob. error-subtle-foreground colors the message and icon on error-subtle.
Each foreground is checked against the surfaces nxds pairs it with. On any other surface, check the contrast yourself.
What each shade is for
The shade number is a lightness step. Each token uses a different step in each mode, so it keeps the same visual weight on a dark background.
Text and thin borders don’t use a shade. foreground, muted-foreground and border.default are semi-transparent black in light mode and white in dark mode, so they work on any surface.
| Shade | Role | Light-mode use | Dark-mode use |
|---|---|---|---|
| 50 | Near-white | muted, disabled, background/container/popover-hover | nav-foreground |
| 100 | Very light | background-active, container-active, control-background, nav-background | — |
| 200 | Light | control-background-hover, nav-item-hover/active, nav-border | — |
| 300 | Light-medium | — | disabled-foreground, nav-muted-foreground |
| 400 | Medium-light | border-focus, disabled-foreground | border-focus |
| 500 | Mid | — (perceptual anchor) | — (anchor) |
| 600 | Medium-dark | nav-muted-foreground, brand -background | — |
| 700 | Dark | brand -background-hover | control-background-hover, popover-hover |
| 800 | Very dark | brand -background-active | control-background, popover, container-hover, nav-border |
| 900 | Darker | container-foreground, popover-foreground, nav-foreground | container, muted, background-hover/active, nav-item-hover/active |
| 950 | Near-black | — (rarely surfaced) | background — the canvas, nav-background, disabled |
Accessibility
nxds checks contrast with APCA (a perceptual contrast score), not WCAG 2 ratios. WCAG 2 ratios misjudge the mid-tone pairs a token system produces all the time.
Each pair has a minimum score for how it’s used. CI derives themes across many brand colors, surface tones and contrast settings, then checks every pair in each one. If any pair falls below its minimum, the build fails.
CI also checks that the palettes stay distinct for color-blind users.
Every foreground is checked against each surface it can sit on, including hover, pressed and translucent states. If a brand or status label can’t reach its minimum, nxds shifts that fill’s shade. Otherwise the fill keeps the brand color you chose.
| Pair | Min APCA | Covers |
|---|---|---|
| foreground ↔ background | Lc ≥ 75 | Body text |
| {brand,status}-foreground ↔ -background | Lc ≥ 60 | UI labels — buttons, badges |
| *-subtle-foreground ↔ -subtle | Lc ≥ 60 | Labels on tinted fills |
| nav-foreground ↔ nav surfaces | Lc ≥ 60 | Nav label text |
| chart.categorical ↔ container | Lc ≥ 60 | Chart marks |
| muted-foreground ↔ muted | Lc ≥ 45 | Incidental / de-emphasised text |
| disabled-foreground ↔ disabled | Lc ≥ 45 | Disabled-state text |
| focus ring ↔ every surface | Lc ≥ 45 | Focus indicators |
Contrast setting
The lightContrast and darkContrast fields in Appearance raise the minimums as they go from 0 to 100. When a target can’t be reached, nxds uses the highest contrast possible. The minimums at 0 always apply.
| Target | At 0 | At 100 |
|---|---|---|
| Body text | Lc 75 | Lc 90 |
| UI labels | Lc 60 | Lc 75 |
| Incidental text | Lc 45 | Lc 60 |
| Light-mode border opacity | 6% | 11.68% |
How it works
- 1Store hex
Tokens live as hex on disk.
- 2Convert to OKLCH
At build time, every hex value becomes an oklch(…) value in the CSS.
- 3Pin the lightness
Each shade’s lightness is set from a perceptual grid. The neutral palettes share one grid. Each chromatic hue follows its own curve and takes the most vivid chroma Display-P3 allows.
- 4Gate with APCA
Every foreground and background pair is scored with APCA. A failing pair blocks the build.