Monsoon v0.2.0
Made by Alexandre Kohler Star

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.

3 min readBy Alexandre Kohler

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.

monsoon.css
--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:

ClassWhat it does
sr-only, not-sr-onlyHide content visually but keep it for screen readers, and undo it.
skip-linkA component class (see Skip link): visually hidden until it receives focus, for "Skip to content" links.
focus-ringA 2px outline in the ring color, only on keyboard focus (:focus-visible).
touch-targetMinimum 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, -noneControl 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.

a11y.html
<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-400 on white is not readable; check combinations you build from the palette yourself.
  • Opacity modifiers lower contrast. text-foreground/50 or text on bg-primary/10 should 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.

index.html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss">

No recent searches

Try , , or .

Recent

Loading the search index

No results for ""

Check the spelling, or search a class name such as px-4.