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.
WCAG 2.2 AA
Section titled “WCAG 2.2 AA”| Surface | Minimum 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.
Semantic tokens to prefer
Section titled “Semantic tokens to prefer”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-colorIntent 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.
Override without breaking AA
Section titled “Override without breaking AA”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:
- Text on
--af-color-backgroundand--af-color-surface-primary --af-color-on-primary(and otheron-*tokens) on their fills- Hover, active, and disabled states
- Light, dark, high-contrast light, and high-contrast dark
color-mix() and opacity
Section titled “color-mix() and opacity”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 themes
Section titled “High-contrast themes”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.
Color is not the only signal
Section titled “Color is not the only signal”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.
Focus rings
Section titled “Focus rings”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.
How to check
Section titled “How to check”- Open the contrast checker with your foreground and background hex values.
- Confirm 4.5:1 for normal text and 3:1 for large text and UI.
- 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.
Resources
Section titled “Resources”- Contrast checker
- Theming — light, dark, and high-contrast overrides
- Tokens — primitives, semantic colors, mix knobs
- WCAG 2.2 contrast minimum
- TPGI Colour Contrast Analyser