Skip to content

Color & Contrast

Contrast is part of the token structure, not an afterthought. Default text on --af-color-background and --af-color-surface-primary meets WCAG 2.2 AA in light, dark, and high-contrast. Overrides, color-mix(), opacity, and light intent fills are where ratios slip.

Use the contrast checker before shipping a custom palette.

SurfaceMinimum ratio
Normal text (under 18pt / 24px, or under 14pt / 19px bold)4.5:1
Large text (18pt / 24px+, or 14pt / 19px bold)3:1
UI components and graphical objects (icons, borders, focus)3:1 against adjacent colors

--af-color-text meets 4.5:1 on --af-color-background. --af-color-text-muted also meets 4.5:1 in the default palette — after an override, confirm it still clears 4.5:1 before using it for small body text. 3:1 is only enough for large text and UI.

Prefer semantic tokens. They recompute in light, dark, and high-contrast themes. Text and muted on background / surface-primary are the pairs that already clear AA.

On background

—af-color-text

—af-color-text-muted

4.5:1 body and muted (defaults)

On primary

—af-color-on-primary

Use on-intent tokens, not raw white

--af-color-text
--af-color-text-muted
--af-color-background
--af-color-surface-primary
--af-color-primary
--af-color-on-primary
--af-focus-ring-color

Intent fills (--af-color-success, --af-color-warning, --af-color-info, and the rest) are for buttons, badges, and icons. Do not use them as small text on a matching soft tint — use *-hard on *-soft instead. Light fills are the pairs to re-check: default --af-color-on-info is white on a sky fill (below 4.5:1), and dark-mode primary is close to the floor with white.

On trackAt riskBlockedInfo

Change stored primitives (--af-base-*--light and companions) for a global brand swap. Change semantic tokens (--af-color-*) when light, dark, or high-contrast need different values. Unsuffixed --af-base-primary and --af-color-on-primary are the resolved values — dark and high-contrast selectors overwrite them. See Theming.

Safe: override the stored suffix, then confirm the matching on-* token still clears 4.5:1 on that fill in every scheme.

:root {
  --af-base-primary--light: #0876dd;
  --af-color-on-primary--light: #ffffff;
}

Unsafe: a light gray that looks muted and fails 4.5:1 on white.

:root {
  --af-color-text: #cccccc;
}

After any override, re-check:

  1. Text on --af-color-background and --af-color-surface-primary
  2. --af-color-on-primary (and other on-* tokens) on their fills
  3. Hover, active, and disabled states
  4. Light, dark, high-contrast light, and high-contrast dark

Airframe derives hover, soft, and hard colors with color-mix(). Hover and active mix toward black (via --af-hover-mix / --af-active-mix). Hard text mixes toward --af-hard-toward, which defaults to --af-color-text. Mixing toward transparent lowers contrast.

Use the derived hover token for custom surfaces:

.promo:hover {
  background: var(--af-color-primary-hover);
}

Filled .af-btn hover uses filter: brightness(), not --af-color-primary-hover. In dark mode brightness increases, which can drop white labels below 4.5:1 — check the live hover, not only the mix token.

These two fail as patterns: a 40% wash of on-primary often misses 3:1 even on large display type, and stacking opacity on text can take a passing token below AA.

.hero-trail {
  color: color-mix(in srgb, var(--af-color-on-primary) 40%, transparent);
}

.muted-trail {
  color: var(--af-color-text-muted);
  opacity: 0.7;
}

Decorative chrome (grid lines, window dots) can sit below text ratios. Heading trails, eyebrows, captions, and button labels cannot.

Tune mix knobs instead of inventing a second palette:

:root {
  --af-hover-mix: 12%;
  --af-active-mix: 18%;
  --af-soft-mix: 12%;
  --af-hard-mix: 45%;
  --af-hard-toward: var(--af-color-text);
}

Keep --af-hard-mix high enough that *-hard text on *-soft surfaces stays at 4.5:1. Details are in Tokens.

High-contrast light and dark ship with the core CSS and follow prefers-contrast. High contrast is not a data-theme value. Simulate prefers-contrast: more in DevTools, or enable high contrast in the OS. If dark mode and high contrast are both on, high-contrast dark tokens apply.

Override with --hc-light and --hc-dark suffixes. Muted text in high contrast is still a contrast token, not a decoration:

:root {
  --af-color-text--hc-light: #000000;
  --af-color-text-muted--hc-light: #333333;
  --af-color-action--hc-light: #0066cc;
  --af-color-on-action--hc-light: #ffffff;

  --af-color-text--hc-dark: #ffffff;
  --af-color-text-muted--hc-dark: #cccccc;
  --af-color-action--hc-dark: #60a5fa;
  --af-color-on-action--hc-dark: #000000;
}

Full suffix tables live in Theming.

Errors, required fields, and status need text or an accessible name as well as color. A red border alone is not enough:

<input style="border-color: red;" />

Use invalid state, a linked message, and af-field__error:

<div class="af-field">
  <label class="af-field__label" for="email">Email</label>
  <input
  id="email"
  class="af-input"
  type="email"
  aria-invalid="true"
  aria-describedby="email-error"
/>
  <div class="af-field__error" id="email-error">Enter a valid email.</div>
</div>

Required fields: use the required attribute and visible text such as (required), not a red asterisk alone. Pattern details are in Form accessibility.

Airframe applies a visible :focus-visible ring. Default --af-focus-ring-color tracks primary and is meant to meet 3:1 against the page background. Do not replace it with a low-alpha outline.

:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: var(--af-focus-ring-width) solid var(--af-focus-ring-color);
  outline-offset: var(--af-focus-ring-offset);
}

A low-alpha outline will not meet 3:1:

:focus-visible {
  outline: 1px solid rgb(0 0 0 / 0.2);
}

When a control sits on a primary fill (hero, filled toolbar), invert the ring so it stays visible against that fill: --af-focus-ring-color: var(--af-color-on-primary). See Keyboard & focus.

  1. Open the contrast checker with your foreground and background hex values.
  2. Confirm 4.5:1 for normal text and 3:1 for large text and UI.
  3. Repeat for hover, focus, and each theme you ship.

Browser tools: Chrome’s color picker in the Styles panel shows a ratio; Firefox shows it in the Computed color picker.

Automated: axe DevTools, Lighthouse, and WAVE catch many failures. They will not catch a custom color-mix(..., transparent) on a marketing heading unless that text is in the DOM at audit time — check those by hand.