DEV Community

Design Den Solutions
Design Den Solutions

Posted on AI-assisted

Contrast Is a Pair, Not a Property

Orange, black and white 3D editorial illustration of a brand colour swatch board beside a table of measured contrast pairs, carrying the Design Den Solutions logo, the number +91 98721 90965 and www.designdensolutions.com

Every brand palette I have inherited from a handoff arrived as a list of single colours. A hex, a name, a swatch card, sometimes a note saying "primary". Then the first sprint ships an orange button with a white label, someone opens the site on a budget Android phone in daylight, and the report comes back as a rendering bug.

It is not a rendering bug. It is the predictable result of treating contrast as a property of a colour, when contrast only ever exists between two colours. #FF5A00 has no contrast value. It has a luminance. The number that decides whether anyone can read your interface is created at the moment you place one colour on top of another.

That reframing changes what you deliver at the end of a brand or design project. The useful artefact is not a palette board. It is a pairing table.

What the standard actually asks for

WCAG 2.2 sets the floor most teams are measured against, and the thresholds are short enough to memorise:

  • SC 1.4.3 Contrast (Minimum), Level AA — text and images of text need 4.5:1. Large text needs 3:1, where "large" means at least 18pt (~24px) regular, or at least 14pt (~18.5px) bold.
  • SC 1.4.6 Contrast (Enhanced), Level AAA — 7:1 for normal text, 4.5:1 for large text.
  • SC 1.4.11 Non-text Contrast, Level AA — 3:1 for the visual information needed to identify user interface components and their states, against the colours adjacent to them. Logotype-style marks and decorative graphics are excluded.
  • SC 1.4.1 Use of Colour — colour cannot be the only signal you use. A red border on an invalid field with no icon or message still fails.

Sources: Understanding Contrast (Minimum), Understanding Non-text Contrast, WCAG 2.2 recommendation.

Here is the worked example on our own brand colour, which is the argument in one line. Design Den Solutions uses an orange close to #FF5A00:

Foreground Background Ratio Body text AA Large text AA Non-text AA
#FF5A00 #FFFFFF 3.13 fail pass pass
#FFFFFF #FF5A00 3.13 fail pass pass
#0F0F0F #FF5A00 6.13 pass pass pass
#B34700 #FFFFFF 5.50 pass pass pass
#8A4000 #FFFFFF 7.46 pass pass pass
#0F0F0F #FFFFFF 19.17 pass pass pass

The same orange is simultaneously a legal heading colour on white, an illegal body-copy colour on white, and a fine component border on white. Three verdicts, one swatch. This is exactly why a swatch list cannot be an accessibility deliverable.

It also explains a very common UI decision that gets made by taste and argued about forever: an orange primary button with white text is the version that fails at body size. Orange with near-black text passes at 6.13:1. Nobody had to change the brand to fix it. They had to stop putting white on it.

Compute it, do not eyeball it

The maths is roughly twenty lines and belongs in your tooling rather than in someone's memory. Contrast comes from relative luminance, which needs the gamma-encoded sRGB channels linearised first:

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

const luminance = ({ r, g, b }) =>
  0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);

const toRgb = (hex) => {
  const h = hex.replace('#', '');
  const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
};

export function contrastRatio(foreground, background) {
  const a = luminance(toRgb(foreground));
  const b = luminance(toRgb(background));
  const [lighter, darker] = a > b ? [a, b] : [b, a];
  return (lighter + 0.05) / (darker + 0.05);
}

export function grade(foreground, background, { large = false } = {}) {
  const ratio = Number(contrastRatio(foreground, background).toFixed(2));
  return {
    ratio,
    AA: ratio >= (large ? 3 : 4.5),
    AAA: ratio >= (large ? 4.5 : 7),
  };
}
Enter fullscreen mode Exit fullscreen mode

Two implementation notes that cause more confusion than the maths:

  1. Always round the ratio last. 3.13:1 passes nothing at body size, but a tool that reports 3.1 and a reviewer who remembers "the large-text number is 3" will happily approve it.
  2. Alpha has to be flattened first. rgba(255, 255, 255, 0.7) over #FF5A00 is a different colour than either input. Composite, then measure. Any checker that silently ignores alpha will over-report passes on overlays.

The pairs nobody writes down

When you only audit "text colour on background colour", you pass review and still ship an unreadable product. The list below has been the source of most real defects I have had to unwind.

Disabled controls. WCAG exempts inactive components from 1.4.3, so a 50%-opacity grey label is technically legal. It is also the same visual as an enabled label that merely looks quiet. If a state can be mistaken for another state, the fix is a second signal (SC 1.4.1), not a bigger ratio.

