A framework-agnostic UI color accessibility & design-system compatibility analyzer. It inspects rendered DOM/CSSOM state — via getComputedStyle, not source files — and reports insufficient contrast, out-of-palette colors, and where each color came from, independently of any UI framework.
| Package | Version | What it is |
|---|---|---|
@colorlint/core |
Framework-agnostic engine (zero deps) — see package README | |
@colorlint/react |
useColorAccessibility hook — see package README
|
|
@colorlint/angular |
ColorAccessibilityService — see package README
|
|
@colorlint/vue |
useColorAccessibility composable — see package README
|
Source: github.com/senthiljruby/colorlint
Design systems drift. A button's color might come from an inline style, a CSS Modules class, a styled-components rule, a design token, or plain inheritance — and by the time it reaches the browser, none of that provenance is visible in code review. Two questions matter and are usually conflated into one vague "accessibility issue":
- Is this color accessible? (WCAG contrast against its real effective background)
- Is this color allowed? (does it belong to the approved design-system palette?)
A color can fail either, both, or neither, independently — see "Accessibility vs. design system" in docs/ARCHITECTURE.md for why this library never merges the two into one verdict. This library answers both questions by inspecting what the browser actually renders, attributes each answer to a likely source (inline style / stylesheet rule / CSS variable / inheritance / pseudo-element), and produces ranked, actionable replacement suggestions — without ever touching the DOM or your CSS.
Framework UI (React / Angular / Vue / Svelte / Vanilla / Web Components)
│
▼
Integration Layer (thin, optional framework adapters)
│
▼
┌────────────────────────────────────────────────────┐
│ Accessibility Core │
│ DOM Inspector → Style Resolver → Color Parser │
│ → Contrast Analyzer + Palette Matcher → Rule Engine │
│ → Suggestion Engine → Reporters │
└────────────────────────────────────────────────────┘
@colorlint/core has zero runtime dependencies and no knowledge of React/Angular/Vue. Each adapter is a thin binding: it creates a core ColorAccessibilityAnalyzer, wires its lifecycle to the framework's (mount/unmount, refs, DI), and re-exports results. All analysis logic — color parsing, contrast math, background compositing, source attribution, palette matching, rule evaluation, suggestion ranking — lives in core and is unit-tested independently of any framework.
Modules inside core (packages/core/src/):
| Module | Responsibility |
|---|---|
color/ |
Parse every CSS color syntax (hex/rgb/hsl/hwb/lab/lch/oklab/oklch/named/transparent/currentColor/var()) into normalized RGBA; OKLab conversion for perceptual distance. |
contrast/ |
WCAG relative luminance + contrast ratio; configurable AA/AAA × normal/large thresholds. |
dom/ |
Computed-style caching, effective-background compositing (walks translucent ancestor layers), color-source attribution, selector generation, SVG paint properties, element-role classification. |
palette/ |
Perceptual (OKLab) nearest-color matching against an approved palette. |
rules/ |
Pluggable AccessibilityRule engine + built-in WCAG-contrast and palette rules. |
suggestions/ |
Ranked, read-only replacement-color suggestions and CSS fix snippets. |
reporters/ |
JSON / console / HTML report rendering from the same AnalysisResult. |
analyzer/ |
Orchestration: config normalization, traversal, caching, MutationObserver-driven incremental re-analysis, violation aggregation. |
See docs/ARCHITECTURE.md for the full design rationale and the limitations that were deliberately designed around rather than glossed over.
npm install @colorlint/core
# optional, only if you use the corresponding framework:
npm install @colorlint/react
npm install @colorlint/angular
npm install @colorlint/vueFramework adapters declare their framework as a peerDependency — installing @colorlint/core alone pulls in nothing else.
import { createColorAccessibilityAnalyzer } from "@colorlint/core";
const analyzer = createColorAccessibilityAnalyzer({
rules: {
wcag: { enabled: true, level: "AA" },
palette: {
enabled: true,
colors: [
{ name: "Text Primary", value: "#1D1D1F" },
{ name: "Text Secondary", value: "#6E6E73" },
],
},
},
});
const result = analyzer.analyze(document.body);
console.log(result.violations);
console.log(analyzer.generateReport({ format: "console" }));A runnable version lives in examples/vanilla — open index.html via any static file server (ES module <script type="module"> requires http(s)://, not file://).
import { useRef } from "react";
import { useColorAccessibility } from "@colorlint/react";
function Card() {
const ref = useRef<HTMLDivElement>(null);
const { violations } = useColorAccessibility(ref, {
rules: { wcag: { enabled: true, level: "AA" } },
observe: true, // keep re-analyzing on DOM mutation
});
return (
<div ref={ref}>
<p style={{ color: "#aaa" }}>Low-contrast text</p>
{violations.length > 0 && <p>{violations.length} color issue(s) found</p>}
</div>
);
}Full app: examples/react (npm run dev inside that folder).
import { Component, ElementRef, AfterViewInit, ViewChild } from "@angular/core";
import { ColorAccessibilityService } from "@colorlint/angular";
@Component({ selector: "app-card", template: `<div #card><p style="color:#aaa">Low-contrast text</p></div>` })
export class CardComponent implements AfterViewInit {
@ViewChild("card") card!: ElementRef<HTMLElement>;
constructor(private a11y: ColorAccessibilityService) {}
ngAfterViewInit() {
const result = this.a11y.analyze(this.card.nativeElement);
console.log(result.violations);
}
}ColorAccessibilityService is providedIn: 'root' — one shared analyzer per app by default. Call configure() once at startup for non-default rules/palette.
<script setup lang="ts">
import { ref } from "vue";
import { useColorAccessibility } from "@colorlint/vue";
const cardRef = ref<HTMLElement | null>(null);
const { violations } = useColorAccessibility(cardRef, { observe: true });
</script>
<template>
<div ref="cardRef">
<p style="color:#aaa">Low-contrast text</p>
</div>
<p v-if="violations.length">{{ violations.length }} color issue(s) found</p>
</template>createColorAccessibilityAnalyzer({
rules: {
wcag: { enabled: true, level: "AA", thresholds: { aaNormal: 4.5, aaLarge: 3, aaaNormal: 7, aaaLarge: 4.5 } },
palette: { enabled: true, colors: [...], nearMatchThreshold: 0.05 },
},
customRules: [myBrandRule()],
ignore: { selectors: [".third-party-widget", "[data-a11y-ignore]"], ruleIds: [], properties: [] },
include: { selectors: ["button", "a", "input", "[role='button']"] },
performance: { maxElements: 10000, maxDepth: 200, observerDebounceMs: 150 },
severityOverrides: { "wcag-contrast": "error", "palette-match": "warning" },
});ignore always wins over include. include.selectors (when set) restricts analysis to only matching elements.
Two built-ins, both independent, both optional:
-
wcag-contrast— flags any color-bearing property whose analysis produced a failingContrastResult(text uses WCAG text thresholds; borders/outlines/SVG paint use the WCAG 1.4.11 non-text 3:1 threshold). Severity:error. -
palette-match— flags colors that arenot-supported(severitywarning) and surfacesnear-matchcolors asinfoeven though they technically "pass", nudging toward the canonical token value. Anexact-matchproduces no finding at all.
Custom rules implement the same interface and run alongside the built-ins:
import type { AccessibilityRule } from "@colorlint/core";
const noBrightRed: AccessibilityRule = {
id: "no-bright-red",
evaluate(ctx) {
const hex = ctx.propertyAnalysis.foreground?.hex;
if (hex?.toLowerCase() === "#ff0000") {
return { passes: false, severity: "warning", message: `${ctx.selector} uses pure red, which is reserved for error states.` };
}
return null; // not applicable
},
};
createColorAccessibilityAnalyzer({ customRules: [noBrightRed] });A rule that throws is caught per-call and skipped — it cannot abort the rest of the analysis.
rules: {
palette: {
enabled: true,
colors: [
{ name: "Primary", value: "#007AFF" },
{ name: "Text Primary", value: "#1D1D1F", token: "--color-text-primary" },
"#6E6E73", // bare strings are accepted too
],
nearMatchThreshold: 0.05, // OKLab Euclidean distance
},
}Matching uses OKLab perceptual distance, not raw RGB delta — an equal RGB channel difference is not equally noticeable at every lightness level, so a naive RGB-distance palette matcher over- or under-flags depending on how dark or light the color is.
Read-only, ranked, never applied automatically:
const suggestions = analyzer.getSuggestions(violation);
// [{ color: "#6E6E73", score: 0.97, reason: "...", fromPalette: true, predictedContrastRatio: 5.07 }, ...]Ranking priority: approved palette membership → WCAG compliance → minimal visual change from the original color (an OKLab-space lightness search, not "just make it black"). For a concrete fix:
import { getFix } from "@colorlint/core";
const fix = getFix(violation, { strategy: "palette-first" }); // or accessibility-first / minimal-change / brand-first
// { property: "color", currentValue: "#777777", suggestedValue: "#6E6E73",
// location: "stylesheet", cssSnippet: { before: "color: #777777;", after: "color: #6E6E73;" } }location reflects where the fix should be applied (inline-style / stylesheet / css-variable / design-token), based on the violation's detected source.
analyzer.generateReport({ format: "object" }); // AnalysisResult (default)
analyzer.generateReport({ format: "json" }); // string
analyzer.generateReport({ format: "console" }); // prints + returns string
analyzer.generateReport({ format: "html" }); // self-contained HTML table, no external resourcesAnalysisResult also includes aggregatedFindings: violations sharing one root cause (a CSS variable, a stylesheet rule, or a duplicated inline literal) are rolled up so a design-system fix can target the cause once —
{ "kind": "css-variable", "description": "...", "affectedElementCount": 37,
"recommendation": "Fix the design token `--color-text-secondary` once rather than each affected component individually. (37 elements affected.)" }const handle = analyzer.observe(document.body, {
debounceMs: 150,
onViolation: (violation) => console.log(violation),
});
// later:
handle.disconnect();Mutations are batched within debounceMs, and only the smallest set of dirty subtrees is re-analyzed (never the whole document) — a highly dynamic SPA doesn't pay for a full-tree walk on every re-render. observe() never overwrites the result of your last analyze() call; it's a separate, additive stream of incremental findings.
- Computed styles are memoized per element (and per pseudo-element) via
WeakMapfor the lifetime of one analysis pass — no repeated forced-reflow-prone lookups. - Effective-background compositing is memoized per element and computed recursively, so shared ancestors are only composited once even across thousands of siblings.
-
performance.maxElements/maxDepthcap a single pass; exceeding the cap setssummary.truncated = truerather than hanging. -
ignore/includeselectors prune traversal, not just filter results afterward.
Targets evergreen browsers with getComputedStyle, MutationObserver, and ShadowRoot support (all current Chrome/Firefox/Safari/Edge). Explicit, honest limitations rather than silently-wrong results:
-
Transparent/gradient/image backgrounds: effective-background compositing walks ancestor
background-colorlayers only. Abackground-image(gradient or photo) in that chain is flagged via a note and lowers confidence — it is never guessed at. -
Pseudo-elements:
getComputedStyle(el, '::before'|'::after')is used per spec;::placeholderand other pseudo-elements are not attempted, since CSSOM support for reading them is inconsistent across browsers. -
Source attribution is best-effort: stylesheet-rule matching approximates the cascade via source order +
element.matches(), without full CSS specificity/!importantresolution — used only for human-readable "where did this come from" attribution, never for the actual color value (which always comes fromgetComputedStyle). Cross-origin stylesheets can't be inspected (cssRulesthrows) and are skipped with a recorded warning, not silently dropped. -
Closed Shadow DOM is fundamentally invisible to page scripts by browser design —
element.shadowRootisnullfor both "no shadow root" and "closed shadow root", sosummary.shadowRootsSkippedcannot be a reliable count. Open shadow roots are traversed recursively. -
Interactive states (
:hover,:focus,:active,:visited) cannot be read without forcing that state — the library does not fabricate results for states it cannot observe; violations tied to guessed semantic roles carryconfidence: "low"or"medium"instead of asserting certainty. -
Semantic intent (is this text a heading vs. decorative marketing copy?) is inferred only from DOM-observable facts (tag,
role, computed font-size/weight,disabled) — never from guessed intent. -
jsdomtest environments:getComputedStyle(el, pseudo)is not implemented in jsdom (throws), and jsdom's CSS parser doesn't resolve inheritance, gradients, orvar()at the property-setter level. This library's own test suite works around these with explicit stubs where needed (see comments inpackages/core/tests/dom/pseudo-element.test.ts); real browsers do not have these limitations.
Implement AccessibilityRule (see §9) and pass it via customRules. A rule can also provide getSuggestions(context) to override the default suggestion engine for its own findings. This is the intended extension point for APCA, WCAG 3, brand-specific, or platform-specific (e.g. Apple HIG) policies without modifying core.
createColorAccessibilityAnalyzer(config?: AnalyzerConfig): ColorAccessibilityAnalyzer
class ColorAccessibilityAnalyzer {
analyze(root?: Element | Document): AnalysisResult;
analyzeSelector(selector: string, root?: ParentNode): AnalysisResult;
analyzeElement(element: Element): AnalysisResult;
getViolations(): ColorViolation[];
getAggregatedFindings(): AggregatedFinding[];
getSuggestions(violation: ColorViolation, options?: SuggestOptions): ColorSuggestion[];
generateReport(options?: ReportOptions): AnalysisResult | string;
observe(root: Element, options?: ObserveOptions): ObserveHandle;
}Tree-shakable leaf exports (no analyzer instance required): parseColor, contrastRatio, evaluateContrast, matchPalette, suggest, getFix, toJSON, toHTML, formatConsoleReport. Full type definitions ship as .d.ts — see packages/core/src/types/ for the source of truth (ColorInfo, ColorSource, ContrastResult, PaletteMatchResult, ElementColorAnalysis, ColorViolation, AggregatedFinding, AnalyzerConfig, AccessibilityRule).
This is a static, read-only analyzer of rendered DOM state. It does not: modify your DOM/CSS, simulate interactive states, see through images/canvas/video, resolve full CSS cascade specificity, or guarantee semantic-role accuracy. Where certainty isn't possible, it reports lower confidence instead of asserting a wrong answer — see §15 for the full list.
npm install
npm run build # builds every package
npm run test # runs @colorlint/core's Jest suite (88 tests)MIT