Accessibility
Accessible color contrast in CSS, for any seed color
A theme generated from any color is only useful if the text stays readable. Monsoon builds the contrast guarantee into the formulas themselves and checks it with tests, so a user picked brand color cannot produce an unreadable button.
The target: WCAG AA
WCAG 2 asks for a contrast ratio of at least 4.5:1 between normal text and its background (level AA). Monsoon aims for that between every semantic fill and the foreground meant to sit on it: primary and primary-foreground, secondary, accent, the status colors, background with foreground, the three surfaces with foreground, and muted with muted-foreground.
Why a naive formula fails
A tempting approach is "use the seed as the fill and pick white or black text, whichever contrasts more". It fails for mid lightness colors. A saturated green or orange around OKLCH lightness 0.6 can sit below 4.5:1 against white and against black at the same time. No text color saves it.
Monsoon therefore changes the fill, not only the text. If the seed lightness is below 0.6, primary takes min(l, 0.52); otherwise it takes max(l, 0.66). The band between 0.52 and 0.66 is never used, and the foreground flips on the same threshold: white for the dark side, near black (lightness 0.17) for the light side.
--primary-foreground: oklch(from var(--primary)
clamp(0.17, (0.6 - l) * 100000, 1) min(c, 0.03) h);In dark mode the seed is first lifted to a lightness of at least 0.7, so text-primary on the dark background stays readable too.
How it is tested
The formulas live in one JavaScript module that writes the CSS, and the tests evaluate those exact expressions. The suite checks:
- 18 named seeds, including black, white, gray, pure red, pure green, pure blue, gold and a seed placed exactly on the threshold,
- both light and dark mode,
- 13 token pairs, with two methods of bringing out of gamut colors into sRGB,
- a sweep of 2,197 sRGB seeds (13 steps per channel) for primary, accent and body text,
- that primary lightness never lands inside the unsafe band.
Every one of those pairs must be at least 4.5:1. You can watch the same guarantee hold in the seed playground, which measures the colors your browser actually renders.
Accessibility helpers beyond color
Contrast is one part of accessible styling. Monsoon includes small helpers for the rest:
| Class | What it does |
|---|---|
sr-only, not-sr-only | Hide content visually but keep it for screen readers, and undo it. |
skip-link | A component class (see Skip link): visually hidden until it receives focus, for "Skip to content" links. |
focus-ring | A 2px outline in the ring color, only on keyboard focus (:focus-visible). |
touch-target | Minimum 44 by 44 pixels for small controls. |
motion-safe:, motion-reduce: | Apply transitions and animations only when the user has not asked for reduced motion. |
forced-color-adjust-auto, -none | Control how elements behave in Windows high contrast mode. |
The base reset also gives every focusable element a visible :focus-visible outline in the ring color, which follows your seed.
<a href="#main" class="skip-link fixed top-2 left-2 z-50 rounded-md bg-primary text-primary-foreground px-3 py-2">
Skip to content
</a>
<button class="touch-target focus-ring rounded-full hover:bg-muted" aria-label="Close">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor"/></svg>
</button>
<div class="motion-safe:animate-pulse rounded-lg bg-muted h-4"></div>What remains your responsibility
- Palette colors are fixed.
text-yellow-400on white is not readable; check combinations you build from the palette yourself. - Opacity modifiers lower contrast.
text-foreground/50or text onbg-primary/10should be checked case by case. - Large text has a lower requirement (3:1), but small text on tinted fills should still aim for 4.5:1.
- Color should never be the only signal. Pair status colors with a word or an icon.
A quick way to check your own combinations is the browser developer tools: inspect a text element and the color picker shows its contrast ratio against the background. Do it in both schemes, since a pair that passes in light mode can fail in dark mode when one side is a fixed palette color.
Frequently asked questions
Is the primary button text always readable?
Yes for any seed. Primary is moved out of the unsafe lightness band and primary-foreground flips between white and near black on the same threshold. Tests check 4.5:1 for 18 named seeds and 2,197 sweep seeds in both modes.
Does Monsoon check contrast at runtime?
No runtime code is involved. The guarantee comes from the formulas, which the browser evaluates as normal CSS.
How do I hide text visually but keep it for screen readers?
Use the sr-only class. Use skip-link for a skip navigation link that appears when focused.
Does Monsoon respect reduced motion?
Yes. Use motion-safe: and motion-reduce: on transitions and animations, and the reset turns off smooth scrolling for users who ask for reduced motion.
Try it in ten seconds
Paste this into any HTML page. No install, no build.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss">