Accessibility
What every Camaleo component guarantees in every skin, how it is tested, and the measured contrast of each skin's colors.
// you'll learn
- What you get from every component without extra work
- How the library is tested in all 13 skins, in light and dark, with five hard brand colors
- The measured contrast of each skin's text and control colors
- Where an authentic look and accessibility disagree, and which one wins
The target is WCAG 2.2 AA in every skin, in light and dark, with any brand color. A skin can change shape, depth, type and motion; it can't take away a name, a key or a focus ring.
What every component guarantees
- Keyboard. Each component follows a WAI-ARIA Authoring Practices pattern and gets its keyboard model from the primitive under it (Base UI, or React Aria for dates). Arrows move inside a widget, Tab moves between widgets, Escape closes popups and returns focus to the trigger.
- Focus. Focus is drawn with
outline, never onlybox-shadow, so it survives forced colors. Its color,--cml-focus-ring, is tested at 3:1 or more against the surface in every skin. - Names. Built-in buttons and regions have names (close, clear, show password, page 3…) that you can translate with
MessagesProvider. Icon-only controls you build still need anaria-label. - Targets. Checkboxes, radios, links in breadcrumbs and other small controls keep a pointer target of at least 24 × 24 px, even in dense skins.
- Reduced motion. It reduces, it doesn't remove: under
prefers-reduced-motion, distances and scales go to zero, short fades stay, panels open at once, the skeleton shimmer stops and an indeterminate progress bar pulses in place. - Forced colors. Components keep a transparent border that becomes visible in Windows high contrast, and selected states switch to system colors (
Highlight,CanvasText). - Reduced transparency.
glassandcupertinoswap their translucent surfaces for opaque ones underprefers-reduced-transparency: reduce, and in browsers withoutbackdrop-filter. - Right to left. Components flip with
DirectionProvider; see Languages & RTL.
How it is tested
The tests live in packages/react/test and run in a real browser (Vitest browser mode with Playwright).
- The matrix (
matrix.test.tsx) renders a showcase of every component in 13 skins × light and dark × 5 hard brand colors (#2563eb,#facc15,#22d3ee,#ff7f50and#808080): 130 scopes. In each one it runs axe with the WCAG 2.0, 2.1 and 2.2 A/AA rules, checks that the focus ring and control borders reach 3:1, that the danger, success, warning and info tones reach 4.5:1, and then opens 17 popups (dialog, menu, select, date picker, toast…) one by one to check that each keeps the skin through its portal and passes axe too. That is 2,340 runs. - Text over images. axe can't measure text over gradients and glass, and reports it as "incomplete". The matrix measures those nodes on real pixels instead (
pixel-contrast.ts): two screenshots, with and without the text, and the worst glyph pixel against the background behind it. - Keyboard (
keyboard.test.tsx): 30 tests that drive the components with real key presses, from Space on a checkbox to PageUp in a calendar. - The docs. Every page of this site, with its live examples, is checked with axe in 4 skins, light and dark, at desktop, tablet and phone widths, in both languages.
Contrast report
The table below is measured, not typed: apps/docs/scripts/contrast-report.mjs loads tokens.css and each skin's CSS in a browser, resolves the tokens with each of the five matrix brand colors, and keeps the lowest ratio of the five. Numbers are rounded down, so 4.499 shows as 4.49.
What it measures, all against the skin's page color --cml-surface unless noted:
- Text, 4.5:1 or more:
--cml-fg(body text),--cml-fg-secondary,--cml-fg-muted,--cml-accent-text(brand-colored text and links),--cml-accent-fgon--cml-accent(text on a solid brand button), and the--cml-danger,--cml-successand--cml-warningtones. - Non-text, 3:1 or more:
--cml-border-control(the edge of fields and choices) and--cml-focus-ring.
It doesn't measure text on raised surfaces, hover states or glass panels over the backdrop; the matrix covers those on rendered components.
| Skin | Mode | fg | fg-secondary | fg-muted | accent-text | danger | success | warning |
|---|---|---|---|---|---|---|---|---|
| minimal | light | 19.89 | 7.72 | 4.83 | 6.93 | 6.57 | 5.01 | 6.85 |
| dark | 19.06 | 13.46 | 7.76 | 9.94 | 7.14 | 11.41 | 12.99 | |
| neumorphism | light | 10.04 | 5.15 | 5.15 | 5.75 | 6.25 | 5.79 | 4.91 |
| dark | 11.20 | 7.67 | 6.12 | 6.90 | 7.27 | 8.98 | 8.27 | |
| retro-pixel | light | 17.09 | 8.36 | 8.36 | 6.63 | 6.47 | 6.22 | 6.10 |
| dark | 16.16 | 10.34 | 10.34 | 9.20 | 5.92 | 9.59 | 12.71 | |
| editorial | light | 16.31 | 8.10 | 6.41 | 6.21 | 6.76 | 5.72 | 5.66 |
| dark | 14.81 | 10.20 | 7.99 | 9.18 | 7.57 | 9.21 | 9.34 | |
| neubrutalism | light | 17.35 | 10.72 | 6.58 | 6.37 | 6.04 | 4.61 | 6.29 |
| dark | 16.20 | 11.63 | 7.41 | 9.28 | 6.67 | 10.66 | 12.13 | |
| retro | light | 11.54 | 8.74 | 5.35 | 5.39 | 5.28 | 4.52 | 4.70 |
| dark | 9.85 | 8.19 | 6.32 | 5.51 | 5.44 | 6.51 | 7.70 | |
| glass | light | 15.78 | 9.15 | 7.65 | 6.13 | 6.61 | 6.30 | 6.05 |
| dark | 17.42 | 12.85 | 8.62 | 9.53 | 8.55 | 10.95 | 12.46 | |
| swiss | light | 21.00 | 15.13 | 6.68 | 6.93 | 6.57 | 5.01 | 6.85 |
| dark | 21.00 | 15.31 | 8.32 | 10.49 | 7.53 | 12.05 | 13.71 | |
| progressive | light | 15.62 | 8.46 | 5.20 | 6.19 | 5.87 | 5.50 | 6.11 |
| dark | 15.66 | 11.11 | 6.80 | 8.66 | 6.22 | 9.95 | 11.32 | |
| clay | light | 12.94 | 8.80 | 6.16 | 6.49 | 6.15 | 6.67 | 6.41 |
| dark | 15.16 | 11.46 | 7.11 | 8.57 | 6.88 | 10.89 | 11.51 | |
| material | light | 16.14 | 8.82 | 6.06 | 6.59 | 6.12 | 6.19 | 6.12 |
| dark | 14.45 | 10.86 | 8.08 | 9.29 | 10.90 | 10.63 | 10.86 | |
| cupertino | light | 18.81 | 9.80 | 5.36 | 6.21 | 4.82 | 5.37 | 6.14 |
| dark | 21.00 | 13.80 | 9.49 | 10.49 | 8.61 | 10.38 | 14.87 | |
| y2k | light | 14.65 | 9.05 | 5.95 | 5.63 | 5.34 | 5.79 | 5.56 |
| dark | 16.71 | 11.53 | 8.15 | 9.33 | 6.70 | 10.72 | 12.20 |
| Skin | Mode | accent-fgon accent | border-control | focus-ring |
|---|---|---|---|---|
| minimal | light | 4.85 | 3.24 | 6.93 |
| dark | 7.45 | 4.11 | 9.94 | |
| neumorphism | light | 4.85 | 3.11 | 5.75 |
| dark | 7.45 | 3.71 | 6.90 | |
| retro-pixel | light | 4.85 | 17.09 | 6.63 |
| dark | 7.45 | 16.16 | 9.20 | |
| editorial | light | 16.31 | 16.31 | 6.21 |
| dark | 14.81 | 14.81 | 9.18 | |
| neubrutalism | light | 5.35 | 17.35 | 6.37 |
| dark | 5.35 | 16.20 | 9.28 | |
| retro | light | 9.80 | 10.88 | 5.39 |
| dark | 4.85 | 4.21 | 5.51 | |
| glass | light | 4.85 | 4.20 | 6.13 |
| dark | 7.45 | 5.88 | 9.53 | |
| swiss | light | 4.85 | 21.00 | 21.00 |
| dark | 7.45 | 21.00 | 21.00 | |
| progressive | light | 4.85 | 3.34 | 6.19 |
| dark | 5.35 | 4.63 | 8.66 | |
| clay | light | 4.85 | 3.66 | 6.49 |
| dark | 7.45 | 4.78 | 8.57 | |
| material | light | 4.85 | 4.25 | 6.59 |
| dark | 10.49 | 5.83 | 9.29 | |
| cupertino | light | 4.85 | 3.72 | 4.35 |
| dark | 4.85 | 6.44 | 5.35 | |
| y2k | light | 4.85 | 3.58 | 5.63 |
| dark | 5.35 | 5.02 | 9.33 |
Every pair meets its target in every skin and mode. Run node scripts/contrast-report.mjs in apps/docs after changing a skin to refresh the numbers.
Authentic or accessible
Some styles fail contrast by nature: neumorphism has no edges, glass puts text on whatever is behind it, a classic desktop OS draws a 1 px dotted focus box. The project decided (decision 9 in the plan) that every skin is accessible by default, and that an authentic variant would be opt-in and labelled "doesn't guarantee AA". No authentic variant ships yet.
| Skin | The authentic look | What Camaleo does by default |
|---|---|---|
neumorphism | Controls shaped by light and shade only, no edges | A 1 px border of 3:1 or more on inputs and choices; buttons are named by their text |
glass | Translucent panels over any background | Backdrop blobs kept light (dark: dark) enough that text holds 4.8:1 at their peak; opaque under reduced transparency |
clay | Soft pastel pieces with no outline | Fields and choices keep an edge of 3:1 or more |
cupertino | Frosted chrome on menus and popups | Dark glass at 94% opacity so text keeps 4.5:1; opaque fallback |
y2k | Glossy "jelly" buttons with text over the shine | Text sits only on the flat accent; the gloss stays above and below it |
retro | Dotted focus rectangle | Kept inside buttons, tabs and toggles (in the label color); fields get a solid 2 px ring outside the bevel |
A token or rule you change can break what the tests guarantee. After any color change, rerun the matrix (pnpm --filter @camaleo/react test) or at least this report.
Before you ship it
- Icon-only buttons you add have an
aria-label - Custom colors come from tokens, so they keep their tested pairs
- Animations you add respect
prefers-reduced-motion - New popups keep the skin (use
PortalScopefor your own portals) - The contrast report and the matrix still pass after a skin change