text-wrap Balance & Pretty
A live typography playground contrasting CSS text-wrap balance on headings and pretty on body copy against plain wrapping, with toggles for hanging-punctuation and an initial-letter drop cap so you can watch ragged lines, orphans, and widows resolve in real time.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--measure: 30ch;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 600px at 82% -8%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 520px at 6% 108%, rgba(34, 211, 238, 0.12), transparent 60%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.stage {
max-width: 1080px;
margin: 0 auto;
padding: clamp(20px, 4vw, 52px) clamp(16px, 4vw, 40px) 56px;
}
/* ---------- Masthead ---------- */
.masthead {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
}
.masthead__title {
margin: 0 0 14px;
font-size: clamp(30px, 6vw, 52px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.05;
text-wrap: balance;
}
.grad {
background: linear-gradient(92deg, var(--accent), #c4b5fd);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.grad--2 {
background: linear-gradient(92deg, var(--accent-2), #a5f3fc);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.masthead__sub {
margin: 0;
max-width: 62ch;
color: var(--muted);
font-size: clamp(15px, 2vw, 17px);
text-wrap: pretty;
}
/* ---------- Controls ---------- */
.controls {
display: grid;
gap: 16px;
padding: 18px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: linear-gradient(180deg, var(--surface-2), var(--surface));
box-shadow: 0 22px 50px -30px rgba(0, 0, 0, 0.9);
}
.controls__group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.switch {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, transform 0.1s ease;
}
.switch:hover {
border-color: var(--line-2);
background: rgba(255, 255, 255, 0.05);
}
.switch:active {
transform: translateY(1px);
}
.switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.switch__track {
flex: 0 0 auto;
width: 40px;
height: 24px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--line-2);
transition: background 0.2s ease, border-color 0.2s ease;
position: relative;
}
.switch__thumb {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--muted);
transform: translateY(-50%);
transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s ease;
}
.switch input:checked ~ .switch__track {
background: linear-gradient(92deg, var(--accent), var(--accent-2));
border-color: transparent;
}
.switch input:checked ~ .switch__track .switch__thumb {
transform: translate(16px, -50%);
background: #fff;
}
.switch input:focus-visible ~ .switch__track {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
.switch__label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.switch__label code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.switch__label small {
font-size: 11px;
color: var(--muted);
}
.switch__flag {
margin-left: auto;
align-self: flex-start;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--warn);
border: 1px solid rgba(251, 191, 36, 0.4);
background: rgba(251, 191, 36, 0.1);
padding: 2px 6px;
border-radius: 999px;
}
.switch.is-unsupported {
opacity: 0.62;
cursor: not-allowed;
}
.switch.is-unsupported:hover {
border-color: var(--line);
background: rgba(255, 255, 255, 0.02);
}
/* ---------- Measure slider ---------- */
.controls__measure {
padding-top: 4px;
}
.slider {
display: block;
}
.slider__top {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.slider__top output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
color: var(--accent-2);
font-weight: 500;
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--accent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.12);
}
input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--accent);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
}
input[type="range"]:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 4px;
}
/* ---------- Status ---------- */
.status {
margin: 16px 0 22px;
font-size: 13.5px;
color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.16);
flex: 0 0 auto;
}
/* ---------- Columns ---------- */
.columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.col {
position: relative;
padding: 22px 22px 26px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--surface);
overflow: hidden;
}
.col--on {
border-color: rgba(139, 92, 246, 0.35);
box-shadow: 0 0 0 1px rgba(139, 92, 246, 0.12) inset,
0 24px 60px -34px rgba(139, 92, 246, 0.55);
}
.col__tag {
display: inline-block;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
letter-spacing: 0.02em;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 10px;
margin-bottom: 16px;
}
.col--on .col__tag {
color: #e9d9ff;
border-color: rgba(139, 92, 246, 0.5);
background: rgba(139, 92, 246, 0.12);
}
.headline {
margin: 0 0 12px;
max-width: var(--measure);
font-size: clamp(20px, 3.2vw, 26px);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.16;
text-wrap: normal;
}
.col--on .headline {
text-wrap: balance;
}
.byline {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
margin-bottom: 16px;
}
.byline .dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
flex: 0 0 auto;
}
.prose {
max-width: var(--measure);
font-family: "Source Serif 4", Georgia, serif;
font-size: 16px;
line-height: 1.62;
color: #d7d9e4;
text-wrap: normal;
}
.col--on .prose {
text-wrap: pretty;
}
.prose p {
margin: 0 0 1em;
}
.prose p:last-child {
margin-bottom: 0;
}
/* Hanging punctuation — toggled per column */
.prose.is-hang {
hanging-punctuation: first last;
}
/* Drop cap — toggled per column */
.prose.is-dropcap p:first-of-type::first-letter {
-webkit-initial-letter: 3;
initial-letter: 3;
font-family: "Source Serif 4", Georgia, serif;
font-weight: 600;
color: var(--accent);
margin-right: 0.08em;
padding-right: 0.04em;
}
/* Fallback drop cap when initial-letter is unsupported */
@supports not (initial-letter: 3) {
.prose.is-dropcap p:first-of-type::first-letter {
font-size: 3.1em;
line-height: 0.86;
float: left;
padding: 0.02em 0.08em 0 0;
color: var(--accent);
font-weight: 600;
}
}
/* ---------- Legend ---------- */
.legend {
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--muted);
}
.legend__item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.chip {
width: 12px;
height: 12px;
border-radius: 3px;
flex: 0 0 auto;
}
.chip--bal {
background: var(--accent);
}
.chip--orphan {
background: var(--accent-2);
}
.chip--hang {
background: var(--ok);
}
/* ---------- Responsive ---------- */
@media (max-width: 780px) {
.columns {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.stage {
padding: 22px 16px 44px;
}
.controls__group {
grid-template-columns: 1fr;
}
.headline {
max-width: none;
}
.prose {
max-width: none;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
}
}(function () {
"use strict";
// Shared article body. First paragraph opens with a quote so hanging-punctuation
// and the drop cap both have something to act on. Deliberately worded to produce
// a short "orphan" last line unless text-wrap: pretty intervenes.
var BODY = [
"“The line breaks nobody notices are the ones doing the most work,” says every " +
"typographer who has ever agonized over a single ragged edge. For decades the browser " +
"greedily filled each line and moved on, occasionally stranding one lonely word.",
"text-wrap: pretty asks the engine to look ahead across the whole paragraph, trading a " +
"little speed for a smoother right edge and, crucially, no orphaned final word dangling " +
"on a line of its own.",
"For headings, text-wrap: balance evens out the length of every line so a two-line title " +
"never leaves a single small word marooned below a very long one. It is quietly the most " +
"flattering line of CSS you can add to a page."
];
var proseOff = document.getElementById("prose-off");
var proseOn = document.getElementById("prose-on");
function render(el) {
el.innerHTML = BODY.map(function (t) {
return "<p>" + t + "</p>";
}).join("");
}
render(proseOff);
render(proseOn);
// ---- Feature detection -------------------------------------------------
function supported(prop, value) {
try {
return window.CSS && CSS.supports(prop, value);
} catch (e) {
return false;
}
}
var FEATURES = {
"tw-balance": supported("text-wrap", "balance"),
"tw-pretty": supported("text-wrap", "pretty"),
"hang": supported("hanging-punctuation", "first"),
// Safari ships the prefixed form; accept either.
"dropcap":
supported("initial-letter", "3") || supported("-webkit-initial-letter", "3")
};
var els = {
balance: document.getElementById("tw-balance"),
pretty: document.getElementById("tw-pretty"),
hang: document.getElementById("hang"),
dropcap: document.getElementById("dropcap")
};
var status = document.getElementById("status");
function markUnsupported(inputId) {
var input = document.getElementById(inputId);
if (!input) return;
var wrapper = input.closest(".switch");
var flag = wrapper ? wrapper.querySelector("[data-flag]") : null;
if (!FEATURES[inputId]) {
input.checked = false;
input.disabled = true;
if (wrapper) wrapper.classList.add("is-unsupported");
if (flag) flag.hidden = false;
}
}
Object.keys(FEATURES).forEach(markUnsupported);
// ---- Apply state -------------------------------------------------------
var onHeadline = proseOn.parentElement.querySelector(".headline");
function apply() {
// Heading balance (ON column). When unsupported, class simply has no effect.
onHeadline.style.textWrap = els.balance.checked ? "balance" : "normal";
// Body pretty (ON column).
proseOn.style.textWrap = els.pretty.checked ? "pretty" : "normal";
// Hanging punctuation + drop cap apply to BOTH columns so the effect is
// isolated from the balance/pretty comparison.
proseOff.classList.toggle("is-hang", els.hang.checked);
proseOn.classList.toggle("is-hang", els.hang.checked);
proseOff.classList.toggle("is-dropcap", els.dropcap.checked);
proseOn.classList.toggle("is-dropcap", els.dropcap.checked);
announce();
}
function announce() {
var on = [];
if (els.balance.checked) on.push("balance");
if (els.pretty.checked) on.push("pretty");
if (els.hang.checked) on.push("hanging punctuation");
if (els.dropcap.checked) on.push("drop cap");
var msg;
if (on.length === 0) {
msg = "All enhancements off — both columns now use plain, greedy line wrapping.";
} else {
var list =
on.length === 1
? on[0]
: on.slice(0, -1).join(", ") + " and " + on[on.length - 1];
msg =
"Active: " +
list +
". The right column shows the difference against plain wrapping on the left.";
}
status.textContent = msg;
}
["balance", "pretty", "hang", "dropcap"].forEach(function (k) {
els[k].addEventListener("change", apply);
});
// ---- Measure slider ----------------------------------------------------
var measure = document.getElementById("measure");
var measureOut = document.getElementById("measure-out");
function setMeasure() {
var ch = measure.value + "ch";
document.documentElement.style.setProperty("--measure", ch);
measureOut.textContent = ch;
}
measure.addEventListener("input", setMeasure);
// ---- Init --------------------------------------------------------------
setMeasure();
apply();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>text-wrap Balance & Pretty</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:opsz,[email protected],400;8..60,600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<header class="masthead">
<p class="eyebrow">CSS Text & Inline modules</p>
<h1 class="masthead__title">text-wrap: <span class="grad">balance</span> & <span class="grad grad--2">pretty</span></h1>
<p class="masthead__sub">
The same headline and article, rendered twice. Flip the switches and watch the
browser's line breaker even out ragged headings, banish orphaned last words, and
drop a serif cap into the margin — all in CSS, no layout math.
</p>
</header>
<section class="controls" aria-label="Typography controls">
<div class="controls__group" role="group" aria-label="Wrapping features">
<label class="switch" data-prop="text-wrap-heading">
<input type="checkbox" id="tw-balance" checked />
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">
<code>text-wrap: balance</code>
<small>headings</small>
</span>
<span class="switch__flag" data-flag hidden>unsupported</span>
</label>
<label class="switch" data-prop="text-wrap-body">
<input type="checkbox" id="tw-pretty" checked />
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">
<code>text-wrap: pretty</code>
<small>body copy</small>
</span>
<span class="switch__flag" data-flag hidden>unsupported</span>
</label>
<label class="switch" data-prop="hanging-punctuation">
<input type="checkbox" id="hang" />
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">
<code>hanging-punctuation</code>
<small>optical margin</small>
</span>
<span class="switch__flag" data-flag hidden>unsupported</span>
</label>
<label class="switch" data-prop="initial-letter">
<input type="checkbox" id="dropcap" />
<span class="switch__track"><span class="switch__thumb"></span></span>
<span class="switch__label">
<code>initial-letter: 3</code>
<small>drop cap</small>
</span>
<span class="switch__flag" data-flag hidden>unsupported</span>
</label>
</div>
<div class="controls__measure">
<label class="slider" for="measure">
<span class="slider__top">
<span>Column width</span>
<output id="measure-out" for="measure">30ch</output>
</span>
<input type="range" id="measure" min="18" max="48" step="1" value="30" />
</label>
</div>
</section>
<p class="status" id="status" role="status" aria-live="polite">
Balance and Pretty are on. Compare the two columns below.
</p>
<div class="columns">
<!-- OFF column: plain wrapping baseline -->
<article class="col col--off" aria-label="Default wrapping (no text-wrap)">
<div class="col__tag">text-wrap: normal</div>
<h2 class="headline">Great typography is invisible until the moment it isn't</h2>
<div class="byline">
<span class="dot"></span>
<span>The Line Break Review</span>
<span aria-hidden="true">·</span>
<span>7 min read</span>
</div>
<div class="prose" id="prose-off"></div>
</article>
<!-- ON column: enhanced wrapping -->
<article class="col col--on" aria-label="Enhanced wrapping (text-wrap balance and pretty)">
<div class="col__tag">balance + pretty</div>
<h2 class="headline">Great typography is invisible until the moment it isn't</h2>
<div class="byline">
<span class="dot"></span>
<span>The Line Break Review</span>
<span aria-hidden="true">·</span>
<span>7 min read</span>
</div>
<div class="prose" id="prose-on"></div>
</article>
</div>
<footer class="legend">
<span class="legend__item"><i class="chip chip--bal"></i> balanced heading lines</span>
<span class="legend__item"><i class="chip chip--orphan"></i> orphan / widow removed</span>
<span class="legend__item"><i class="chip chip--hang"></i> punctuation in the margin</span>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>A side-by-side reading room for modern CSS text-wrapping. The left column renders a headline and article with the new algorithms switched off; the right mirrors the exact same copy with text-wrap: balance evening out the heading’s line lengths and text-wrap: pretty cleaning up the paragraph’s ragged edge and lonely last-line words. A live toggle row lets you flip each property independently so the difference is unmistakable, not theoretical.
Beyond wrapping, the demo layers two more polish features from the CSS Text and Inline modules: hanging-punctuation: first last pulls opening quotes and terminal periods into the margin for a crisper optical edge, and initial-letter: 3 sinks a three-line drop cap at the start of the body. Each control is a real checkbox with visible focus, keyboard support, and an aria-live status line announcing what changed, while a measured-width slider re-flows both columns so you can hunt for orphans and widows at any column width.
It demonstrates the text-wrap shorthand (balance / pretty) plus hanging-punctuation and initial-letter, all feature-detected with @supports and CSS.supports(). Where a browser lacks a property, the toggle is labelled unsupported and the layout degrades gracefully to normal wrapping — no JavaScript layout math, just the browser’s own line breaker doing the work you can now see.