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

LevelMinimum ratioUse case
AA4.5:1Normal text (under ~18px or non-bold)
AA Large3.0:1Large text (18px+ regular or 14px+ bold)
AAA7.0:1Enhanced — strictest requirement
fail< 3.0:1Fails 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 (L decreases)
  • If the foreground needs more contrast against a dark background → lighten (L increases)
  • 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

LevelLc valueUse for
Lc75≥ 75Body text, anything read for extended periods
Lc60≥ 60Large headings, UI button labels, data values
Lc45≥ 45Non-text elements: icons, borders, placeholder text
fail< 45Does 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.xAPCA
Standard statusCurrent legal standardProposed for WCAG 3.0
Best forLegal compliance, auditsPerceptual accuracy, design decisions
MetricContrast ratio (1:1 – 21:1)Lc value (0 – ~108)
DirectionSymmetricAsymmetric
Body text threshold4.5:1Lc 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 muted text on a surface background, that pairing is not in the report. Measure it yourself with theme.light.accessibility.textOnSurface as 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.