Show DEV: a single-file WCAG contrast checker, generated in one shot and verified by execution
I do marketing for HelloAI (https://tryhello.app), the app in this test. Insider test, not a neutral review. My earlier posts here were documents it wrote; this time I asked for a runnable thing: a WCAG color contrast ch
I do marketing for HelloAI (https://tryhello.app), the app in this test. Insider test, not a neutral review. My earlier posts here were documents it wrote; this time I asked for a runnable thing: a WCAG color contrast checker as one self-contained HTML file. It produced the whole file in a single response. Below is that exact file, unchanged, plus everything I did to check it before publishing. Repairs from me: zero.
I picked a contrast checker deliberately: the correct answer is exact math, so a generated one can be verified instead of trusted.
The prompt
Build me a single-file HTML page (inline CSS and JS, no external libraries) that checks WCAG color contrast. Two hex color inputs (foreground and background), a live text preview in those colors, the contrast ratio rounded to 2 decimals, and pass/fail badges for AA normal text, AA large text, AAA normal, and AAA large. Add four preset color pairs I can click to load. Keep it compact, under 200 lines. Give me the complete file as code I can save and open.
The file
Save as contrast.html and open it in a browser:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WCAG Contrast Checker</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif;
color: #17202a; background: #f2f4f7;
}
main {
max-width: 540px; margin: auto; padding: 22px;
background: white; border: 1px solid #d9dee5; border-radius: 12px;
}
h1 { margin: 0 0 4px; font-size: 1.35rem; }
.hint { margin: 0 0 18px; color: #586575; font-size: .9rem; }
.inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
label { display: block; font-weight: 650; }
input[type="text"] {
display: block; width: 100%; margin-top: 5px; padding: 9px;
border: 1px solid #b8c1cc; border-radius: 6px; font: inherit;
}
.preview {
margin: 16px 0; min-height: 92px; padding: 18px;
border: 1px solid #d9dee5; border-radius: 8px;
}
.preview strong { display: block; font-size: 1.2rem; }
.ratio { margin: 12px 0; font-size: 1.08rem; font-weight: 700; }
.badges { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.badge {
display: flex; justify-content: space-between; gap: 8px;
padding: 9px 10px; border-radius: 6px; background: #f2f4f7;
}
.pass { color: #146c3a; font-weight: 700; }
.fail { color: #a12622; font-weight: 700; }
h2 { margin: 18px 0 8px; font-size: 1rem; }
.presets { display: flex; flex-wrap: wrap; gap: 8px; }
button {
padding: 7px 10px; border: 1px solid #b8c1cc;
border-radius: 6px; background: white; font: inherit; cursor: pointer;
}
button:hover { background: #edf1f5; }
@media (max-width: 420px) {
body { padding: 12px; }
main { padding: 16px; }
.inputs { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<h1>WCAG Contrast Checker</h1>
<p class="hint">Enter 3- or 6-digit hex colors, with or without #.</p>
<div class="inputs">
<label for="fg">Foreground
<input id="fg" type="text" value="#1A1A1A" autocomplete="off" spellcheck="false">
</label>
<label for="bg">Background
<input id="bg" type="text" value="#FFFFFF" autocomplete="off" spellcheck="false">
</label>
</div>
<div id="preview" class="preview">
<strong>Live text preview</strong>
Normal text sample Β· Large text sample
</div>
<div id="ratio" class="ratio" aria-live="polite"></div>
<div id="results" class="badges"></div>
<h2>Presets</h2>
<div id="presets" class="presets"></div>
</main>
<script>
const fg = document.querySelector("#fg");
const bg = document.querySelector("#bg");
const preview = document.querySelector("#preview");
const ratio = document.querySelector("#ratio");
const results = document.querySelector("#results");
const pairs = [
["Black on white", "#000000", "#FFFFFF"],
["White on navy", "#FFFFFF", "#1D3557"],
["Yellow on black", "#FFFF00", "#000000"],
["Gray on white", "#777777", "#FFFFFF"]
];
function parseHex(value) {
let hex = value.trim().replace(/^#/, "");
if (/^[0-9a-f]{3}$/i.test(hex)) hex = [...hex].map(x => x + x).join("");
if (!/^[0-9a-f]{6}$/i.test(hex)) return null;
return [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
}
function luminance(rgb) {
const channels = rgb.map(v => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function update() {
const f = parseHex(fg.value), b = parseHex(bg.value);
fg.setAttribute("aria-invalid", String(!f));
bg.setAttribute("aria-invalid", String(!b));
if (!f || !b) {
preview.textContent = "Enter valid hex colors to see the preview.";
preview.style.color = preview.style.backgroundColor = "";
ratio.textContent = "Please enter valid 3- or 6-digit hex colors.";
results.replaceChildren();
return;
}
const foreground = "#" + f.map(v => v.toString(16).padStart(2, "0")).join("");
const background = "#" + b.map(v => v.toString(16).padStart(2, "0")).join("");
preview.innerHTML = "<strong>Live text preview</strong>Normal text sample Β· Large text sample";
preview.style.color = foreground;
preview.style.backgroundColor = background;
const l1 = luminance(f), l2 = luminance(b);
const value = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
ratio.textContent = `Contrast ratio: ${value.toFixed(2)}:1`;
const checks = [
["AA normal text", 4.5], ["AA large text", 3],
["AAA normal text", 7], ["AAA large text", 4.5]
];
results.innerHTML = checks.map(([name, threshold]) => {
const passes = value >= threshold;
return `<div class="badge"><span>${name}</span><span class="${passes ? "pass" : "fail"}">${passes ? "PASS" : "FAIL"}</span></div>`;
}).join("");
}
[fg, bg].forEach(input => input.addEventListener("input", update));
pairs.forEach(([name, foreground, background]) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = name;
button.addEventListener("click", () => {
fg.value = foreground;
bg.value = background;
update();
});
document.querySelector("#presets").append(button);
});
update();
</script>
</body>
</html>
How I checked it
I did not want to publish math I had not run:
- I pulled its
parseHexandluminancefunctions out verbatim and ran 12 color pairs through them, and through a second implementation I wrote myself from the WCAG 2.x formula. All 12 matched, including the boundary pair that matters most:#777777on white (4.48, fails AA normal) versus#767676on white (4.54, passes AA normal). A checker that gets the 4.5 line wrong is worse than no checker. - Three-digit expansion and missing-
#inputs behaved (#777equals#777777). - Six invalid inputs (a named color, a 4-digit value, junk, an empty field) were all rejected with a clear message. No crash, no stale badges.
- I loaded the actual file in a browser and clicked through it: presets load, the badges show the exact expected FAIL/PASS pattern on the boundary gray, and invalid input clears the results.
- Cross-check against an outside oracle: WebAIM's contrast checker API on 5 of the pairs. The pass/fail calls agreed on all 5. The displayed ratio can differ in the last digit (this shows 4.48 where WebAIM shows 4.47 on
#777777) because this tool rounds and WebAIM truncates. The decisions are unaffected: both compare the unrounded value against the threshold.
Honest limits
- Hex input only. No named colors,
rgb(),hsl(), or transparency. Named colors are rejected, not resolved. - The preview text says "Normal text sample Β· Large text sample" but renders both at the same size. Cosmetic, but split them if you ship this.
- It implements the WCAG 2.x luminance math, which is what current conformance targets use (2.1 and 2.2 carry the same 4.5 / 3 / 7 / 4.5 thresholds; I checked both specs). APCA, the candidate approach for WCAG 3, is different math and this is not it.
- A passing ratio is a floor, not a readability guarantee. Font, weight, size and context still matter.
- One prompt, one run. The file above is exactly what it produced this time; the next prompt could produce something worse. Verify before you ship, whoever wrote the code.
The app: https://tryhello.app (free plan, no card)
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.