UI Components Medium
Accessible Dark/Light Toggle
Dark and light mode toggle that maintains WCAG AA contrast ratio in both themes with smooth transition.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Dark theme (default) ── */
:root {
color-scheme: dark;
--bg: #0a0a0a;
--surface: #141414;
--elevated: #1a1a1a;
--border: #262626;
--text: #e5e5e5;
--text-secondary: #a3a3a3;
--text-muted: #737373;
--link: #60a5fa;
--accent: #3b82f6;
--accent-text: #ffffff;
--input-bg: #171717;
--input-border: #333333;
--input-text: #e5e5e5;
--btn-outline-border: #404040;
--transition-speed: 0.3s;
}
/* ── Light theme ── */
.light {
color-scheme: light;
--bg: #ffffff;
--surface: #f5f5f5;
--elevated: #fafafa;
--border: #e5e5e5;
--text: #111111;
--text-secondary: #525252;
--text-muted: #737373;
--link: #2563eb;
--accent: #2563eb;
--accent-text: #ffffff;
--input-bg: #ffffff;
--input-border: #d4d4d4;
--input-text: #111111;
--btn-outline-border: #d4d4d4;
}
body {
font-family: Inter, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 0;
transition: background var(--transition-speed), color var(--transition-speed);
}
.page {
width: 100%;
max-width: 760px;
padding: 1.5rem 2rem;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
transition: border-color var(--transition-speed);
}
.header-left {
display: flex;
align-items: center;
gap: 2rem;
}
.logo {
font-size: 1.125rem;
font-weight: 800;
letter-spacing: -0.02em;
white-space: nowrap;
}
.nav {
display: flex;
gap: 0.25rem;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
background: var(--surface);
color: var(--text);
}
.nav-link.active {
color: var(--text);
font-weight: 600;
}
/* ── Theme toggle button ── */
.theme-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text);
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.theme-btn:hover {
border-color: var(--text-muted);
}
/* Show/hide sun/moon icons */
.icon-sun {
display: none;
}
.icon-moon {
display: block;
}
.light .icon-sun {
display: block;
}
.light .icon-moon {
display: none;
}
/* ── Hero ── */
.hero {
margin-bottom: 2rem;
padding: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
transition: background var(--transition-speed), border-color var(--transition-speed);
}
.hero-title {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.5rem;
}
.hero-text {
color: var(--text-secondary);
font-size: 0.9375rem;
line-height: 1.6;
}
/* ── Cards ── */
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 2rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.25rem;
transition: background var(--transition-speed), border-color var(--transition-speed);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.card-title {
font-size: 0.875rem;
font-weight: 700;
}
.card-body {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.6;
}
.card-body.secondary {
color: var(--text-secondary);
}
/* ── Ratio badges ── */
.ratio-badge {
font-size: 0.625rem;
font-weight: 700;
font-family: "SF Mono", "Fira Code", monospace;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: var(--elevated);
border: 1px solid var(--border);
white-space: nowrap;
transition: background var(--transition-speed), border-color var(--transition-speed);
}
.ratio-badge.small {
font-size: 0.5625rem;
margin-top: 0.25rem;
display: inline-block;
}
.ratio-badge.inline {
vertical-align: baseline;
}
/* ── Form ── */
.form-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
transition: background var(--transition-speed), border-color var(--transition-speed);
}
.section-title {
font-size: 1rem;
font-weight: 700;
margin-bottom: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.form-group input {
width: 100%;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 0.5rem;
color: var(--input-text);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.15s, background var(--transition-speed);
}
.form-group input:focus {
border-color: var(--accent);
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.btn-primary {
background: var(--accent);
color: var(--accent-text);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-outline {
background: transparent;
color: var(--text);
border-color: var(--btn-outline-border);
}
.btn-outline:hover {
background: var(--surface);
}
/* ── Text section ── */
.text-section {
margin-bottom: 2rem;
}
.body-text {
font-size: 0.9375rem;
line-height: 1.7;
color: var(--text-secondary);
}
.body-text a {
color: var(--link);
text-decoration: underline;
text-underline-offset: 2px;
}
.body-text a:hover {
opacity: 0.8;
}
/* ── Contrast table ── */
.contrast-table-section {
margin-bottom: 2rem;
}
.contrast-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.contrast-table th {
text-align: left;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.contrast-table td {
padding: 0.625rem 0.75rem;
border-bottom: 1px solid var(--border);
transition: border-color var(--transition-speed);
}
.contrast-table .swatch-cell {
width: 24px;
height: 24px;
border-radius: 0.25rem;
border: 1px solid var(--border);
display: inline-block;
vertical-align: middle;
}
.pass-cell {
color: #22c55e;
font-weight: 700;
}
.fail-cell {
color: #ef4444;
font-weight: 700;
}
@media (max-width: 640px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.header-left {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.cards {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: 1fr;
}
}(function () {
var toggleBtn = document.getElementById("theme-toggle");
var themeLabel = document.getElementById("theme-label");
var tbody = document.getElementById("contrast-tbody");
var body = document.body;
// ── WCAG contrast helpers ──
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 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);
}
// ── Theme definitions ──
var themes = {
dark: {
bg: "#0a0a0a",
surface: "#141414",
text: "#e5e5e5",
textSecondary: "#a3a3a3",
link: "#60a5fa",
inputBg: "#171717",
inputText: "#e5e5e5",
},
light: {
bg: "#ffffff",
surface: "#f5f5f5",
text: "#111111",
textSecondary: "#525252",
link: "#2563eb",
inputBg: "#ffffff",
inputText: "#111111",
},
};
var colorPairs = [
{ name: "Text / Background", fgKey: "text", bgKey: "bg", pairId: "text-bg" },
{ name: "Text / Surface", fgKey: "text", bgKey: "surface", pairId: "text-surface" },
{ name: "Secondary / Background", fgKey: "textSecondary", bgKey: "bg", pairId: "secondary-bg" },
{ name: "Link / Background", fgKey: "link", bgKey: "bg", pairId: "link-bg" },
{ name: "Input text / Input bg", fgKey: "inputText", bgKey: "inputBg", pairId: "input-text" },
];
var currentTheme = "dark";
// ── Detect OS preference ──
function getPreferred() {
var stored = localStorage.getItem("theme-preference");
if (stored) return stored;
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches)
return "light";
return "dark";
}
// ── Apply theme ──
function applyTheme(theme) {
currentTheme = theme;
if (theme === "light") {
body.classList.add("light");
} else {
body.classList.remove("light");
}
themeLabel.textContent = theme === "light" ? "Dark" : "Light";
localStorage.setItem("theme-preference", theme);
updateRatios();
}
// ── Update ratio badges and table ──
function updateRatios() {
var t = themes[currentTheme];
// Update inline badges
var badges = document.querySelectorAll(".ratio-badge[data-pair]");
badges.forEach(function (badge) {
var pair = colorPairs.find(function (p) {
return p.pairId === badge.getAttribute("data-pair");
});
if (pair) {
var ratio = contrastRatio(t[pair.fgKey], t[pair.bgKey]);
badge.textContent = ratio.toFixed(1) + ":1";
badge.style.color = ratio >= 4.5 ? "#22c55e" : "#ef4444";
}
});
// Update table
tbody.innerHTML = "";
colorPairs.forEach(function (pair) {
var fg = t[pair.fgKey];
var bg = t[pair.bgKey];
var ratio = contrastRatio(fg, bg);
var passAA = ratio >= 4.5;
var passAAA = ratio >= 7;
var tr = document.createElement("tr");
tr.innerHTML =
"<td>" +
pair.name +
"</td>" +
'<td><span class="swatch-cell" style="background:' +
fg +
'"></span> ' +
fg +
"</td>" +
'<td><span class="swatch-cell" style="background:' +
bg +
'"></span> ' +
bg +
"</td>" +
"<td><strong>" +
ratio.toFixed(2) +
":1</strong></td>" +
'<td class="' +
(passAA ? "pass-cell" : "fail-cell") +
'">' +
(passAA ? "\u2713 Pass" : "\u2717 Fail") +
"</td>" +
'<td class="' +
(passAAA ? "pass-cell" : "fail-cell") +
'">' +
(passAAA ? "\u2713 Pass" : "\u2717 Fail") +
"</td>";
tbody.appendChild(tr);
});
}
// ── Toggle ──
toggleBtn.addEventListener("click", function () {
applyTheme(currentTheme === "dark" ? "light" : "dark");
});
// ── Listen for OS changes ──
if (window.matchMedia) {
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function (e) {
if (!localStorage.getItem("theme-preference")) {
applyTheme(e.matches ? "light" : "dark");
}
});
}
// ── Init ──
applyTheme(getPreferred());
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Accessible Dark/Light Toggle</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<!-- Header -->
<header class="header">
<div class="header-left">
<h1 class="logo">Accessible Themes</h1>
<nav class="nav" aria-label="Main">
<a href="#" class="nav-link active" onclick="return false">Home</a>
<a href="#" class="nav-link" onclick="return false">Docs</a>
<a href="#" class="nav-link" onclick="return false">API</a>
<a href="#" class="nav-link" onclick="return false">Blog</a>
</nav>
</div>
<button class="theme-btn" id="theme-toggle" aria-label="Toggle dark/light mode">
<svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<span class="theme-label" id="theme-label">Light</span>
</button>
</header>
<!-- Hero -->
<section class="hero">
<h2 class="hero-title">WCAG-Compliant Themes</h2>
<p class="hero-text">Both dark and light themes maintain minimum 4.5:1 contrast ratio for all text elements and interactive controls.</p>
</section>
<!-- Cards with contrast ratios -->
<div class="cards">
<div class="card">
<div class="card-header">
<h3 class="card-title">Text on Background</h3>
<span class="ratio-badge" data-pair="text-bg"></span>
</div>
<p class="card-body">Primary text content displayed on the main background color. This combination exceeds AA requirements in both themes.</p>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Text on Surface</h3>
<span class="ratio-badge" data-pair="text-surface"></span>
</div>
<p class="card-body">Content placed on elevated surfaces like cards and panels. Contrast ratios are validated for both themes.</p>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Secondary Text</h3>
<span class="ratio-badge" data-pair="secondary-bg"></span>
</div>
<p class="card-body secondary">Secondary text uses a muted color while still maintaining the minimum 4.5:1 contrast ratio required by WCAG AA.</p>
</div>
</div>
<!-- Form -->
<div class="form-section">
<h3 class="section-title">Form Controls</h3>
<div class="form-grid">
<div class="form-group">
<label for="input-name">Name</label>
<input type="text" id="input-name" value="Jane Doe" />
<span class="ratio-badge small" data-pair="input-text"></span>
</div>
<div class="form-group">
<label for="input-email">Email</label>
<input type="email" id="input-email" value="jane@example.com" />
<span class="ratio-badge small" data-pair="input-text"></span>
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary">Submit</button>
<button class="btn btn-outline">Cancel</button>
</div>
</div>
<!-- Inline text -->
<div class="text-section">
<h3 class="section-title">Inline Elements</h3>
<p class="body-text">
Regular text with <a href="#" onclick="return false">accessible links</a> that remain
distinguishable in both themes. Links maintain a <span class="ratio-badge inline" data-pair="link-bg"></span>
contrast ratio. <strong>Bold text</strong> and <em>emphasized text</em> inherit the primary
color and its high contrast ratio.
</p>
</div>
<!-- Contrast table -->
<div class="contrast-table-section">
<h3 class="section-title">Contrast Ratios</h3>
<table class="contrast-table" aria-label="Contrast ratios for current theme">
<thead>
<tr>
<th>Pair</th>
<th>Foreground</th>
<th>Background</th>
<th>Ratio</th>
<th>AA</th>
<th>AAA</th>
</tr>
</thead>
<tbody id="contrast-tbody"></tbody>
</table>
</div>
</div>
<script src="script.js"></script>
</body>
</html>A dark and light mode toggle that guarantees WCAG AA contrast ratios in both themes. Uses CSS custom properties for smooth transitions, respects prefers-color-scheme, persists preference in localStorage, and dynamically displays contrast ratios for every element.