UI Components Medium
Accessible Palette Generator
Generates color palettes that automatically meet WCAG 2.1 contrast ratio requirements for text and UI elements.
Open in Lab
MCP
vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.generator {
width: 100%;
max-width: 640px;
}
.gen-title {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.375rem;
}
.gen-sub {
color: #737373;
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 2rem;
}
/* ── Base input ── */
.base-input {
margin-bottom: 2rem;
}
.base-input label {
display: block;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #a3a3a3;
margin-bottom: 0.5rem;
}
.base-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.base-row input[type="color"] {
width: 36px;
height: 36px;
border: none;
border-radius: 0.375rem;
cursor: pointer;
padding: 0;
background: none;
-webkit-appearance: none;
}
.base-row input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.base-row input[type="color"]::-webkit-color-swatch {
border: 1px solid #404040;
border-radius: 0.375rem;
}
.base-row input[type="text"] {
background: #171717;
border: 1px solid #262626;
border-radius: 0.5rem;
color: #e5e5e5;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
width: 110px;
outline: none;
}
.base-row input[type="text"]:focus {
border-color: #525252;
}
.randomize-btn {
display: flex;
align-items: center;
gap: 0.375rem;
background: #171717;
border: 1px solid #262626;
border-radius: 0.5rem;
color: #a3a3a3;
font-size: 0.8125rem;
font-weight: 500;
padding: 0.5rem 0.875rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.randomize-btn:hover {
background: #262626;
color: #e5e5e5;
}
/* ── Palette ── */
.palette {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.swatch {
border-radius: 0.75rem;
border: 1px solid #262626;
overflow: hidden;
background: #171717;
}
.swatch-color {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
position: relative;
}
.swatch-color span {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 0.6875rem;
opacity: 0.85;
}
.swatch-info {
padding: 0.5rem;
font-size: 0.6875rem;
}
.swatch-hex {
font-family: "SF Mono", "Fira Code", monospace;
color: #a3a3a3;
margin-bottom: 0.375rem;
}
.swatch-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.swatch-row .label {
color: #737373;
}
.swatch-row .ratio {
font-weight: 600;
font-family: "SF Mono", "Fira Code", monospace;
}
.badge {
font-size: 0.5625rem;
font-weight: 700;
padding: 0.0625rem 0.3125rem;
border-radius: 0.1875rem;
letter-spacing: 0.04em;
}
.badge-aaa {
background: #14532d;
color: #4ade80;
}
.badge-aa {
background: #422006;
color: #fbbf24;
}
.badge-fail {
background: #450a0a;
color: #f87171;
}
/* ── Actions ── */
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.copy-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: #e5e5e5;
color: #0a0a0a;
border: none;
border-radius: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.copy-btn:hover {
background: #d4d4d4;
}
.copy-toast {
font-size: 0.8125rem;
color: #22c55e;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
}
.copy-toast.show {
opacity: 1;
}
/* ── Legend ── */
.legend {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: #737373;
}
@media (max-width: 640px) {
.palette {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 400px) {
.palette {
grid-template-columns: repeat(2, 1fr);
}
}(function () {
var basePicker = document.getElementById("base-picker");
var baseInput = document.getElementById("base-color");
var randomBtn = document.getElementById("randomize-btn");
var paletteEl = document.getElementById("palette");
var copyBtn = document.getElementById("copy-css");
var copyToast = document.getElementById("copy-toast");
var currentPalette = [];
// ── Color math ──
function hexToRgb(hex) {
hex = hex.replace("#", "");
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
var n = parseInt(hex, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r, g, b) {
return (
"#" +
[r, g, b]
.map(function (c) {
var h = Math.round(Math.max(0, Math.min(255, c))).toString(16);
return h.length === 1 ? "0" + h : h;
})
.join("")
);
}
function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
var max = Math.max(r, g, b),
min = Math.min(r, g, b);
var h,
s,
l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
var d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return [h * 360, s * 100, l * 100];
}
function hslToRgb(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
var r, g, b;
if (s === 0) {
r = g = b = l;
} else {
function hue2rgb(p, q, t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function linearize(c) {
var s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
function luminance(rgb) {
return 0.2126 * linearize(rgb[0]) + 0.7152 * linearize(rgb[1]) + 0.0722 * linearize(rgb[2]);
}
function contrastRatio(hex1, hex2) {
var l1 = luminance(hexToRgb(hex1));
var l2 = luminance(hexToRgb(hex2));
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// ── Generate palette ──
function generatePalette(baseHex) {
var rgb = hexToRgb(baseHex);
var hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]);
var hue = hsl[0];
var sat = hsl[1];
// 7 lightness stops
var stops = [95, 85, 70, 50, 35, 22, 10];
var palette = stops.map(function (l, i) {
// Slightly adjust saturation for extreme lightness
var adjustedSat = l > 85 ? sat * 0.4 : l < 20 ? sat * 0.6 : sat;
var c = hslToRgb(hue, adjustedSat, l);
var hex = rgbToHex(c[0], c[1], c[2]);
return { hex: hex, lightness: l, index: (i + 1) * 100 };
});
return palette;
}
function getBadge(ratio) {
if (ratio >= 7) return { cls: "badge-aaa", text: "AAA" };
if (ratio >= 4.5) return { cls: "badge-aa", text: "AA" };
return { cls: "badge-fail", text: "Fail" };
}
// ── Render ──
function render() {
var baseHex = baseInput.value;
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(baseHex)) return;
currentPalette = generatePalette(baseHex);
paletteEl.innerHTML = "";
currentPalette.forEach(function (swatch) {
var whiteRatio = contrastRatio(swatch.hex, "#ffffff");
var darkRatio = contrastRatio(swatch.hex, "#111111");
var whiteBadge = getBadge(whiteRatio);
var darkBadge = getBadge(darkRatio);
// Choose text color for the swatch preview
var textColor = swatch.lightness > 55 ? "#111111" : "#ffffff";
var el = document.createElement("div");
el.className = "swatch";
el.innerHTML =
'<div class="swatch-color" style="background:' +
swatch.hex +
";color:" +
textColor +
'">' +
"<span>" +
swatch.index +
"</span>" +
"</div>" +
'<div class="swatch-info">' +
'<div class="swatch-hex">' +
swatch.hex +
"</div>" +
'<div class="swatch-row">' +
'<span class="label" style="color:#fff">\u25CF White</span>' +
'<span class="badge ' +
whiteBadge.cls +
'">' +
whiteBadge.text +
" " +
whiteRatio.toFixed(1) +
"</span>" +
"</div>" +
'<div class="swatch-row">' +
'<span class="label">\u25CF Dark</span>' +
'<span class="badge ' +
darkBadge.cls +
'">' +
darkBadge.text +
" " +
darkRatio.toFixed(1) +
"</span>" +
"</div>" +
"</div>";
paletteEl.appendChild(el);
});
}
// ── Copy CSS ──
function copyCss() {
var lines = [":root {"];
currentPalette.forEach(function (s) {
lines.push(" --color-" + s.index + ": " + s.hex + ";");
});
lines.push("}");
navigator.clipboard.writeText(lines.join("\n")).then(function () {
copyToast.classList.add("show");
setTimeout(function () {
copyToast.classList.remove("show");
}, 1500);
});
}
// ── Events ──
basePicker.addEventListener("input", function () {
baseInput.value = basePicker.value;
render();
});
baseInput.addEventListener("input", function () {
if (/^#([0-9a-fA-F]{6})$/.test(baseInput.value)) {
basePicker.value = baseInput.value;
}
render();
});
randomBtn.addEventListener("click", function () {
var h = Math.floor(Math.random() * 360);
var s = 50 + Math.floor(Math.random() * 40);
var l = 40 + Math.floor(Math.random() * 20);
var rgb = hslToRgb(h, s, l);
var hex = rgbToHex(rgb[0], rgb[1], rgb[2]);
baseInput.value = hex;
basePicker.value = hex;
render();
});
copyBtn.addEventListener("click", copyCss);
// ── Init ──
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accessible Palette Generator</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="generator">
<h1 class="gen-title">Accessible Palette Generator</h1>
<p class="gen-sub">Generate palettes that meet WCAG 2.1 contrast requirements automatically.</p>
<!-- Base color input -->
<div class="base-input">
<label for="base-color">Base Color</label>
<div class="base-row">
<input type="color" id="base-picker" value="#3b82f6" aria-label="Pick base color" />
<input type="text" id="base-color" value="#3b82f6" maxlength="7" spellcheck="false" />
<button class="randomize-btn" id="randomize-btn" aria-label="Random color">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><circle cx="12" cy="12" r="2"/></svg>
Randomize
</button>
</div>
</div>
<!-- Palette -->
<div class="palette" id="palette"></div>
<!-- Copy actions -->
<div class="actions">
<button class="copy-btn" id="copy-css" aria-label="Copy CSS variables">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Copy CSS Variables
</button>
<span class="copy-toast" id="copy-toast">Copied!</span>
</div>
<!-- Legend -->
<div class="legend">
<div class="legend-item"><span class="badge badge-aaa">AAA</span> Contrast ≥ 7:1</div>
<div class="legend-item"><span class="badge badge-aa">AA</span> Contrast ≥ 4.5:1</div>
<div class="legend-item"><span class="badge badge-fail">Fail</span> Below 4.5:1</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Generates accessible color palettes from a base color, producing 7 shades from light to dark. Each shade displays its contrast ratio against white and black text, with AA/AAA compliance badges and one-click CSS custom property export.