# Desaturate Status Colors on Dark

**SHOULD** · **ID:** `design-interface-desaturate-on-dark` · **Category:** design
**Source:** [interface-design](https://github.com/Dammyjay93/interface-design)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-interface-desaturate-on-dark

> SHOULD: Give each semantic color (success, error, warning) a SEPARATE dark-theme value: hold the hue angle, raise lightness a little, and pull chroma DOWN. A swatch picked to fight a white surface has nothing to fight on near-black — it vibrates and the glyph edges shimmer. It is not a contrast failure (the ratio may pass), it is a saturation failure. On dark, also lean on borders rather than shadows, which barely read. This moves the OPPOSITE way from `design-impeccable-tinted-neutrals` (which pushes a little brand chroma INTO the neutrals); a palette should do both.

Keep the hue and drop the chroma when a semantic color moves to a dark surface, because the swatch tuned for white vibrates on near-black

A success green and an error red are picked against white, where a high-chroma swatch has to fight a bright surface to be seen. Move the identical token to a near-black panel and the fight is over: the same chroma now sits against almost no lightness, the edges of the glyphs shimmer, and the badge buzzes rather than reads. It is not a contrast failure — the ratio may pass — it is a saturation failure, and the fix is the third sentence of the quote: same hue, less chroma. In OKLCH that is one number: hold the hue angle, raise lightness a little so the badge stays legible, and pull chroma down until the swatch sits still. This is NOT design-impeccable-tinted-neutrals, which pushes the NEUTRALS a tiny amount of chroma TOWARD the brand hue so surfaces feel like one system. This rule moves in the opposite direction on the opposite tokens: it takes chroma AWAY from an already-saturated SEMANTIC color so it survives a dark surface. A palette can and should do both. Express the pair as two theme values per semantic role rather than one, and let the dark token be a deliberate value, not the light one reused.

## Bad — do not do this

`design-interface-desaturate-on-dark-bad`

```tsx
import type { CSSProperties } from 'react';

/**
 * Bad: ONE token per semantic role, reused on both surfaces. The success and error
 * swatches were picked against white, where high chroma is what makes them visible.
 * Dropped onto near-black they keep every bit of that chroma, and the badges buzz.
 *
 * Literals are deliberately confined to these two OKLCH values — the offending color
 * IS the subject of this principle.
 */
const ONE_TOKEN_PER_ROLE = {
  // The classic saturated pair, tuned for a white page and never re-tuned.
  '--status-success': 'oklch(0.72 0.19 149)',
  '--status-error': 'oklch(0.64 0.21 25)',
} as CSSProperties;

function Badges() {
  return (
    <div className="flex gap-2">
      <span className="rounded-md px-2 py-1 text-xs font-semibold text-white" style={{ backgroundColor: 'var(--status-success)' }}>
        Deployed
      </span>
      <span className="rounded-md px-2 py-1 text-xs font-semibold text-white" style={{ backgroundColor: 'var(--status-error)' }}>
        Failed
      </span>
    </div>
  );
}

export function InterfaceDesaturateOnDarkBad() {
  return (
    <div className="w-full space-y-3" style={ONE_TOKEN_PER_ROLE}>
      <p className="text-xs text-muted-foreground">Same two tokens on both surfaces</p>

      <div className="grid grid-cols-2 gap-3">
        <div className="space-y-2 rounded-lg bg-white p-4">
          <p className="text-xs font-medium text-neutral-500">Light surface</p>
          <Badges />
        </div>

        <div className="space-y-2 rounded-lg bg-neutral-950 p-4">
          <p className="text-xs font-medium text-neutral-300">Dark surface</p>
          <Badges />
        </div>
      </div>

      <div className="rounded bg-muted p-2 font-mono text-xs">
        <code className="text-error">--status-success / --status-error: one value, both themes</code>
      </div>

      <p className="text-xs text-error">
        On white the chroma is doing a job: it has a bright surface to fight. On near-black there is
        nothing left to fight, so the same chroma just vibrates &mdash; the glyph edges shimmer and the
        badge reads as loud rather than as information. This is not a contrast failure (the ratio can
        pass); it is a saturation failure, and no amount of adjusting lightness alone will fix it.
      </p>
    </div>
  );
}
```

## Good — do this

`design-interface-desaturate-on-dark-good`

```tsx
import type { CSSProperties } from 'react';

/**
 * Good: TWO values per semantic role — one for each surface. Same hue angle, so the
 * green is still that green; chroma pulled down and lightness nudged up for dark, so
 * the badge sits still instead of buzzing. Optically matched, not literally identical.
 *
 * Literals are deliberately confined to these four OKLCH values — the offending color
 * IS the subject of this principle. Only chroma and lightness move; hue never does.
 */
const SUCCESS_HUE = 149;
const ERROR_HUE = 25;

const onLight = {
  '--status-success': `oklch(0.72 0.19 ${SUCCESS_HUE})`,
  '--status-error': `oklch(0.64 0.21 ${ERROR_HUE})`,
} as CSSProperties;

// Same hue. Chroma down (0.19 → 0.11, 0.21 → 0.13), lightness up to stay legible.
const onDark = {
  '--status-success': `oklch(0.78 0.11 ${SUCCESS_HUE})`,
  '--status-error': `oklch(0.71 0.13 ${ERROR_HUE})`,
} as CSSProperties;

function Badges({ ink }: { ink: string }) {
  return (
    <div className="flex gap-2">
      <span className={`rounded-md px-2 py-1 text-xs font-semibold ${ink}`} style={{ backgroundColor: 'var(--status-success)' }}>
        Deployed
      </span>
      <span className={`rounded-md px-2 py-1 text-xs font-semibold ${ink}`} style={{ backgroundColor: 'var(--status-error)' }}>
        Failed
      </span>
    </div>
  );
}

export function InterfaceDesaturateOnDarkGood() {
  return (
    <div className="w-full space-y-3">
      <p className="text-xs text-muted-foreground">One hue per role, a value per surface</p>

      <div className="grid grid-cols-2 gap-3">
        <div className="space-y-2 rounded-lg bg-white p-4" style={onLight}>
          <p className="text-xs font-medium text-neutral-500">Light surface</p>
          <Badges ink="text-white" />
        </div>

        <div className="space-y-2 rounded-lg bg-neutral-950 p-4" style={onDark}>
          <p className="text-xs font-medium text-neutral-300">Dark surface</p>
          {/* The desaturated, lighter fills want dark ink on top */}
          <Badges ink="text-neutral-950" />
        </div>
      </div>

      <div className="space-y-1 rounded bg-muted p-2 font-mono text-xs">
        <code className="block">light: oklch(0.72 0.19 149) &middot; oklch(0.64 0.21 25)</code>
        <code className="block">dark: &nbsp;oklch(0.78 0.11 149) &middot; oklch(0.71 0.13 25)</code>
      </div>

      <p className="text-xs text-success">
        Hue is held constant, so nobody would call these different colors; chroma is the only thing
        that moves, and the dark badges stop vibrating. Note this runs OPPOSITE to
        <code className="mx-1 font-mono">design-impeccable-tinted-neutrals</code>, which ADDS a trace of
        chroma to neutrals so they belong to the brand. Here we TAKE chroma away from an already-saturated
        semantic color so it survives a dark surface. A palette should do both.
      </p>
    </div>
  );
}
```

## References

- [interface-design (Damola Akinleye)](https://github.com/Dammyjay93/interface-design)
- [OKLCH color picker](https://oklch.com/)
- [Material Design: Dark theme](https://m2.material.io/design/color/dark-theme.html)
