The pain this chapter solves
You finish the UI, run a contrast checker, and discover six color pairs fail WCAG AA. You fix them by eye, break the visual harmony, and end up with a patchwork of overrides nobody understands.
Chapter 6
Accessibility Built-in
Why accessibility is a color problem
Most accessibility failures in production UIs come from one source: colors chosen without measuring contrast. Designers pick colors they find attractive. Developers implement them faithfully. Neither party runs a contrast check until QA or an audit catches failures weeks later.
The fix — “darken the text a bit” or “lighten the background slightly” — is applied by eye. It passes the checker but now the color is no longer in your system. It’s a one-off patch. The next color you add has the same problem.
salt-theme-gen solves this by measuring contrast at generation time, correcting failures before you ever see them, and giving you the report to verify.
The AccessibilityReport
Every generated theme mode includes an AccessibilityReport:
const { accessibility } = theme.light;
Each entry in the report has the same shape:
interface ContrastEntry {
ratio: number; // The actual contrast ratio (e.g. 5.2)
level: 'AAA' | 'AA' | 'fail';
}
Contrast ratio is calculated using the WCAG 2.1 relative luminance formula. The range is 1:1 (no contrast — same color) to 21:1 (maximum — black on white).
WCAG thresholds
| Level | Minimum ratio | Use case |
|---|---|---|
| AA | 4.5:1 | Normal text (under ~18px or non-bold) |
| AA Large | 3.0:1 | Large text (18px+ regular or 14px+ bold) |
| AAA | 7.0:1 | Enhanced — strictest requirement |
| fail | < 3.0:1 | Fails all standards |
The library targets AA as the minimum for all 25 checks. If a check would fall below 4.5, the relevant color is auto-corrected. AAA is reported when achieved but not required.
All 25 checks
Text legibility (3)
accessibility.textOnBackground
// Primary text color on page background
// Expected: AAA — should always exceed 7:1
accessibility.textOnSurface
// Primary text on card/input surface
// Expected: AAA — cards are text containers
accessibility.mutedOnBackground
// Muted/secondary text on page background (placeholders, captions)
// Expected: AA
Brand colors on surfaces (4)
accessibility.primaryOnBackground
// Primary color used as text or icon color on background
// Expected: AA
accessibility.secondaryOnBackground
// Secondary accent as text/icon on background
// Expected: AA
accessibility.tertiaryOnBackground
// Tertiary accent as text/icon on background
// Expected: AA
accessibility.quaternaryOnBackground
// Quaternary accent as text/icon on background
// Expected: AA
Foreground on intent — button text legibility (6)
accessibility.onPrimaryOnPrimary
// onPrimary text on primary background — your primary button text
// Expected: AA
accessibility.onSecondaryOnSecondary
// onSecondary text on secondary background
// Expected: AA
accessibility.onTertiaryOnTertiary
// onTertiary text on tertiary background
// Expected: AA
accessibility.onQuaternaryOnQuaternary
// onQuaternary text on quaternary background
// Expected: AA
accessibility.onBackgroundOnBackground
// onBackground text on page background
// Expected: AAA — same as textOnBackground but uses the explicit onBackground token
accessibility.onSurfaceOnSurface
// onSurface text on card surface
// Expected: AAA
These are the most important checks for interactive elements. A button with background: primary, color: onPrimary must pass here or the label is illegible. The library guarantees these pass by choosing each on* colors from near-white or near-black — whichever has higher contrast with the base color.
Intent colors on background (4)
accessibility.dangerOnBackground
// Danger color as text/icon on background (error messages)
// Expected: AA
accessibility.successOnBackground
// Success color as text/icon on background
// Expected: AA
accessibility.warningOnBackground
// Warning color as text/icon on background
// Note: amber hues are hardest to pass — auto-correction may darken significantly
accessibility.infoOnBackground
// Info color as text/icon on background
// Expected: AA
On-intent foregrounds — text on semantic backgrounds (4)
accessibility.onDangerOnDanger
// Text on danger-colored backgrounds (error banners, danger badges)
// Expected: AA
accessibility.onSuccessOnSuccess
// Text on success-colored backgrounds
// Expected: AA
accessibility.onWarningOnWarning
// Text on warning-colored backgrounds
// Expected: AA
accessibility.onInfoOnInfo
// Text on info-colored backgrounds
// Expected: AA
Text on elevation surfaces (4)
accessibility.textOnCard
// Primary text on surfaceElevation.card
// Expected: AAA — cards are reading surfaces
accessibility.textOnElevated
// Primary text on surfaceElevation.elevated (bottom sheets, floating panels)
// Expected: AAA
accessibility.textOnModal
// Primary text on surfaceElevation.modal
// Expected: AAA
accessibility.textOnPopover
// Primary text on surfaceElevation.popover (tooltips, dropdowns)
// Expected: AAA
Reading the full report
const report = theme.light.accessibility;
// Print all checks
for (const [check, result] of Object.entries(report)) {
const icon = result.level === 'fail' ? '✗' : result.level === 'AAA' ? '★' : '✓';
console.log(`${icon} ${check}: ${result.ratio.toFixed(1)} (${result.level})`);
}
Sample output for the Ocean preset (light mode):
★ textOnBackground: 18.4 (AAA)
★ textOnSurface: 20.1 (AAA)
✓ mutedOnBackground: 4.6 (AA)
✓ primaryOnBackground: 4.5 (AA)
✓ secondaryOnBackground: 4.5 (AA)
✓ tertiaryOnBackground: 5.0 (AA)
✓ quaternaryOnBackground: 4.5 (AA)
✓ dangerOnBackground: 4.9 (AA)
✓ successOnBackground: 4.5 (AA)
✓ warningOnBackground: 4.5 (AA)
✓ infoOnBackground: 4.5 (AA)
✓ onPrimaryOnPrimary: 4.9 (AA)
✓ onSecondaryOnSecondary: 5.0 (AA)
✓ onTertiaryOnTertiary: 5.5 (AA)
✓ onQuaternaryOnQuaternary: 4.9 (AA)
★ onBackgroundOnBackground: 18.4 (AAA)
★ onSurfaceOnSurface: 20.1 (AAA)
✓ onDangerOnDanger: 5.3 (AA)
✓ onSuccessOnSuccess: 4.9 (AA)
✓ onWarningOnWarning: 5.0 (AA)
✓ onInfoOnInfo: 4.9 (AA)
★ textOnCard: 17.9 (AAA)
★ textOnElevated: 17.2 (AAA)
★ textOnModal: 16.8 (AAA)
★ textOnPopover: 16.5 (AAA)
Auto-correction — how it works
When a generated color would fail its contrast check, the library adjusts it before returning the theme. The adjustment is always a lightness shift in OKLCH:
- If the foreground needs more contrast against a light background → darken (
Ldecreases) - If the foreground needs more contrast against a dark background → lighten (
Lincreases) - Chroma (
C) and hue (H) stay constant — the color identity is preserved
The adjustment is binary-searched in OKLCH space until the contrast ratio just clears 4.5. This produces the minimum possible shift — the corrected color is as close to the original as it can be while still passing.
A console.warn is emitted for each corrected color, identifying which check triggered it:
[salt-theme-gen] warningOnBackground was auto-corrected from oklch(0.78 0.16 85)
to oklch(0.62 0.16 85) to meet WCAG AA (was 2.8, now 4.6).
You can suppress these warnings by setting { silent: true } in the options:
generateTheme({ preset: 'honey', silent: true })
Dark mode accessibility
OKLCH makes dark mode contrast predictable. When the library generates dark mode colors, it applies lightness inversions that maintain the same perceptual contrast relationships:
// Light mode
theme.light.colors.text // L ≈ 0.12 (dark text)
theme.light.colors.background // L ≈ 0.98 (light background)
// → high contrast
// Dark mode
theme.dark.colors.text // L ≈ 0.95 (light text)
theme.dark.colors.background // L ≈ 0.12 (dark background)
// → same high contrast, inverted
The accessibility report for dark mode is generated separately and checked independently. Both modes pass AA.
This is why HSL-based dark mode is unreliable: HSL lightness is not perceptually uniform. A color that passes AA at hsl(L=50%) in light mode may not pass when you invert to hsl(L=50%) in dark mode because different hues have different perceived brightness at the same L value. OKLCH’s L channel is perceptually calibrated, so the math works.
Using the report in CI
You can generate a theme and assert on the accessibility report as part of your build or test suite:
import { generateTheme } from 'salt-theme-gen';
describe('theme accessibility', () => {
const theme = generateTheme({ primary: process.env.BRAND_COLOR ?? '#2563EB' });
const checks = Object.entries(theme.light.accessibility);
test.each(checks)('%s passes WCAG AA', (_, result) => {
expect(result.level).not.toBe('fail');
});
test.each(checks)('%s passes WCAG AA in dark mode', (_, _result) => {
const darkResult = theme.dark.accessibility[_ as keyof typeof theme.dark.accessibility];
expect(darkResult.level).not.toBe('fail');
});
});
This gives you a CI gate: if someone changes the brand color to one that fails contrast after auto-correction (which should not happen, but paranoia is good), the build fails with a clear error.
Building accessible components
The token system makes accessible component implementation mechanical — you do not have to think about contrast at the component level:
Accessible button
<button
style={{
backgroundColor: theme.light.colors.primary,
color: theme.light.colors.onPrimary, // pre-checked AA
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = theme.light.states.primary.hover}
onMouseLeave={e => e.currentTarget.style.backgroundColor = theme.light.colors.primary}
>
Save changes
</button>
Accessible error state
<p style={{
color: theme.light.colors.danger, // dangerOnBackground checked AA
backgroundColor: theme.light.colors.background,
}}>
Please fix the errors above.
</p>
Accessible disabled state
<button
disabled
style={{
backgroundColor: theme.light.states.primary.disabled,
color: theme.light.colors.onPrimary,
opacity: 1, // do not use opacity for disabled — use the token
cursor: 'not-allowed',
}}
>
Processing...
</button>
The disabled state is desaturated and lightened — it reads as visually inactive without becoming invisible. Avoid adding opacity: 0.5 on top of the disabled token; the token already communicates the state correctly.
APCA — Advanced Perceptual Contrast Algorithm
Every theme mode also includes an APCAReport alongside the WCAG report. APCA (APCA-W3 0.0.98G) is a more accurate perceptual contrast model — it is the algorithm proposed for WCAG 3.0.
Why APCA over WCAG 2.x?
WCAG 2.x contrast ratio is a good rule of thumb but has known accuracy problems:
- White text on mid-blue (e.g.
#1e40af) passes WCAG AA at 4.6:1 but is genuinely hard to read - Black text on yellow passes AAA at 19:1 and is also hard to read at small sizes
- WCAG treats dark-on-light and light-on-dark as equivalent — but they are not perceptually equal
APCA solves this with asymmetric exponents, a black soft-clamp (to handle very dark backgrounds), and separate thresholds for body text vs large text vs UI elements.
Reading the APCA report
const { apca } = theme.light;
// Each entry: { lc: number; level: 'Lc75' | 'Lc60' | 'Lc45' | 'fail' }
console.log(apca.textOnBackground);
// → { lc: 94.2, level: 'Lc75' }
console.log(apca.primaryOnBackground);
// → { lc: 61.4, level: 'Lc60' }
lc is the absolute Lc value. APCA is directional internally (dark text on light bg ≠ light text on dark bg), but the report always gives a positive Lc — the library resolves direction automatically.
APCA levels
| Level | Lc value | Use for |
|---|---|---|
| Lc75 | ≥ 75 | Body text, anything read for extended periods |
| Lc60 | ≥ 60 | Large headings, UI button labels, data values |
| Lc45 | ≥ 45 | Non-text elements: icons, borders, placeholder text |
| fail | < 45 | Does not meet any threshold |
Using apcaContrast directly
For custom color pairs not in the report, use the exported utility:
import { apcaContrast, meetsAPCA } from 'salt-theme-gen';
const lc = apcaContrast('#0077dc', '#f5f8ff'); // foreground, background
console.log(Math.abs(lc)); // e.g. 62.4
// Check against a minimum Lc threshold
const ok = meetsAPCA('#0077dc', '#f5f8ff', 60);
console.log(ok); // true if |lc| ≥ 60
WCAG vs APCA — which to use?
Use both. They measure different things and one does not replace the other:
| WCAG 2.x | APCA | |
|---|---|---|
| Standard status | Current legal standard | Proposed for WCAG 3.0 |
| Best for | Legal compliance, audits | Perceptual accuracy, design decisions |
| Metric | Contrast ratio (1:1 – 21:1) | Lc value (0 – ~108) |
| Direction | Symmetric | Asymmetric |
| Body text threshold | 4.5:1 | Lc 75 |
The salt-theme-gen token system guarantees WCAG AA at generation time (auto-corrects failures). The APCA report is informational — it tells you the perceptual contrast quality of each pair, but does not auto-correct based on APCA thresholds (WCAG is the compliance target).
What the report does not check
The 25 checks cover the color pairings that are universally meaningful. They do not cover:
- Your custom component combinations — if you put
mutedtext on asurfacebackground, that pairing is not in the report. Measure it yourself withtheme.light.accessibility.textOnSurfaceas a reference, or use a contrast tool with the OKLCH values. - Non-color accessibility — focus management, keyboard navigation, ARIA attributes, motion preferences. These are yours to implement.
- Minimum target sizes — WCAG 2.5.5 (44×44px touch targets) is not a color concern and is out of scope.
- Color as the only differentiator — using color alone to convey state (red = error, green = success) fails WCAG 1.4.1. Always pair intent color with an icon, label, or pattern.
The report is a color contract, not a full accessibility audit.
Auditing an existing brand color: Pass your brand hex to generateTheme({ primary: ‘#yourColor’ }) and read theme.light.accessibility. You’ll immediately see which pairings pass, which are auto-corrected, and by how much. It’s faster than any manual contrast tool for a full system audit.