Bonus
WCAG contrast checker
Test color pairs with live accessibility results
Test color pairs with live accessibility results
Create a polished WCAG contrast checker with Tailwind CSS. Use a two-column card with foreground and background controls, each combining a native color input and an editable hex field, plus a swap-colors button between them. Under the controls, show a generous live preview containing large heading text, body copy, and a button using the chosen colors. Finish with a results strip that shows the contrast ratio and four pass/fail badges for AA normal, AA large, AAA normal, and AAA large text. Then add JavaScript that accepts three- or six-digit hex colors, keeps the native pickers and text fields synchronized, marks invalid input without breaking the last valid preview, swaps the colors, calculates relative luminance and contrast using the WCAG formula, formats the ratio to two decimals, and updates every pass badge against the correct 3.0, 4.5, and 7.0 thresholds. <div class="mx-auto max-w-2xl p-6">
<section class="overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm">
<header class="border-b border-neutral-100 px-5 py-4">
<h2 class="text-sm font-semibold text-neutral-900">Contrast checker</h2>
<p class="mt-1 text-sm text-neutral-500">Test a color pair against WCAG text thresholds.</p>
</header>
<div class="grid gap-3 p-5 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
<label>
<span class="mb-1.5 block text-xs font-medium text-neutral-600">Foreground</span>
<span class="flex rounded-xl border border-neutral-200 p-1 focus-within:border-neutral-900 focus-within:ring-4 focus-within:ring-neutral-900/10">
<input id="foregroundPicker" type="color" value="#171717" class="h-9 w-10 cursor-pointer rounded-lg border-0 bg-transparent p-0" />
<input id="foregroundHex" value="#171717" spellcheck="false" class="min-w-0 flex-1 px-2 font-mono text-sm uppercase text-neutral-700 outline-none" />
</span>
</label>
<button id="swapColors" aria-label="Swap foreground and background colors" class="mx-auto flex h-10 w-10 items-center justify-center rounded-xl border border-neutral-200 text-neutral-500 transition hover:bg-neutral-50 hover:text-neutral-900 sm:mb-0"><svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="inline-block h-[1em] w-[1em] shrink-0 align-[-0.125em] transition-transform"><path d="M7 7h12m0 0-3-3m3 3-3 3M17 17H5m0 0 3 3m-3-3 3-3"/></svg></button>
<label>
<span class="mb-1.5 block text-xs font-medium text-neutral-600">Background</span>
<span class="flex rounded-xl border border-neutral-200 p-1 focus-within:border-neutral-900 focus-within:ring-4 focus-within:ring-neutral-900/10">
<input id="backgroundPicker" type="color" value="#FAFAFA" class="h-9 w-10 cursor-pointer rounded-lg border-0 bg-transparent p-0" />
<input id="backgroundHex" value="#FAFAFA" spellcheck="false" class="min-w-0 flex-1 px-2 font-mono text-sm uppercase text-neutral-700 outline-none" />
</span>
</label>
</div>
<div class="px-5 pb-5">
<div id="contrastPreview" class="rounded-2xl p-8 transition-colors">
<p class="text-xs font-semibold uppercase tracking-[0.18em] opacity-60">Preview</p>
<h3 class="mt-3 text-3xl font-semibold tracking-tight">Readable by design.</h3>
<p class="mt-2 max-w-lg text-sm leading-6 opacity-80">Strong contrast makes interfaces easier to scan in bright rooms, on dim displays, and for people with low vision.</p>
<button id="previewButton" class="mt-5 rounded-xl border px-4 py-2 text-sm font-medium">Primary action</button>
</div>
</div>
<div class="border-t border-neutral-100 bg-neutral-50 p-5">
<div class="flex items-baseline justify-between gap-4">
<span class="text-sm font-medium text-neutral-700">Contrast ratio</span>
<strong id="contrastRatio" class="text-2xl font-semibold tabular-nums text-neutral-900">20.14:1</strong>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">
<div data-test data-threshold="4.5" class="rounded-xl border px-3 py-2"><p class="text-xs font-medium">AA normal</p><p data-result class="mt-0.5 text-xs"></p></div>
<div data-test data-threshold="3" class="rounded-xl border px-3 py-2"><p class="text-xs font-medium">AA large</p><p data-result class="mt-0.5 text-xs"></p></div>
<div data-test data-threshold="7" class="rounded-xl border px-3 py-2"><p class="text-xs font-medium">AAA normal</p><p data-result class="mt-0.5 text-xs"></p></div>
<div data-test data-threshold="4.5" class="rounded-xl border px-3 py-2"><p class="text-xs font-medium">AAA large</p><p data-result class="mt-0.5 text-xs"></p></div>
</div>
</div>
</section>
</div> var foregroundHex = document.getElementById("foregroundHex");
var backgroundHex = document.getElementById("backgroundHex");
var foregroundPicker = document.getElementById("foregroundPicker");
var backgroundPicker = document.getElementById("backgroundPicker");
var preview = document.getElementById("contrastPreview");
var previewButton = document.getElementById("previewButton");
var ratioOutput = document.getElementById("contrastRatio");
var tests = [].slice.call(document.querySelectorAll("[data-test]"));
var foreground = "#171717";
var background = "#FAFAFA";
function expand(value) {
var clean = value.trim().replace(/^#/, "");
if (/^[0-9a-f]{3}$/i.test(clean)) {
clean = clean.split("").map(function (letter) { return letter + letter; }).join("");
}
return /^[0-9a-f]{6}$/i.test(clean) ? "#" + clean.toUpperCase() : null;
}
function luminance(hex) {
var channels = [1, 3, 5].map(function (start) {
var value = parseInt(hex.slice(start, start + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrast(first, second) {
var light = Math.max(luminance(first), luminance(second));
var dark = Math.min(luminance(first), luminance(second));
return (light + 0.05) / (dark + 0.05);
}
function mark(input, valid) {
input.parentElement.classList.toggle("border-red-400", !valid);
input.classList.toggle("text-red-600", !valid);
}
function render() {
var ratio = contrast(foreground, background);
foregroundHex.value = foreground;
backgroundHex.value = background;
foregroundPicker.value = foreground;
backgroundPicker.value = background;
preview.style.color = foreground;
preview.style.backgroundColor = background;
previewButton.style.borderColor = foreground;
ratioOutput.textContent = ratio.toFixed(2) + ":1";
tests.forEach(function (test) {
var pass = ratio >= Number(test.getAttribute("data-threshold"));
test.className = pass
? "rounded-xl border border-green-200 bg-green-50 px-3 py-2 text-green-700"
: "rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-red-700";
test.querySelector("[data-result]").textContent = pass ? "✓ Pass" : "✕ Fail";
});
}
function bindText(input, side) {
input.addEventListener("input", function () {
var color = expand(input.value);
mark(input, Boolean(color));
if (!color) return;
if (side === "foreground") foreground = color;
else background = color;
render();
});
input.addEventListener("blur", render);
}
foregroundPicker.addEventListener("input", function () {
foreground = foregroundPicker.value.toUpperCase();
mark(foregroundHex, true);
render();
});
backgroundPicker.addEventListener("input", function () {
background = backgroundPicker.value.toUpperCase();
mark(backgroundHex, true);
render();
});
document.getElementById("swapColors").addEventListener("click", function () {
var previous = foreground;
foreground = background;
background = previous;
mark(foregroundHex, true);
mark(backgroundHex, true);
render();
});
bindText(foregroundHex, "foreground");
bindText(backgroundHex, "background");
render();