UI Components Easy
Text Spacing Control
Adjustable controls for letter-spacing, word-spacing and line-height to meet WCAG 1.4.12 text spacing requirements.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0a0a0a;
color: #e4e4e7;
line-height: 1.5;
min-height: 100vh;
}
.layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr;
}
}
/* ---- Panel ---- */
.panel {
position: sticky;
top: 2rem;
align-self: start;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 1.5rem;
}
.panel__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.panel__title {
font-size: 1rem;
font-weight: 600;
color: #f4f4f5;
}
.panel__badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8b5cf6;
background: rgba(139, 92, 246, 0.12);
padding: 0.25rem 0.5rem;
border-radius: 6px;
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.panel__badge:hover {
background: rgba(139, 92, 246, 0.22);
color: #a78bfa;
}
/* ---- Controls ---- */
.panel__controls {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-bottom: 1.5rem;
}
.control__header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.control__label {
font-size: 0.8125rem;
font-weight: 500;
color: #a1a1aa;
}
.control__output {
font-size: 0.8125rem;
font-weight: 600;
color: #c4b5fd;
font-variant-numeric: tabular-nums;
min-width: 52px;
text-align: right;
}
.control__range {
width: 100%;
appearance: none;
height: 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.control__range::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
background: #8b5cf6;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(139, 92, 246, 0.35);
transition: transform 0.15s ease;
}
.control__range::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.control__range::-moz-range-thumb {
width: 18px;
height: 18px;
background: #8b5cf6;
border: none;
border-radius: 50%;
cursor: pointer;
}
.control__ticks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 0.625rem;
color: #52525b;
}
/* ---- Buttons ---- */
.panel__actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.btn {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn--preset {
background: rgba(139, 92, 246, 0.15);
color: #c4b5fd;
border: 1px solid rgba(139, 92, 246, 0.25);
}
.btn--preset:hover {
background: rgba(139, 92, 246, 0.25);
}
.btn--reset {
background: rgba(255, 255, 255, 0.05);
color: #a1a1aa;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn--reset:hover {
background: rgba(255, 255, 255, 0.1);
}
/* ---- Status ---- */
.panel__info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
font-size: 0.75rem;
color: #71717a;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #52525b;
flex-shrink: 0;
transition: background 0.3s ease;
}
.status-dot.passing {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
}
.status-dot.partial {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
}
/* ---- Article Content ---- */
.content {
--text-letter-spacing: 0em;
--text-word-spacing: 0em;
--text-line-height: 1.5;
--text-paragraph-spacing: 0em;
}
.article {
max-width: 680px;
}
.article__title {
font-size: 1.75rem;
font-weight: 700;
color: #f4f4f5;
margin-bottom: 1.5rem;
letter-spacing: -0.02em;
line-height: 1.3;
}
.article h2 {
font-size: 1.25rem;
font-weight: 600;
color: #e4e4e7;
margin-top: 2rem;
margin-bottom: 0.75rem;
}
.article p {
font-size: 1rem;
color: #a1a1aa;
margin-bottom: var(--text-paragraph-spacing, 1rem);
letter-spacing: var(--text-letter-spacing, normal);
word-spacing: var(--text-word-spacing, normal);
line-height: var(--text-line-height, 1.5);
}
.article p + p {
margin-top: 0.25rem;
}
.article__list {
list-style: none;
margin: 1rem 0 1.5rem;
padding: 0;
}
.article__list li {
position: relative;
padding-left: 1.5rem;
margin-bottom: 0.5rem;
font-size: 0.9375rem;
color: #a1a1aa;
letter-spacing: var(--text-letter-spacing, normal);
word-spacing: var(--text-word-spacing, normal);
line-height: var(--text-line-height, 1.5);
}
.article__list li::before {
content: "";
position: absolute;
left: 0;
top: 0.55em;
width: 6px;
height: 6px;
border-radius: 50%;
background: #8b5cf6;
}
.article__quote {
border-left: 3px solid #8b5cf6;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
background: rgba(139, 92, 246, 0.06);
border-radius: 0 8px 8px 0;
font-style: italic;
color: #c4b5fd;
font-size: 1rem;
letter-spacing: var(--text-letter-spacing, normal);
word-spacing: var(--text-word-spacing, normal);
line-height: var(--text-line-height, 1.5);
}
.article__quote cite {
display: block;
margin-top: 0.5rem;
font-style: normal;
font-size: 0.8125rem;
color: #71717a;
}(() => {
const STORAGE_KEY = "text-spacing-prefs";
const content = document.getElementById("text-content");
const statusDot = document.getElementById("status-dot");
const statusText = document.getElementById("status-text");
const sliders = {
letterSpacing: {
input: document.getElementById("letter-spacing"),
output: document.getElementById("letter-spacing-output"),
prop: "--text-letter-spacing",
unit: "em",
wcagMin: 0.12,
default: 0,
},
wordSpacing: {
input: document.getElementById("word-spacing"),
output: document.getElementById("word-spacing-output"),
prop: "--text-word-spacing",
unit: "em",
wcagMin: 0.16,
default: 0,
},
lineHeight: {
input: document.getElementById("line-height"),
output: document.getElementById("line-height-output"),
prop: "--text-line-height",
unit: "",
wcagMin: 1.5,
default: 1.5,
},
paragraphSpacing: {
input: document.getElementById("paragraph-spacing"),
output: document.getElementById("paragraph-spacing-output"),
prop: "--text-paragraph-spacing",
unit: "em",
wcagMin: 2.0,
default: 0,
},
};
function updateDisplay() {
let passingCount = 0;
const total = Object.keys(sliders).length;
for (const key of Object.keys(sliders)) {
const s = sliders[key];
const val = parseFloat(s.input.value);
const display = s.unit ? val.toFixed(2) + s.unit : val.toFixed(1);
s.output.textContent = display;
content.style.setProperty(s.prop, val + s.unit);
if (val >= s.wcagMin) passingCount++;
}
// Update status
statusDot.classList.remove("passing", "partial");
if (passingCount === total) {
statusDot.classList.add("passing");
statusText.textContent = "Meets WCAG 1.4.12";
} else if (passingCount > 0) {
statusDot.classList.add("partial");
statusText.textContent = `${passingCount}/${total} criteria met`;
} else {
statusText.textContent = "Default spacing";
}
savePrefs();
}
function savePrefs() {
const prefs = {};
for (const key of Object.keys(sliders)) {
prefs[key] = parseFloat(sliders[key].input.value);
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
}
function loadPrefs() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
const prefs = JSON.parse(saved);
for (const key of Object.keys(prefs)) {
if (sliders[key]) {
sliders[key].input.value = prefs[key];
}
}
} catch {
// ignore
}
}
function applyWCAG() {
for (const key of Object.keys(sliders)) {
sliders[key].input.value = sliders[key].wcagMin;
}
updateDisplay();
}
function resetAll() {
for (const key of Object.keys(sliders)) {
sliders[key].input.value = sliders[key].default;
}
updateDisplay();
}
// Bind events
for (const key of Object.keys(sliders)) {
sliders[key].input.addEventListener("input", updateDisplay);
}
document.getElementById("btn-wcag").addEventListener("click", applyWCAG);
document.getElementById("btn-reset").addEventListener("click", resetAll);
// Initialize
loadPrefs();
updateDisplay();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Text Spacing Control</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="layout">
<!-- Controls Panel -->
<aside class="panel" id="panel">
<div class="panel__header">
<h2 class="panel__title">Text Spacing</h2>
<a href="https://www.w3.org/WAI/WCAG21/Understanding/text-spacing.html" target="_blank" rel="noopener noreferrer" class="panel__badge" title="Read official W3C documentation">
WCAG 1.4.12
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</div>
<div class="panel__controls">
<!-- Letter Spacing -->
<div class="control">
<div class="control__header">
<label class="control__label" for="letter-spacing">Letter Spacing</label>
<output class="control__output" id="letter-spacing-output">0em</output>
</div>
<input type="range" id="letter-spacing" class="control__range" min="0" max="0.24" step="0.01" value="0" />
<div class="control__ticks">
<span>0</span>
<span>0.12em</span>
<span>0.24em</span>
</div>
</div>
<!-- Word Spacing -->
<div class="control">
<div class="control__header">
<label class="control__label" for="word-spacing">Word Spacing</label>
<output class="control__output" id="word-spacing-output">0em</output>
</div>
<input type="range" id="word-spacing" class="control__range" min="0" max="0.32" step="0.01" value="0" />
<div class="control__ticks">
<span>0</span>
<span>0.16em</span>
<span>0.32em</span>
</div>
</div>
<!-- Line Height -->
<div class="control">
<div class="control__header">
<label class="control__label" for="line-height">Line Height</label>
<output class="control__output" id="line-height-output">1.5</output>
</div>
<input type="range" id="line-height" class="control__range" min="1.0" max="3.0" step="0.1" value="1.5" />
<div class="control__ticks">
<span>1.0</span>
<span>1.5</span>
<span>2.0</span>
<span>3.0</span>
</div>
</div>
<!-- Paragraph Spacing -->
<div class="control">
<div class="control__header">
<label class="control__label" for="paragraph-spacing">Paragraph Spacing</label>
<output class="control__output" id="paragraph-spacing-output">0em</output>
</div>
<input type="range" id="paragraph-spacing" class="control__range" min="0" max="3.0" step="0.1" value="0" />
<div class="control__ticks">
<span>0</span>
<span>1em</span>
<span>2em</span>
<span>3em</span>
</div>
</div>
</div>
<div class="panel__actions">
<button class="btn btn--preset" id="btn-wcag">WCAG Minimum</button>
<button class="btn btn--reset" id="btn-reset">Reset</button>
</div>
<div class="panel__info" id="wcag-status">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Default spacing</span>
</div>
</aside>
<!-- Text Content -->
<main class="content" id="text-content">
<article class="article">
<h1 class="article__title">Understanding WCAG 1.4.12 Text Spacing</h1>
<p>
WCAG Success Criterion 1.4.12 requires that no loss of content or functionality occurs when users override text spacing properties. Specifically, content must remain readable and functional when line height is set to at least 1.5 times the font size, paragraph spacing is at least 2 times the font size, letter spacing is at least 0.12 times the font size, and word spacing is at least 0.16 times the font size.
</p>
<h2>Why Text Spacing Matters</h2>
<p>
Many users with cognitive disabilities, dyslexia, or low vision rely on custom text spacing to make content readable. Browser extensions and user stylesheets can override these properties, but content must not break when they do. Fixed-height containers that clip overflowing text, for instance, violate this criterion because increased spacing causes text to overflow and become hidden.
</p>
<p>
This requirement applies to text set in human languages that use a script with a concept of spacing. It addresses the needs of users who require increased spacing between lines, words, and letters. The intent is to ensure that people can override author-specified text spacing to improve their reading experience without losing access to content.
</p>
<h2>Common Violations</h2>
<ul class="article__list">
<li>Containers with fixed heights that clip text when spacing increases</li>
<li>Text overlapping other elements when line-height is modified</li>
<li>Navigation items wrapping incorrectly with increased word-spacing</li>
<li>Tooltips or popovers becoming unreadable with custom spacing</li>
<li>Form labels detaching from their inputs when spacing grows</li>
</ul>
<h2>Best Practices</h2>
<p>
To comply with WCAG 1.4.12, avoid setting fixed heights on text containers. Use relative units like em and rem rather than pixels for spacing-related properties. Test your layouts with the minimum spacing values specified in the criterion. Design flexible containers that can accommodate varying amounts of text, and ensure that interactive elements remain functional regardless of text spacing.
</p>
<blockquote class="article__quote">
"Content can be presented without loss of content or functionality, and without requiring scrolling in two dimensions."
<cite>-- WCAG 2.1, Understanding SC 1.4.12</cite>
</blockquote>
<h2>Testing Your Implementation</h2>
<p>
Use the controls on the left to adjust text spacing in real time. Click the "WCAG Minimum" button to apply the exact minimum values specified by the success criterion. Observe how the text reflows and whether all content remains visible and functional. The status indicator shows whether current settings meet WCAG minimums.
</p>
<p>
A well-built layout should handle these adjustments gracefully, with text reflowing naturally within its containers. If any text becomes clipped, overlaps other elements, or becomes otherwise inaccessible, the implementation needs attention.
</p>
</article>
</main>
</div>
<script src="script.js"></script>
</body>
</html>A floating control panel with range sliders for letter-spacing, word-spacing, line-height, and paragraph spacing that update text in real-time via CSS custom properties. Includes a one-click WCAG 1.4.12 minimum preset and localStorage persistence.