Placeholder text. Placeholders get styled in the lightest grey in the system, which is usually the pair closest to the 4.5 boundary. A #767676 placeholder on white measures 4.54:1 and clears AA by the width of a rounding error; the "helpful" #999999 most frameworks default to does not. Placeholders are not labels either, so treat the placeholder pair as decorative and give the field a real visible label.

Focus indicators. A 2px brand-orange ring on white is 3.13:1 — it clears the 3:1 non-text bar, but only just, and any opacity, box-shadow blur or overlapping element pushes it below. Check the ring against the surface it actually sits on, which is often the input's own background rather than the page.

Icon strokes and hairline rules. Non-text contrast applies to the graphical object you need in order to understand the control. A 1px #CCCCCC divider between two form sections is invisible on a low-quality panel, and invisible is worse than ugly.

Hover and active. Contrast is about the state being legible; users also need to see that the state changed. Verify the before/after pair against each other, not only against the page.

Dark mode. Inverting a light palette is not a dark theme. A saturated brand hue that carries text on white will visually vibrate against near-black, and it usually loses to a desaturated, lightened step of the same hue. This is why the scale matters more than the hero swatch.

Turn one hue into a scale that survives

Instead of approving three colours, approve a ladder of steps for each hue and record which steps are text-safe on each surface. Starting from the same orange:

Token Hex On white Verdict for body text
brand-500 #FF5A00 3.13 display, headings, large labels only
brand-600 #CC5200 4.40 still fails, do not use for copy
brand-700 #B34700 5.50 AA text on white
brand-800 #8A4000 7.46 AAA text on white

Encode the intent so the next developer does not have to remember the table:

:root {
  color-scheme: light dark;
  --brand-500: #ff5a00; /* graphics, headings >= 24px, component fills */
  --brand-700: #b34700; /* AA body text on light surfaces */
  --brand-800: #8a4000; /* AAA body text on light surfaces */
  --ink-900: #0f0f0f;
  --surface-0: #ffffff;
}

.btn-primary {
  background: var(--brand-500);
  color: var(--ink-900);      /* 6.13:1 */
  border: 2px solid var(--brand-700); /* state edge vs white: 5.50:1 */
}

.btn-link {
  color: var(--brand-700);    /* 5.50:1 — readable inline link */
  text-decoration: underline; /* SC 1.4.1: colour is not the only cue */
}
Enter fullscreen mode Exit fullscreen mode

Then assert it in CI rather than in a code review comment. A tiny test that walks an allowed-pairs list fails the build when someone edits a token:

const ALLOWED = [
  ['--ink-900', '--surface-0', 4.5],
  ['--brand-700', '--surface-0', 4.5],
  ['--brand-800', '--surface-0', 7],
];
Enter fullscreen mode Exit fullscreen mode

One honest caveat about the platform: in forced-colors mode the browser replaces your palette with the user's system colours on purpose. Do not build anything whose meaning depends on your specific orange surviving. Test with forced colors enabled and confirm the layout and labels still make sense.

Text over photography has no fixed answer

A hero image is the case where a pairing table can go wrong, because the background is a range. Two workable approaches:

  • Put a scrim behind the copy — a solid panel, or a gradient whose darkest stop is the value you actually measured. Measure the ratio against the composited result, not the hex in your stylesheet.
  • Sample the image's average luminance at render time and switch between a light and dark text token. This is the approach worth automating on user-generated imagery, where you cannot control the source.

What does not work is a fixed #FFFFFF label with a text-shadow and a shrug. Shadows are not contrast guarantees, and they are the first thing to disappear when a thumbnail is rendered small and compressed.

Ship the pairing table with the files

A brand handoff that contains only swatches pushes every downstream decision onto people who were not in the room. Add one page to the handoff: every approved foreground/background pair, its measured ratio, the minimum size it is valid at, the components it covers and a review date. When a new surface appears, the question stops being "does this orange feel right here?" and becomes "is this row in the table?"

If you want the accessibility checks to be part of the design work rather than an audit after it, this is the kind of readable, documented front end we build — design tokens specified against the standard, tested in CI, and handed over with the pairing table so it survives the next redesign: website development work at Design Den Solutions.


Disclosure: this article was prepared with AI assistance and reviewed for technical accuracy by Design Den Solutions. The cover image is a conceptual AI-generated illustration with the official company logo composited unchanged. All contrast ratios above were calculated with the WCAG 2.2 relative-luminance formula quoted in this article, and are reproducible from the code sample.

Top comments (0)