Colors
Six ramps, five semantic roles, and one rule that outranks all of them: colour is never the only thing carrying the message.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Neutral
--p-neutral-*Brand · Iris
--p-brand-*Success · Emerald
--p-success-*Warning · Amber
--p-warning-*Danger · Rose
--p-danger-*Info · Blue
--p-info-*Semantic roles
Each row is one role in its five certified forms. The -text value is the only one guaranteed to pass on -subtle; the -fg value is the only one guaranteed to pass on the solid base.
Live contrast audit
Computed from the running stylesheet on every render, with alpha tints composited against their surface. Switch the app theme and watch every row recalculate.
| Pair | Sample | Ratio | Grade |
|---|---|---|---|
| Body text on page | Aa 15px | — | — |
| Secondary text on card | Aa 15px | — | — |
| Caption on card | Aa 15px | — | — |
| Label on filled button | Aa 15px | — | — |
| Accent text on tonal fill | Aa 15px | — | — |
| Success text on tint | Aa 15px | — | — |
| Warning text on tint | Aa 15px | — | — |
| Danger text on tint | Aa 15px | — | — |
| Label on danger button | Aa 15px | — | — |
| Disabled text (exempt) | Aa 15px | — | exempt |
The greyscale test
Run any status UI through a greyscale filter. If you can still tell success from failure, colour was doing its job as reinforcement. If you cannot, colour was doing the whole job.
Categorical palette
Eight hues ordered for maximum separation between neighbours, including under deuteranopia. Never used for status — a chart series that happens to be red does not mean an error.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Payment declined
Card ending 4242 was refused by the issuer.
1 · fill
--ds-danger-subtle2 · border
--ds-danger-border3 · icon+label
--ds-danger-text4 · body
--ds-fg-secondaryOne status message uses four different tokens from two families. Substituting any one of them for another breaks either contrast or meaning.
- Subtle fill14% alpha of the 400 step
Alpha, not a solid, so the same token works over a card, a table row, or a nested panel. A solid tint only matches one background.
- Border34% alpha
Must reach 3:1 against the surrounding surface — WCAG 1.4.11 covers UI boundaries, not just text.
- Status text and icon--ds-danger-text
A lighter ramp step than the base in dark mode and a darker one in light mode. This is the pair certified against the subtle fill.
- Body copy--ds-fg-secondary
Deliberately neutral. Colouring the entire message red reduces legibility and makes the severity read as louder than it is.
- Redundant encodingicon + heading text
The word "declined" and the triangle both carry the meaning. Remove the colour entirely and nothing is lost.
Every colour in the active theme, resolved from the running stylesheet. Click any swatch to copy its value.
Primitives
--p-*Raw values. Identical in both themes — never reference these from a component.Neutral
--p-neutral-*Brand · Iris
--p-brand-*Success · Emerald
--p-success-*Warning · Amber
--p-warning-*Danger · Rose
--p-danger-*Info · Blue
--p-info-*Categorical
--p-viz-*Semantics
--ds-*What components actually use. Switch the theme and every value below re-reads.Surfaces
Interaction layers
Foreground
Borders
Brand & focus
Success
Warning
Danger
Info
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Surfaces | ||
| --ds-canvas | — | Page background — the furthest-back plane |
| --ds-sunken | — | Recessed regions that read as below the canvas |
| --ds-surface | — | Cards, panels, the default raised plane |
| --ds-surface-raised | — | A surface on a surface |
| --ds-surface-overlay | — | Dialogs, menus, popovers |
| --ds-surface-inset | — | Wells — inputs, code, table headers |
| Interaction layers | ||
| --ds-layer-hover | — | Hover wash over any surface |
| --ds-layer-active | — | Pressed and held states |
| --ds-layer-selected | — | Persistent selection tint — never reuse the hover value |
| --ds-layer-scrim | — | Backdrop behind modals and drawers |
| Foreground | ||
| --ds-fg | — | Headings and primary text |
| --ds-fg-secondary | — | Body copy |
| --ds-fg-muted | — | Captions, metadata, placeholders, icons |
| --ds-fg-disabled | — | Disabled text — formally exempt from contrast rules |
| --ds-fg-on-accent | — | Text sitting on a solid brand fill |
| --ds-fg-inverse | — | Text on an inverted surface — tooltips, toasts |
| Borders | ||
| --ds-border-subtle | — | Dividers and card edges |
| --ds-border | — | Default component borders |
| --ds-border-strong | — | Checkbox and radio outlines, scrollbar thumbs |
| --ds-border-interactive | — | The edge of something you can click |
| Brand & focus | ||
| --ds-accent | — | Primary action and selection |
| --ds-accent-subtle | — | Tonal brand fill behind text or an icon |
| --ds-accent-border | — | Brand-tinted edge at ≥3:1 |
| --ds-accent-text | — | The pair certified for text on --ds-accent-subtle |
| --ds-focus-ring | — | One ring for the whole system — clears 3:1 on every surface |
| Status | ||
| --ds-success | — | Completed, healthy, approved |
| --ds-warning | — | Degraded, needs attention, reversible risk |
| --ds-danger | — | Failed, destructive, irreversible |
| --ds-info | — | Neutral system information |
| --ds-{role}-subtle / -border / -text / -fg | — | The four certified companions every role ships with |
| Data visualisation | ||
| --p-viz-1 … --p-viz-8 | — | Charts, avatars, categorical tags — never status |
Your trial ends in three days. Add a payment method to keep your projects online.
Pure #FFF on pure #000 — 21:1 and unpleasant.
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Body text (under 18.66px regular / 14px bold) needs 4.5:1. Large text needs 3:1. UI boundaries and meaningful graphics need 3:1.
- Alpha fills must be composited against their actual background before measuring — a 14% tint measured against transparent is meaningless.
- Disabled controls are formally exempt, which is why "just disable it" is a way of hiding an accessibility problem rather than solving one.
- AAA (7:1) is the target for long-form reading surfaces. Our body text hits 13.8:1 in dark and 15.9:1 in light.
Keyboard
| ⌘I | Inspector Mode reports the live contrast ratio of anything you hover. |
Screen readers
- Screen readers do not announce colour. Any meaning encoded in a fill must also exist as text, an accessible name, or an icon with a label.
- Charts need direct labels or a data table alternative; a legend that maps colour to series is useless without colour vision.
Focus & touch
- The focus ring uses a fixed accent step chosen to clear 3:1 against every surface token in both themes — that is why it is --ds-focus-ring rather than simply reusing --ds-accent.
- Colour has no bearing on hit area, but low-contrast controls are measurably harder to acquire — users aim more slowly at targets they can barely see, which compounds Fitts’ Law.
| Attribute | Applied to | Notes |
|---|---|---|
| forced-colors: active | Media query | Windows High Contrast Mode replaces your palette wholesale. Test it — anything conveyed only by background colour disappears entirely. |
| color-scheme | :root | Declares dark or light to the UA so native controls, scrollbars, the caret and autofill match your surface. |
| aria-label / visible text | Status elements | The state must be readable. A green dot with no accessible name announces as nothing at all. |
Example usage
Prefer the semantic utility. Reach for a raw var() only when no utility exists.
1// Surfaces2<div className="bg-canvas">3 <div className="bg-surface border border-line-subtle rounded-xl">4 <div className="bg-surface-inset" />5 </div>6</div>78// Text hierarchy9<h2 className="text-fg">Primary</h2>10<p className="text-fg-secondary">Body copy</p>11<p className="text-fg-muted">Caption</p>1213// A status message — four tokens, two families14<div className="bg-danger-subtle border border-danger-border rounded-lg p-4">15 <AlertTriangle className="text-danger-text" aria-hidden />16 <p className="text-fg">Payment declined</p>17 <p className="text-fg-secondary">Card ending 4242 was refused.</p>18</div>1920// Categorical colour for a chart — never a status family21const SERIES = [1, 2, 3, 4, 5, 6, 7, 8].map(22 (i) => 'var(--p-viz-' + i + ')'23)CSS
How a status family is defined. Note that both themes are always written together.
:root, [data-theme='dark'] {
--ds-danger: #ee4351; /* solid fill */
--ds-danger-hover: #fa5b68;
--ds-danger-active: #d62838;
--ds-danger-fg: #ffffff; /* text ON solid */
--ds-danger-subtle: rgb(250 100 112 / 0.14); /* tinted fill */
--ds-danger-border: rgb(250 100 112 / 0.36); /* >= 3:1 edge */
--ds-danger-text: #ff929b; /* text ON tint */
}
[data-theme='light'] {
--ds-danger: #d62838; /* darker: needs 4.5:1 with white */
--ds-danger-fg: #ffffff;
--ds-danger-subtle: rgb(238 67 81 / 0.10);
--ds-danger-border: rgb(214 40 56 / 0.28);
--ds-danger-text: #b21c2b; /* darker still: sits on a tint */
}
/* High-contrast mode strips colour entirely — keep the structure */
@media (forced-colors: active) {
.status { border: 1px solid; }
}Professional tips
- Design the dark theme first if your product is developer-facing, and the light theme first if it is consumer-facing. Whichever you build second will expose the places where colour was doing structural work.
- When you need "one more" colour, try changing lightness before changing hue. A second hue costs the user a new thing to learn; a lighter step of an existing hue costs nothing.
- The accent colour should not appear in your empty states, your table borders, or your body text. If it does, it stops meaning "here is the action".
- Test on an OLED phone at minimum brightness and on a cheap IPS panel at maximum. Ramps that look smooth on a calibrated monitor often band badly on both.
Performance
- color-mix() and relative colour syntax are resolved at computed-value time and are effectively free — you do not need to precompute tints into static hexes.
- Avoid animating background-color on large surfaces during scroll; paint cost scales with area. Animate opacity of an overlay instead.
- A gradient with many stops is more expensive to paint than a flat fill. On a full-page background, use at most two radial gradients at low alpha.
Common mistakes
- Measuring contrast against the token value instead of the composited result. A 14% alpha over a dark card is not the colour in the token.
- Using the same colour for "selected" and "hover". The user cannot tell whether a row is currently selected or just under the cursor.
- Assigning status colours to chart series. A red line in a revenue chart reads as a failure even when it is just Q3.
- Reusing the warning amber for a brand highlight. Now every promotional banner in the product looks like a system alert.
Real-world recommendations
- Ship a colour-blindness simulator in your review process. Chrome DevTools has one built in under Rendering → Emulate vision deficiencies; use it on every status screen before merge.
- When a stakeholder asks to "make it pop", the answer is almost never more saturation. It is usually more contrast between adjacent elements, or more whitespace around the thing that matters.
- Keep a single page in your app that renders every semantic pair with its live contrast ratio, exactly like the audit above. It turns an abstract rule into a build-time regression test.
- Brand colours from a marketing palette are almost never accessible as UI colours. Take the hue, rebuild the ramp for the screen, and give the marketing value back to marketing.
A decision table for the question that comes up most often.
| Situation | Role | Why |
|---|---|---|
| The action the product wants you to take | accent | Points. Exactly one per view. |
| Operation finished and cannot be undone by mistake | success | Confirms. Fades from attention quickly. |
| Something needs attention but nothing is broken yet | warning | Interrupts without alarming. |
| Failed, blocked, or destructive | danger | Stops. Never used decoratively. |
| Neutral system fact the user did not ask for | info | Informs without implying action. |
| A category with no inherent good or bad | viz-1…8 | Distinguishes. Carries no valence. |