Advanced Color: Palettes, Dark Mode and Total Accessibility
Chapter objectives
- Extend the palette into coherent tint scales
- Build a dark mode that isn't a simple negative
- Guarantee accessibility beyond text: color blindness, non-text, states
When a palette meets the real world
Two pieces of feedback land the same morning at Studio Mango. The first comes from a Sereno beta tester: "I'm color-blind, and I can't tell the difference between your success and error messages." The second comes from the client: "My users meditate in the evening — we need a dark mode." Two different requests, one same lesson: the chapter 2 palette, perfect in ideal conditions, must now face the real diversity of eyes and usage contexts.
This chapter completes your color toolbox on four fronts: extending each color into a tint scale (because eight colors never suffice for long), reasoning in perceived lightness with OKLCH, building a dark mode worthy of the name, and pushing accessibility beyond text contrast — color blindness, non-text elements, semantic states. By the end, you'll no longer deliver a palette: you'll deliver a complete theme.
From colors to tint scales
Your --color-primary token is a single tint. But as soon as the interface grows, you need its neighbors: a very light version for a banner background, a dark version for hover, a near-black version for text on a light background. Rather than improvising these variants case by case, professional design systems define a scale of 9 to 11 shades per tint, numbered from 50 (the lightest) to 900 (the darkest).
Each number has a conventional role: 50-100 for subtle tinted backgrounds, 200-300 for borders and disabled states, 500 as the base color, 600-700 for hovers and active states, 800-900 for tinted text on a light background. This convention makes decisions fast and consistent: "the info banner uses sage-50 as background and sage-800 as text" is understood immediately, and the contrast is almost guaranteed by construction — the extremes of a well-built scale pass AA against each other.
:root {
/* Sage scale — generated at regular perceived lightness */
--sage-50: #F2F6F4; /* subtle tinted backgrounds */
--sage-100: #E1EAE6;
--sage-200: #C4D5CE; /* borders, dividers */
--sage-300: #A3BDB3;
--sage-400: #7E9D91;
--sage-500: #4A7C6F; /* base: buttons, links */
--sage-600: #3D685D; /* hover */
--sage-700: #32554C; /* active */
--sage-800: #27423B; /* tinted text on light background */
--sage-900: #1C302B;
}
/* Roles point to the scale — never the other way around */
:root {
--color-primary: var(--sage-500);
--color-primary-hover: var(--sage-600);
--color-primary-surface: var(--sage-50);
}Note the two-tier architecture of the code above: the raw scale (--sage-500) on one side, the functional roles (--color-primary) on the other, with roles pointing to the scale. This indirection is the key to the dark mode coming up below: you'll change what the roles point to, without touching either the scale or the components.
OKLCH: reasoning in perceived lightness
To generate these scales, the color format matters more than you'd think. Classic HSL has a known flaw: its "lightness" lies. A yellow and a blue at 50% HSL lightness don't have the same brightness perceived by the eye at all — the yellow looks dazzling, the blue dark. Consequence: a scale generated in HSL has irregular steps, and contrast ratios become unpredictable from one tint to another.
The modern OKLCH format fixes this: its L axis measures lightness as the eye perceives it. Two OKLCH colors with the same L genuinely look equally light, whatever the hue. For you, it's a practical superpower: ask for your scales "in OKLCH, with a regular lightness step", and all your tints (sage, peach, error red) will have aligned shades — the green's 600 and the red's 600 will offer the same contrast on a white background.
Extend my Sereno design system into complete tint scales: - for each base color (sage #4A7C6F, peach #E8A87C, error red #B5544D), generate a scale of 10 shades (50 to 900) in OKLCH with a regular perceived-lightness step - keep the hue and adjust mostly lightness and chroma: light shades slightly desaturated, dark ones slightly more saturated - give for each shade: the oklch() value, the hex fallback, and the contrast ratio on white AND on #1A1F1D - end with a table of guaranteed AA combinations: which text number on which background number Format: commented CSS :root block.
Dark mode is not a negative
First intuition to kill: dark mode is not the inversion of the light palette. Inverting produces a pure black background (#000) that makes white text vibrate, saturated colors that turn fluorescent, and invisible shadows. A good dark theme rests on four principles. One: a very dark tinted gray background (for Sereno, a deep gray-green like #141816), never pure black. Two: desaturated colors — on a dark background, the same saturation looks louder, so you bring the chroma down a notch.
Three: elevation through lightness. In light mode, a card stands out by its shadow; in dark mode, shadows no longer show — it's the surface itself that lightens slightly (a card is a bit lighter than the background, a modal a bit lighter still). Four: off-white text rather than pure white (#E8ECEA rather than #FFFFFF), to reduce vibration during long nighttime reading — precisely Sereno's usage context.
And here is where your two-tier architecture pays off: to create the dark theme, you touch neither the components nor the scale — you re-declare only the roles in a prefers-color-scheme: dark block. --color-surface now points to the deep gray, --color-primary to a lighter sage-400 (because on a dark background, it's the light version of the tint that contrasts). Thirty lines of CSS, and the whole interface switches over.
/* Dark mode: re-declare the roles, nothing else */
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #141816; /* deep gray-green, no pure black */
--color-surface-raised: #1E2421; /* elevation through lightness */
--color-text: #E8ECEA; /* off-white, no pure white */
--color-text-muted: #9DABA5; /* re-checked: 5.2:1 on surface */
--color-primary: var(--sage-400); /* light version of the tint */
--color-primary-hover: var(--sage-300);
--color-primary-surface: #20302B;
--shadow-soft: none; /* elevation replaces the shadow */
--shadow-raised: none;
}
}Generate Sereno's complete dark theme from my light tokens (below): [paste your :root block + your scales here] Rules: - re-declare ONLY the roles in @media (prefers-color-scheme: dark), without touching the scales or the components - deep gray-green background (no pure black), off-white text (no #FFF) - slightly desaturate the accents, use the scales' light shades for primary - replace shadows with elevation: surface-raised lighter than surface - give the contrast ratio of EVERY text/background combination in the dark theme - flag any combination under 4.5:1 and propose the fix
Color blindness: never encode information by color alone
Back to the beta tester. About 8% of men and 0.5% of women perceive certain colors poorly — most often the red/green distinction, exactly the pair Sereno uses for error/success. The accessibility rule is absolute: color must never be the only channel carrying a piece of information. A red error message must also be signaled by an icon, a text prefix ("Error:"), or a distinct shape. An invalid field must have a thicker border or an icon, not just a red border.
The verification reflex: ask the AI to simulate. "Describe this interface as a deuteranope person would see it; list every place where information is carried only by color." You can also do the mental grayscale test: if the whole page went black and white, would the states remain distinguishable? If yes, your design is robust; if no, you know what to double up with an icon or a label.
WCAG beyond text: the 3:1 of interface elements
Chapter 2 covered text (4.5:1). But the WCAG also impose a minimum of 3:1 for meaningful non-text elements: a form field's border (otherwise the field is invisible), an icon that acts as a button, the focus ring, a checkbox's checked state, a chart's strokes. It's the blind spot of almost every pastel palette — that elegant light gray border in your mockup probably measures 1.8:1, and a visually impaired user simply can't find where to click.
Also think of semantic states as complete couples: each status color (success, error, warning, information) exists in a text version, a background version and a border version — and each combination passes its thresholds in both themes. This is the moment your system leaves the "pretty palette" stage to become a true production theme.
flowchart TD P["Base palette: roles and tints"] --> E["Scales 50 to 900 in OKLCH"] E --> T1["Text ratios: 4.5 to 1 minimum"] E --> T2["Non-text ratios: 3 to 1 minimum"] T1 --> D["Color blindness simulation: info never by color alone"] T2 --> D D --> S["Dark theme: roles re-declared and re-tested"] S --> V["Validated theme: light + dark documented"]
Delivering a theme, not colors
Let's recap the final deliverable for Sereno's developer: the tint scales (the raw material), the functional roles in two declarations (light and dark), the complete semantic state couples, and a short usage document — which scale number for which use, which combinations are guaranteed, how to add a tint without breaking the system. Add respect for the user's choice: the theme follows prefers-color-scheme by default, but a manual switch must be able to force it, because meditating at night in forced light mode isn't a premium experience.
The color-blind beta tester will receive an interface where every state is doubled with an icon; the client will get a dark mode worthy of a nighttime meditation app. And you have learned the lesson that goes beyond color: a design system isn't judged in ideal conditions, but on the real diversity of eyes, screens and contexts it will meet.
Context
The client expects the dark mode for the next release, and the color-blind beta tester's feedback must be handled in the same pass. You have your chapter 2 tokens and half a day. The deliverable: a complete light + dark theme, verified beyond text, with its usage documentation for the developer.
Instructions
- Have the 10-shade scales (50-900) generated in OKLCH for your three main tints, with hex fallbacks and contrast ratios.
- Restructure your tokens into two tiers: raw scales on one side, functional roles on the other.
- Generate the dark theme by re-declaring only the roles: deep tinted background, desaturated accents, elevation through lightness.
- Demand the numerical ratios for every combination in both themes and have everything below 4.5:1 (text) or 3:1 (non-text) fixed.
- Run the color blindness audit: ask for the list of information carried only by color, and double each with an icon or a label.
- Test the full landing in both themes (including the form and its error states) and write the 10 lines of usage documentation.
In summary
- A base color becomes a scale of 9-11 shades (50-900), each number having a conventional role.
- The two-tier architecture — raw scales, functional roles pointing at them — makes themes interchangeable.
- OKLCH measures perceived lightness: regular scales and predictable contrasts from one tint to another.
- Dark mode is not a negative: deep tinted background, desaturated accents, elevation through lightness, off-white.
- All contrasts must be re-tested in dark — muted text is the classic trap.
- Information must never be carried by color alone: icon, label or shape as a second channel (8% of men are color-blind).
- The WCAG also impose 3:1 on non-text elements: field borders, active icons, focus, states.
Quiz — check your understanding
1. What is a tint scale (50 to 900) for?
2. What is OKLCH's advantage over HSL?
3. How do you build a good dark mode?
4. What should you do for a color-blind user who confuses error and success messages?
5. What minimum contrast ratio applies to meaningful non-text elements (field borders, active icons)?
6. Why re-test all contrasts after creating the dark theme?