Intent palette
The palette below is WR_COLORS itself, in palette order. Each intent exposes its base value plus four generated shades (-dark −5%, -darker −10%, -light +5%, -lighter +10% lightness), an -rgb channel, and a -contrast — whichever of pure black or pure white scores higher against the fill on real WCAG contrast ratio. It picks, it does not blend, so those two values are the ceiling for every label.
primarysecondarysuccesswarningdangerinfolightmediumdarkUsing an intent
Pair the base with its -contrast for solid surfaces. The shade variants give you hover / pressed states at compile time — no runtime Sass.
/* Solid surface: pair the base with its auto-computed contrast. */
.toast {
background: var(--wr-color-primary);
color: var(--wr-color-primary-contrast);
}
/* Hover / pressed shades come for free, no Sass needed at runtime. */
.toast:hover { background: var(--wr-color-primary-dark); }
.toast:active { background: var(--wr-color-primary-darker); }The -rgb channel
Each intent ships --wr-color-{intent}-rgb — the raw R, G, B triple. Compose any alpha you need at the call site with rgba().
/* The -rgb channel composes any alpha you need at the call site. */
.ring {
box-shadow: 0 0 0 4px rgba(var(--wr-color-primary-rgb), 0.2);
}Soft set
Seven intents (primary, secondary, success, warning, danger, info, medium — not light or dark) also expose a soft set: a low-alpha fill, a matching hairline, a pressed tint, and a readable same-hue text color. Every alpha lives in exactly one place, so soft surfaces stay consistent everywhere.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-color-{intent}-soft | Canonical low-alpha fill. The tinted surface behind soft badges, alerts, selected rows. | rgba(var(--wr-color-{intent}-rgb), 0.12) | — |
--wr-color-{intent}-soft-border | Matching hairline for a soft fill — the 1px border that pairs with -soft. | rgba(var(--wr-color-{intent}-rgb), 0.3) | — |
--wr-color-{intent}-active | Pressed / active tint — one notch stronger than -soft for the held state. | rgba(var(--wr-color-{intent}-rgb), 0.2) | — |
--wr-color-{intent}-soft-contrast | Readable same-hue text on a soft fill. Deep in light mode, light in dark — it follows --wr-color-dark. | color-mix(in srgb, var(--wr-color-{intent}) 62%, var(--wr-color-dark)) | — |
/* Soft set — tinted surface + matching hairline + same-hue text. */
.alert--danger {
background: var(--wr-color-danger-soft); /* α 0.12 */
border: 1px solid var(--wr-color-danger-soft-border); /* α 0.30 */
color: var(--wr-color-danger-soft-contrast); /* mixed 62% + dark */
}
.alert--danger:active {
background: var(--wr-color-danger-active); /* α 0.20 */
}Ink — the intent as text
-contrast is the label ON a filled intent; -ink is the intent used AS text. Mixing them up is how the light theme ended up failing AA on every outlined variant.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-color-{intent}-ink | The intent darkened (light) / lightened (dark) until it clears WCAG AA as its own label — what outlined and ghost variants, tags and tinted chips paint with. The bare intent does not: on white, warning is 1.71:1 and success 3.33:1. The share is per intent, each the most saturated value that still reaches 5.0:1 against that intent's own -soft tint in BOTH themes — primary 78%, secondary 75%, danger 72%, info 71%, success 67%, medium 65%, warning 48%. Warning travels furthest because a yellow readable on white is not yellow. primary was re-derived from 86% when the dark base deepened to #3567ff: a deeper base makes the ink deeper too, which on a dark tint is the losing direction, and axe measured the old share at 4.48:1 on the sidebar's own tint. The 5.0 target is deliberate headroom over AA's 4.5: an earlier pass aimed at 4.5 exactly and a slightly different background pushed wr-typography--code back under it. light-ink is --wr-color-on-surface (a surface tone has no readable darkening) and dark-ink is --wr-color-dark. | color-mix(in srgb, var(--wr-color-{intent}) 48–78%, var(--wr-color-dark)) | — |
Semantic neutrals
Theme-aware tokens for the chrome around your content — surfaces, dividers, scrims, and muted text. These are not per-intent; there is one canonical token for each job. The borders and the hover tint are built on the flipping light channel, so they adapt to dark mode automatically; muted text has its own base (--wr-color-muted-text, re-tuned per theme) because as text it needs contrast the medium fill cannot give it.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-color-white | The page surface. Flips to a deep slate canvas in dark mode. | #ffffff → #0b1120 (dark) | — |
--wr-color-black | Pure black. Does not flip — true black regardless of theme. | #000000 | — |
--wr-color-hover | Generic subtle hover tint (icon buttons, list rows). Adapts in dark via the flipping light channel. | rgba(var(--wr-color-light-rgb), 0.4) | — |
--wr-color-border | Default divider / border. Translucent so it reads on any surface. | rgba(var(--wr-color-light-rgb), 0.5) | — |
--wr-color-border-subtle | Quieter hairline — for low-emphasis separators. | rgba(var(--wr-color-light-rgb), 0.35) | — |
--wr-color-border-strong | Heavier border — for focused or emphasized edges. | rgba(var(--wr-color-light-rgb), 0.6) | — |
--wr-color-overlay | Modal / drawer scrim. Built on -backdrop-rgb so it stays a black dim in both themes. | rgba(var(--wr-color-backdrop-rgb), 0.45) | — |
--wr-color-backdrop-rgb | Always-black channel for scrims. Not wired to -dark-rgb, which would flip to a white wash in dark. | 0, 0, 0 | — |
--wr-color-text-muted | De-emphasized text — muted labels, captions. 0.95, not a rounder 0.85: wrapping the role at 0.85 composited to 3.90:1 on the light surface, under the AA bar. 0.95 is the most softening that still clears it (4.81). | rgba(var(--wr-color-muted-text-rgb), 0.95) | — |
--wr-color-text-faint | Faintest text — placeholders, disabled hints. | rgba(var(--wr-color-muted-text-rgb), 0.6) | — |
/* Semantic neutrals — theme-correct surfaces, borders, muted text. */
.card {
background: var(--wr-color-white); /* page surface */
border: 1px solid var(--wr-color-border);
color: var(--wr-color-dark); /* body text */
}
.card__meta { color: var(--wr-color-text-muted); }
.card__row:hover { background: var(--wr-color-hover); }Gray ramp
A fixed neutral primitive — slate from 50 (subtlest surface tint) to 950 (strongest ink). Unlike the semantic neutrals it does not flip in dark mode; each step is a stable building block. Reach for the role aliases below when you want a value that adapts to the theme.
50100200300400500600700800900950/* Fixed slate primitive — does NOT flip; pick a step by job. */
.code-block {
background: var(--wr-color-gray-50); /* subtlest surface */
border: 1px solid var(--wr-color-gray-200);
}
.ink-strong { color: var(--wr-color-gray-900); }Role aliases
Intent-free semantic roles layered over the flipping neutrals. Prefer these in new code for surface / text jobs — they resolve through white / dark / muted-text, each of which is re-tuned for the dark canvas, so they adapt to dark mode for free. The underlying --wr-color-white / -dark / -muted-text primitives stay available.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-color-surface | Page / card background. Flips with the theme (white → slate canvas). | var(--wr-color-white) | — |
--wr-color-on-surface | Primary text on a surface. Flips with the theme. | var(--wr-color-dark) | — |
--wr-color-on-surface-muted | Secondary / muted text on a surface. | var(--wr-color-muted-text) | — |
--wr-color-outline | The opaque hairline components draw 1px rules with — borders, dividers, table gridlines. Distinct from --wr-color-border, which is the same hue at 50% alpha so it reads over any fill; swapping one for the other changes appearance, not just naming. | var(--wr-color-light) | — |
/* Role aliases — intent-free, theme-aware surface / text. */
.panel {
background: var(--wr-color-surface);
color: var(--wr-color-on-surface);
}
.panel__caption { color: var(--wr-color-on-surface-muted); }Neutral fills
The low-contrast wash a header, a track or a readonly field sits on — three steps, and not the same thing as a border.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-color-fill-subtle | Barely there — a hovered table row, a detail row, a group band. | rgba(var(--wr-color-light-rgb), 0.15) | — |
--wr-color-fill | The default neutral surface — a table header, a readonly field, a grouped control. | rgba(var(--wr-color-light-rgb), 0.3) | — |
--wr-color-fill-strong | The pronounced one — a code chip, a progress track, a drop zone. | rgba(var(--wr-color-light-rgb), 0.55) | — |
These read --wr-color-light-rgb rather than --wr-color-outline-rgb, even though the two resolve to the same channel. A fill is not a hairline: when 33 declarations across 22 files hand-rolled this wash out of the outline channel, retuning outlines for their own job would have moved every one of them — and one measured pair in wr-table drops from 6.58:1 to 4.05:1 that way. Reach for --wr-color-outline when you are drawing a rule, and for these when you are filling a shape.
The focus ring
One family for the keyboard indicator, so it is themable like everything else. It is an outline, and that is a decision rather than a default. One thing it is NOT: universal. Every control that draws a ring reads these tokens and nothing else — buttons included since v14.1, and with them the pagination cells and the column-sort control, which had been left on the browser's own outline where no token reaches — but a text field marks focus differently on purpose, by raising its border and halo to --wr-color-primary, so these four do not recolour wr-input, wr-textarea or an input group. The snippet below shows both halves.
| Name | Description | Type | Default |
|---|---|---|---|
--wr-focus-ring-color | The outline colour. Measured against the page it draws on: 4.89:1 in light, 4.07:1 in dark, where WCAG 1.4.11 asks 3:1 of a focus indicator. | var(--wr-color-primary) | — |
--wr-focus-ring-width | Outline thickness. | 2px | — |
--wr-focus-ring-offset | Gap between the control and the outline, and the halo spread — the halo fills exactly that gap, so the two read as one ring. A component whose target sits inside a clipping container insets it instead (outline-offset: -2px). | 2px | — |
--wr-focus-ring-halo | The soft second channel. It used to be the ONLY channel, at 1.41:1 light / 1.31:1 dark, alongside an outline: none — so no part of the indicator met the bar. | rgba(var(--wr-color-primary-rgb), 0.25) | — |
An outline rather than a shadow, for three reasons. It is exempt from layout, so focus never reflows the box. forced-colors keeps it and drops every box-shadow, so a Windows contrast theme still shows where you are. And it can carry a solid colour: the halo alone is a 25% tint of the primary over the page, which measures 1.41:1 — a focus indicator has to reach 3:1.
/* Retheme the ring once and every control that DRAWS one follows —
the library's own mixin reads nothing else. */
:root {
--wr-focus-ring-color: var(--wr-color-secondary);
--wr-focus-ring-width: 3px;
}
/* The exception, and it is deliberate: a text field does not draw the ring.
It marks focus by raising its own border and halo to --wr-color-primary,
which is a field convention rather than an oversight — but it means these
tokens do not reach it. Retheme both, or give the field the ring: */
.wr-input:focus,
.wr-input-group:focus-within,
.wr-textarea--focused {
--wr-input-border: var(--wr-focus-ring-color);
--wr-input-group-border: var(--wr-focus-ring-color);
--wr-textarea-border: var(--wr-focus-ring-color);
}
/* What the mixin emits. Override the offset AFTER an include when the
target sits inside a clipping container, the way the list rows do. */
.wr-list__item--interactive:focus-visible {
outline: var(--wr-focus-ring-width) solid var(--wr-focus-ring-color);
outline-offset: -2px;
}How dark mode flips
Dark mode is opt-in via [data-theme='dark'] on <html> (driven by provideWrTheme()). The key inversion is that --wr-color-white is the page *surface* and --wr-color-dark is the *text* — and both swap roles in dark mode. The intents are not left alone either: seven of the nine are re-tuned for the deep canvas (primary, success, warning, danger for legibility on it, light / medium / dark because they are the neutrals doing the flipping). Only secondary and info keep their light values. Build with the tokens and you write zero dark-mode overrides.
surface = --wr-color-white, text = --wr-color-dark
surface flips to #0b1120, text to #e6ebf3
/* The neutrals swap roles; four intents are re-tuned for the canvas
(primary, success, warning, danger). secondary and info keep their
light values.
--wr-color-white = page surface → #0b1120 in dark
--wr-color-dark = body text → #e6ebf3 in dark
--wr-color-light = borders/tints → #262f44 in dark
--wr-color-medium = medium FILL → #6d7682 in dark
Secondary TEXT is --wr-color-on-surface-muted (#9aa6b8 in dark) — a
different token on purpose: lighter is what makes text legible on a dark
canvas, the opposite direction from a fill. */
[data-theme='dark'] {
/* set automatically by provideWrTheme(); shown here for reference */
}
/* So this card needs NO dark-mode override — the tokens carry it: */
.card {
background: var(--wr-color-white); /* light page / dark canvas */
color: var(--wr-color-dark); /* dark ink / light ink */
}Iterating the palette
Both lists track $base-colors, so a rebrand flows through without touching either. Use the SCSS $colors list to generate per-intent rules the way the lib's own components do; use WR_COLORS when you need the intents at runtime.
/* Author an intent-aware component the way the lib itself does.
`$colors` is `map.keys($base-colors)`, so it tracks the palette — rebrand
with extra intents and your own component picks them up for free. */
@use 'ngwr/theme' as theme;
.my-badge {
@each $name in theme.$colors {
&--#{$name} {
background: var(--wr-color-#{$name});
color: var(--wr-color-#{$name}-contrast);
}
}
}import { WR_COLORS, type WrColor } from 'ngwr/theme';
@Component({...})
export class Palette {
// The TS-side intent list — drives `WrColor`, the type every `color`
// input accepts.
readonly colors = WR_COLORS;
}