# Alpha Is a Design Smell

**SHOULD** · **ID:** `design-impeccable-alpha-smell` · **Category:** design
**Source:** [impeccable](https://impeccable.style/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-impeccable-alpha-smell

> SHOULD: Define an explicit opaque token per surface instead of reaching for `rgba()`/`hsla()` to fake a missing palette step — a translucent color's contrast ratio belongs to the backdrop, not the token, so the same `text-white/60` can measure 6.2:1 on one surface and 2.7:1 on the next. Exception: focus rings and transient hover/pressed states, which never carry text.

Define an explicit opaque token per surface instead of leaning on rgba() and hsla() to fake missing palette steps

A translucent color has no fixed value: the browser flattens it against whatever sits behind it, so the final color — and therefore the contrast ratio — is a property of the backdrop, not of the token. The same `text-white/60` label can measure 6.2:1 on one surface and 2.7:1 on the next, meaning a component that passes AA in the design file silently fails the moment it is reused, and no CI check can catch it because the value only exists at composite time. Stacked alpha compounds this and adds blending work per layer. When you reach for alpha to get "a slightly lighter gray", what you actually need is another step in the palette. The stated exception: focus rings and interactive states (hover, pressed), where the effect is transient and never carries text.

## Bad — do not do this

`design-impeccable-alpha-smell-bad`

```tsx
type Rgb = [number, number, number];

const WHITE: Rgb = [255, 255, 255];
const BANDS: { name: string; fill: Rgb }[] = [
  { name: 'Surface 1', fill: [39, 39, 42] },
  { name: 'Surface 2', fill: [113, 113, 122] },
];

const css = (c: Rgb) => `rgb(${c[0]} ${c[1]} ${c[2]})`;

const toLinear = (channel: number) => {
  const s = channel / 255;
  return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};

const luminance = ([r, g, b]: Rgb) =>
  0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);

const contrast = (a: Rgb, b: Rgb) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

// What the browser actually composites: 60% white over the band underneath.
const flatten = (fg: Rgb, bg: Rgb, alpha: number): Rgb =>
  fg.map((c, i) => Math.round(c * alpha + bg[i] * (1 - alpha))) as Rgb;

export function ImpeccableAlphaSmellBad() {
  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">
        One label token: <code>text-white/60</code>, reused on both surfaces.
      </p>

      <div className="overflow-hidden rounded-lg border border-border">
        {BANDS.map((band) => {
          const ratio = contrast(flatten(WHITE, band.fill, 0.6), band.fill);
          const passes = ratio >= 4.5;
          return (
            <div
              key={band.name}
              className="flex items-center justify-between px-4 py-5"
              style={{ background: css(band.fill) }}
            >
              <span className="text-sm font-medium text-white/60">Last synced 4 minutes ago</span>
              <span
                className={`rounded px-2 py-0.5 text-xs font-semibold ${
                  passes ? 'bg-white text-black' : 'bg-red-600 text-white'
                }`}
              >
                {ratio.toFixed(2)}:1 · {passes ? 'AA pass' : 'AA fail'}
              </span>
            </div>
          );
        })}
      </div>

      <p className="text-xs text-error">
        The same token, two verdicts. A translucent color has no fixed value — the browser flattens it
        against whatever sits behind, so the contrast ratio is a property of the backdrop, not of the
        token. It quietly fails the moment the component moves to another surface.
      </p>
    </div>
  );
}
```

## Good — do this

`design-impeccable-alpha-smell-good`

```tsx
type Rgb = [number, number, number];

// Two explicit, opaque label tokens — one per surface, each measured against its own background.
const BANDS: { name: string; surface: Rgb; label: Rgb; token: string }[] = [
  { name: 'Surface 1', surface: [39, 39, 42], label: [212, 212, 216], token: '--text-on-surface-1' },
  { name: 'Surface 2', surface: [113, 113, 122], label: [255, 255, 255], token: '--text-on-surface-2' },
];

const css = (c: Rgb) => `rgb(${c[0]} ${c[1]} ${c[2]})`;

const toLinear = (channel: number) => {
  const s = channel / 255;
  return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};

const luminance = ([r, g, b]: Rgb) =>
  0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);

const contrast = (a: Rgb, b: Rgb) => {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

export function ImpeccableAlphaSmellGood() {
  return (
    <div className="space-y-3">
      <p className="text-xs text-muted-foreground">
        Two opaque label tokens, one per surface — no alpha in the palette.
      </p>

      <div className="overflow-hidden rounded-lg border border-border">
        {BANDS.map((band) => {
          const ratio = contrast(band.label, band.surface);
          return (
            <div
              key={band.name}
              className="flex items-center justify-between px-4 py-5"
              style={{ background: css(band.surface), color: css(band.label) }}
            >
              <span className="text-sm font-medium">
                Last synced 4 minutes ago
                <code className="ml-2 text-xs opacity-90">{band.token}</code>
              </span>
              <span className="rounded bg-black px-2 py-0.5 text-xs font-semibold text-white">
                {ratio.toFixed(2)}:1 · AA pass
              </span>
            </div>
          );
        })}
      </div>

      <p className="text-xs text-success">
        Each token is a fixed color, so its contrast is a number you can test in CI and it cannot drift
        when the component is reused. Alpha still earns its place in focus rings and hover/pressed states,
        where the effect is transient and never carries text.
      </p>
    </div>
  );
}
```

## References

- [impeccable.style](https://impeccable.style/)
- [WCAG 2.1: Contrast (Minimum)](https://www.w3.org/TR/WCAG21/#contrast-minimum)
