A framework-agnostic colorblind-safe palette switcher. Drop a floating button on your site; visitors pick a color-vision-deficiency type and your whole palette is swapped for one whose colors stay distinguishable for them.
- 🎨 7 deficiency types, each with its own palette.
- 🌗 Light and dark palettes, following
prefers-color-schemeor your own toggle. - ♿ Keyboard and screen-reader operable, and respects
prefers-reduced-motion. - ⚛️ React adapter, safe in SSR and the Next.js App Router.
- 📦 Zero dependencies, typed, ESM and CJS.
npm install chromafix-a11yYour site's colors already live in CSS variables. Tell chromafix which variable plays which role:
import { createChromafix } from "chromafix-a11y";
createChromafix({
tokens: {
"--brand-bg": "background",
"--brand-text": "text",
"--brand-accent": "primary",
},
});That's the whole setup. A floating button appears; pick Deuteranopia and
--brand-accent becomes a distinguishable amber, --brand-text a safe dark, and
so on across the site at once. The choice is remembered in localStorage.
import { ColorblindWidget } from "chromafix-a11y/react";
export default function Layout({ children }) {
return (
<>
{children}
<ColorblindWidget
tokens={{
"--brand-bg": "background",
"--brand-text": "text",
"--brand-accent": "primary",
}}
/>
</>
);
}The component is client-only, so it's safe to render from a Server Component.
The core is plain DOM with no dependencies, so anything that can run code on
mount works. Only React gets a dedicated adapter; everywhere else you call
createChromafix() yourself and destroy() on teardown.
| Framework | Integration |
|---|---|
| React, Next.js | <ColorblindWidget /> from chromafix-a11y/react |
| Vue, Svelte, Solid, Angular 20+ | createChromafix() in your mount hook |
| Vanilla, CDN | createChromafix() once the page has loaded |
// Vue
onMounted(() => {
chromafix = createChromafix({ tokens });
});
onUnmounted(() => chromafix.destroy());Angular has one extra consideration, covered in Bring your own UI: render the controls in your own template so clicks stay inside change detection.
Importing chromafix-a11y/react needs a resolver that reads exports, which
means Angular 20+, or any current Vite, webpack, Next.js or Rollup setup. The
core entry has no such requirement.
chromafix doesn't filter pixels. You map each of your CSS variables to one of six
semantic roles, and on selection it writes the matching color for each role
straight onto the target element's inline style, which always wins over
stylesheet rules. No DOM walking, no repaint filter, no !important.
| Role | Meaning |
|---|---|
background |
Page background |
surface |
Cards, panels, raised areas |
border |
Dividers, outlines |
text |
Primary foreground text |
muted |
Secondary / de-emphasized text |
primary |
Accent: links, buttons, brand |
The seven supported types (accents drawn from the Okabe-Ito safe set):
protanopia · protanomaly · deuteranopia · deuteranomaly · tritanopia · tritanomaly · achromatopsia
| Option | Type | Default | Description |
|---|---|---|---|
tokens |
Record<string, Role> |
{} |
Your CSS variables to palette roles. Required to recolor. |
target |
string | HTMLElement |
document.documentElement |
Element the variables are set on (:root). |
position |
"bottom-right" | "bottom-left" | "top-right" | "top-left" |
"bottom-right" |
Corner for the floating button. |
theme |
"auto" | "light" | "dark" |
"auto" |
Widget skin. auto contrasts the page background. |
scheme |
"auto" | "light" | "dark" |
"auto" |
Palette set. See Dark mode. |
defaultType |
"off" | CvdType |
"off" |
Selection used when nothing is stored yet. |
storageKey |
string |
"chromafix:type" |
localStorage key for the remembered choice. |
labels |
Partial<ChromafixLabels> |
English | Override any visible string. See Labels and i18n. |
nonce |
string |
none | Nonce for the injected stylesheet. See CSP. |
headless |
boolean |
false |
No UI at all. See Bring your own UI. |
onChange |
(type) => void |
none | Called whenever the selection changes. |
createChromafix() returns { setType, getType, setScheme, getScheme, toggleOpen, destroy }.
Every type has a light and a dark palette. On "auto" the library follows the
OS prefers-color-scheme and keeps tracking it, so an active palette flips
with the system. If your site has its own theme switch, drive it directly:
const chromafix = createChromafix({ tokens });
darkToggle.addEventListener("click", () => {
chromafix.setScheme(isDark ? "dark" : "light");
});
chromafix.getScheme(); // "light" | "dark", with "auto" already resolvedThe palette is written inline, so it beats your stylesheet. Without setScheme,
a site theme toggle does nothing while a type is active.
Every visible string is overridable. Each option label is a plain string, or an
{ name, hint } object that adds a dimmed subtitle. Use the subtitle for the
clinical term, so someone who doesn't know their exact type can still choose:
createChromafix({
tokens,
labels: {
button: "Accessible colours",
off: "Off",
deuteranopia: { name: "Red / green (reduced green)", hint: "Deuteranopia" },
tritanopia: { name: "Blue / yellow", hint: "Tritanopia" },
// …
},
});The widget injects one <style> element. Under a style-src policy without
'unsafe-inline' the browser blocks it and the widget mounts unstyled: the
button and radios are all there, they have simply lost their layout.
If that's your setup, pass the nonce for the current response:
createChromafix({ tokens, nonce: requestNonce });The nonce comes from wherever your server builds the CSP header. In Next.js that's middleware:
// middleware.ts
export function middleware(request: NextRequest) {
const nonce = crypto.randomUUID();
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set(
"Content-Security-Policy",
`style-src 'self' 'nonce-${nonce}'`,
);
return response;
}// app/layout.tsx
const nonce = (await headers()).get("x-nonce");
return <ColorblindWidget tokens={tokens} nonce={nonce} />;Only the widget's stylesheet needs this. Palettes are written with
style.setProperty, which CSP does not police, so applyPalette and
headless: true work under any policy.
Set headless: true and chromafix renders nothing at all: no button, no panel,
no injected stylesheet. It keeps the engine, localStorage persistence and
scheme tracking, and you drive it from your own controls. Useful when you have a
design system to match, or a strict style-src CSP that forbids our stylesheet.
import { createChromafix, CVD_TYPES, DEFAULT_LABELS } from "chromafix-a11y";
const chromafix = createChromafix({ headless: true, tokens });
for (const type of ["off", ...CVD_TYPES]) {
myRadioGroup.add(DEFAULT_LABELS[type], () => chromafix.setType(type));
}Angular, where the widget's listeners would otherwise fire outside change detection:
export class ThemeComponent implements OnInit, OnDestroy {
types = ["off", ...CVD_TYPES];
labels = DEFAULT_LABELS;
private cf!: ChromafixInstance;
ngOnInit() {
this.cf = createChromafix({ headless: true, tokens: this.tokens });
}
select(type: ChromafixType) {
this.cf.setType(type);
}
ngOnDestroy() {
this.cf.destroy();
}
}Because your own template renders the controls, every click already runs inside Angular's zone, and Vue, Svelte and Solid work the same way.
The widget's own controls:
- A real
<button>witharia-expanded/aria-controlsdisclosure. - Options are a native
<fieldset>radio group, so arrow-key navigation is free. - Escape closes the panel and returns focus to the button; outside-click dismisses.
- High-contrast focus-visible rings; all animation gated behind
prefers-reduced-motion.
Skip the UI and drive the palette yourself:
import {
applyPalette,
paletteFor,
PALETTES,
PALETTES_DARK,
CVD_TYPES,
ROLES,
} from "chromafix-a11y";
const tokens = { "--brand-accent": "primary" };
applyPalette("tritanopia", tokens); // apply
applyPalette("tritanopia", tokens, undefined, "dark"); // apply the dark set
applyPalette("off", tokens); // clear
PALETTES.deuteranopia; // { background, surface, border, text, muted, primary }
PALETTES_DARK.deuteranopia; // the dark counterpart
paletteFor("deuteranopia", "dark");
CVD_TYPES; // readonly ["protanopia", …]
ROLES; // readonly ["background", …]Every DOM, window, and localStorage access is guarded and nothing runs at
import time, so importing or calling chromafix in a Node/SSR context is a no-op.
- Side-effect-free (
"sideEffects": false) and tree-shakeable. - Ships ESM and CJS, each with matching type definitions.
reactis an optional peer, so vanilla and CDN users install nothing extra.
Resolution is verified against moduleResolution: "bundler" and "node16" on
every build. Legacy "node" resolution is not supported, so the subpath export
needs Angular 20+, or any current Vite, webpack, Next.js or Rollup setup.
chromafix remaps colors defined through CSS variables. It cannot recolor
raster images, or colors hard-coded in stylesheets and style attributes.
Contributions are welcome. See
CONTRIBUTING.md. The page in
demo/ is what gets published to GitHub Pages; serve that folder to run it locally.
MIT © WORA-BEN