Lesesatz mit Zeilenlänge 55–70ch und text-wrap balance/pretty
Lauftext auf 64ch, 17–19px und 1.6 Zeilenhöhe begrenzt; Headlines mit text-wrap: balance, Absätze mit text-wrap: pretty. Die Demo misst live mit.
Lesesatz für lange Texte: Der Lauftext läuft höchstens 55–70ch breit, in 17–19px mit einer Zeilenhöhe von 1.5–1.7. Headlines und Bildunterschriften bekommen text-wrap: balance, Absätze text-wrap: pretty.
Bei 55–70 Zeichen findet das Auge den Anfang der nächsten Zeile mühelos. Längere Zeilen ermüden, kürzere zerhacken den Satz. balance verteilt die Wörter einer Headline auf gleich lange Zeilen, statt ein einzelnes Wort in die letzte Zeile zu schieben. pretty sorgt in Absätzen dafür, dass die letzte Zeile nicht aus einem einzelnen kurzen Wort besteht. Der Text wirkt ruhig und gesetzt, ohne dass jemand von Hand umbrechen muss.
Wann einsetzen
Überall, wo gelesen wird: Artikel, Journal, Fallstudien, Produktbeschreibungen, Über-uns-Seiten. balance für kurze Blöcke bis etwa 4–6 Zeilen wie Headlines, Vorspann, Bildunterschriften und Teaser. pretty für alle Absätze. Gehört in jeden Reset bzw. jedes Basis-Stylesheet.
Wann nicht
Nicht für Code, Tabellen, Formulare oder Daten, die exakt so umbrechen sollen. balance nicht auf lange Absätze, dort ist es wirkungslos und kostet Rechenzeit. Kein starres Maß für Bildschirmtexte wie Navigation oder Labels.
Varianten
- Roh: Text läuft über die volle Spaltenbreite, normaler Umbruch
- Nur Maß: max-width 64ch, aber normaler Umbruch (text-wrap: wrap)
- Lesesatz: max-width 64ch plus balance für Headline, Vorspann und Bildunterschrift, pretty für Absätze
Umsetzung
Lauftext-Container mit max-width in ch (55–70ch, hier 64ch), font-size per clamp() zwischen 17 und 19px, line-height 1.5–1.7 (hier 1.6). Basisregeln: h1, h2, h3, figcaption { text-wrap: balance } und p { text-wrap: pretty }. Headlines bekommen eine eigene, schmalere max-width in ch. Browser: balance ab Chrome 114, Safari 17.5, Firefox 124. pretty ab Chrome 117 und Safari 26; Firefox bricht normal um, ohne Schaden.
Robust umsetzen
Telefon. Auf dem Telefon ist die Spalte schmaler als das Maß, dann bestimmt der Bildschirm die Zeilenlänge (rund 35–45 Zeichen). balance und pretty wirken hier am stärksten, weil kurze Zeilen schnell einzelne Wörter abhängen. Für lange deutsche Komposita hyphens: auto mit lang="de" setzen.
Ohne Bewegung. Nicht relevant, das Muster hat keine Bewegung.
Barrierefrei. Ein begrenztes Maß und großzügige Zeilenhöhe helfen allen Lesenden, besonders bei Leseschwäche. Maß in ch bzw. rem angeben, damit es mit der Schriftvergrößerung mitwächst. text-wrap ändert nur den Umbruch, nicht den Text: Screenreader und Kopieren bleiben unberührt. Keine manuellen <br> für den Umbruch.
Code
<!DOCTYPE html>
<!--
Lesesatz mit Zeilenlänge 55–70ch und text-wrap balance/pretty
Fundus · Typo-Techniken · https://cecodesign.de/lab/fundus/typo-techniken/lesesatz-text-wrap/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lesesatz mit Zeilenlänge 55–70ch und text-wrap balance/pretty · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Source Serif 4'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/source-serif-4@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
--papier: #f1ede4; --tinte: #1c1b18; --leise: rgba(28, 27, 24, .64); --linie: rgba(28, 27, 24, .14);
--akzent: #2b746d; /* Petrol, passend zum Radcafé-Zeichen */
--markiert: rgba(214, 92, 52, .26); /* kurze Schlusszeile */
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--dauer: 1.2s;
--ui: 150ms cubic-bezier(0.16, 1, 0.3, 1);
/* Die Lesesatz-Token */
--mass: 64ch;
--schrift: clamp(17px, 16px + .25vw, 19px);
--zeile: 1.6;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(16px, 3.2vw, 44px) clamp(16px, 3.6vw, 52px) 96px; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* ——— Leiste ——— */
.leiste { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px 24px; padding-bottom: clamp(16px, 2.2vw, 26px); }
.marke { display: flex; gap: 14px; align-items: baseline; }
.marke b { font: 600 15px/1 'Instrument Sans', sans-serif; letter-spacing: -.01em; text-transform: none; color: var(--tinte); }
.umschalter { display: flex; gap: 2px; border: 1px solid var(--linie); border-radius: 999px; padding: 3px; }
.umschalter button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 0; border-radius: 999px; padding: 7px 13px; cursor: pointer; transition: color var(--ui), background-color var(--ui); }
.umschalter button:hover { color: var(--tinte); }
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.umschalter button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
/* ——— Seite ——— */
.seite { display: grid; grid-template-columns: minmax(150px, 180px) minmax(0, 1fr); gap: 0 clamp(28px, 4.4vw, 72px); padding-top: clamp(22px, 3vw, 40px); }
.rand { display: grid; align-content: start; gap: 34px; padding-top: 4px; }
.rand dl { display: grid; gap: 14px; }
.rand dd { color: var(--tinte); margin-top: 3px; }
.spalte { min-width: 0; }
/* Messleiste: live gemessene Werte des Lauftexts */
.werte { display: grid; grid-template-columns: repeat(6, auto); justify-content: start; border-block: 1px solid var(--linie); margin-bottom: clamp(30px, 4.4vw, 60px); }
.werte > div { padding: 11px clamp(14px, 1.8vw, 26px) 12px; border-left: 1px solid var(--linie); min-width: 0; }
.werte > div:first-child { padding-left: 0; border-left: 0; }
.werte dd { color: var(--tinte); font-variant-numeric: tabular-nums; margin-top: 4px; white-space: nowrap; transition: color var(--ui); }
.werte dd.gut { color: var(--akzent); }
.werte dd.schlecht { color: #b4512f; }
.rubrik { color: var(--akzent); margin-bottom: 18px; }
h1 { font: 400 clamp(42px, 5.4vw, 84px)/1 'Instrument Serif', Georgia, serif; letter-spacing: -.018em; max-width: 18ch; }
h1 em { font-style: italic; color: var(--akzent); }
.vorspann { font-size: clamp(19px, 1.55vw, 23px); line-height: 1.4; letter-spacing: -.008em; max-width: 40ch; margin-top: clamp(18px, 2.2vw, 28px); color: rgba(28, 27, 24, .82); }
/* Lineal: ganze Spaltenbreite als Spur, das Maß als Strecke darauf */
.lineal { position: relative; height: 22px; margin: clamp(26px, 3.4vw, 44px) 0 clamp(16px, 2vw, 24px); }
.lineal::before { content: ""; position: absolute; inset: 50% 0 auto; border-top: 1px dashed var(--linie); }
.lineal b { position: absolute; inset: 0 auto 0 0; width: 100%; max-width: var(--breite); transition: max-width var(--dauer) var(--kurve); }
.lineal b::before { content: ""; position: absolute; inset: 50% 0 auto; border-top: 1px solid var(--tinte); opacity: .5; }
.lineal b::after { content: ""; position: absolute; inset: 6px 0; border-inline: 1px solid var(--tinte); opacity: .6; }
.lineal span { position: absolute; left: 0; top: 50%; translate: 0 -50%; margin-left: 12px; padding: 0 8px; background: var(--papier); color: var(--tinte); white-space: nowrap; font-weight: 500; font-variant-numeric: tabular-nums; }
.lineal span i { font-style: normal; color: var(--leise); }
/* Lauftext */
.lauftext { max-width: var(--breite); font: 400 var(--schrift)/var(--zeile) 'Source Serif 4', Georgia, serif; font-optical-sizing: auto; transition: max-width var(--dauer) var(--kurve); }
.lauftext p + p { text-indent: 1.4em; }
.lauftext figure { margin: 1.8em 0; }
.lauftext figure img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 2px; }
.lauftext figcaption { font: 400 14px/1.45 'Instrument Sans', sans-serif; color: var(--leise); margin-top: 10px; max-width: 46ch; }
.lauftext figure + p { text-indent: 0; }
::highlight(schluss) { background-color: var(--markiert); }
/* ——— Die drei Satzarten ——— */
.seite { --breite: 100%; }
[data-satz="mass"], [data-satz="lesesatz"] { --breite: var(--mass); }
[data-satz="roh"] :is(h1, .vorspann, figcaption) { max-width: none; }
[data-satz="lesesatz"] :is(h1, .vorspann, figcaption) { text-wrap: balance; }
[data-satz="lesesatz"] .lauftext p { text-wrap: pretty; }
/* Zeilenprofil (Randspalte) */
.profil-block { display: grid; gap: 12px; }
.profil { display: grid; gap: 4px; }
.profil i { display: block; height: 4px; border-radius: 2px; background: var(--tinte); opacity: .72; width: 0; transition: width .6s var(--kurve); }
.profil i.kurz { background: #d65c34; opacity: 1; }
.legende { display: flex; align-items: baseline; gap: 8px; text-transform: none; letter-spacing: .02em; }
.legende::before { content: ""; flex: none; width: 12px; height: 9px; background: var(--markiert); }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; padding-bottom: 0; }
.vorschau .seite { grid-template-columns: minmax(0, 1fr); }
.vorschau .rand, .vorschau .marke span, .vorschau .zusatz { display: none; }
.vorschau .werte { grid-template-columns: repeat(4, auto); margin-bottom: 30px; }
.vorschau h1 { font-size: 62px; }
@media (max-width: 1100px) {
.seite { grid-template-columns: minmax(0, 1fr); }
.rand, .werte .zusatz { display: none; }
.werte { grid-template-columns: repeat(4, auto); }
}
@media (max-width: 640px) {
.marke span { display: none; }
.werte { grid-template-columns: 1fr 1fr; margin-bottom: 30px; }
.werte > div:nth-child(n) { padding: 10px 0 11px; border-left: 0; }
.werte > div:nth-child(even) { padding-left: 16px; border-left: 1px solid var(--linie); }
.werte > div:nth-child(n+3) { border-top: 1px solid var(--linie); }
.lauftext { hyphens: auto; }
}
@media (hover: none) { .umschalter button { padding: 11px 14px; } }
.reduziert .lineal b, .reduziert .lauftext, .reduziert .profil i { transition: none !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="leiste">
<p class="marke mono"><b>Studio Feldmark</b><span>Journal · Nr. 14</span></p>
<div class="umschalter mono" role="group" aria-label="Satzart">
<button type="button" data-satz-knopf="roh" aria-pressed="false">Roh</button>
<button type="button" data-satz-knopf="mass" aria-pressed="false">Nur Maß</button>
<button type="button" data-satz-knopf="lesesatz" aria-pressed="true">Lesesatz</button>
</div>
</header>
<div class="seite" data-satz="lesesatz">
<aside class="rand mono" aria-label="Zum Artikel">
<dl>
<div><dt>Ausgabe</dt><dd>Oktober 2026</dd></div>
<div><dt>Text</dt><dd>Lene Hartwig</dd></div>
<div><dt>Lesezeit</dt><dd>6 Minuten</dd></div>
<div><dt>Projekt</dt><dd>Eselsohr, Buchhandlung</dd></div>
</dl>
<div class="profil-block" aria-hidden="true">
<p>Zeilenprofil</p>
<div class="profil" data-profil></div>
<p class="legende">Schlusszeile unter 20 % der Breite</p>
</div>
</aside>
<article class="spalte">
<dl class="werte mono" aria-label="Verwendete Werte, live gemessen">
<div><dt>Maß</dt><dd data-w="mass">64ch</dd></div>
<div><dt>Zeichen/Zeile</dt><dd data-w="zeichen">–</dd></div>
<div><dt>Kurze Schlusszeilen</dt><dd data-w="schluss">–</dd></div>
<div><dt>text-wrap h1 · p</dt><dd data-w="wrap">balance · pretty</dd></div>
<div class="zusatz"><dt>Spanne</dt><dd data-w="spanne">–</dd></div>
<div class="zusatz"><dt>Schrift</dt><dd data-w="schrift">18px / 1.6</dd></div>
</dl>
<div class="kopfgruppe">
<p class="rubrik mono">Werkstattbericht · Erscheinungsbild</p>
<h1>Ein Blatt, eine Ecke und eine <em>Kursive,</em> die nach Widmung aussieht</h1>
<p class="vorspann">Wie aus einem umgeknickten Seitenrand das Zeichen für eine Buchhandlung wurde, die seit 1987 Bücher verkauft und jetzt auch Kaffee.</p>
</div>
<div class="lineal mono" aria-hidden="true"><b></b><span data-lineal>64ch</span></div>
<div class="lauftext">
<p>Am Anfang stand ein Satz von Almut, der Inhaberin: Hier soll man ein Buch aufschlagen dürfen, bevor man es kauft. Kein Laden, in dem man nur kurz nach einem Titel fragt, sondern ein Ort mit Sesseln, langen Tischen und einer Espressomaschine direkt neben der Kasse. Für uns war damit klar, dass das Zeichen beides tragen muss, ohne nach Kompromiss auszusehen.</p>
<p>Die ersten Skizzen zeigten Bücherstapel, Tassen und Leselampen, sauber gezeichnet und völlig austauschbar. Erst als wir aufhörten, Gegenstände zu kombinieren, kam die Idee mit der einen umgeknickten Ecke. So markiert man eine Seite, zu der man zurückwill. Wer je ein Buch verliehen hat, versteht das Zeichen sofort.</p>
<figure>
<img src="bilder/marke-eselsohr.jpg" alt="Zeichen der Buchhandlung Eselsohr: ein Blatt mit umgeknickter rosa Ecke neben der Wortmarke in heller Kursive auf Anthrazit" width="1200" height="800">
<figcaption>Das fertige Zeichen in Kreide auf Anthrazit. Nur die umgeknickte Ecke ist rosa, sonst kommt die Farbe nirgends vor.</figcaption>
</figure>
<p>Für die Wortmarke haben wir eine schmale Kursive gewählt, die nach einer Widmung im Vorsatzblatt aussieht. Die Unterzeile steht in weit gesperrten Versalien darunter, eine kleine Verbeugung vor den Prägungen alter Buchrücken. Auf der Papiertüte, dem Stempel für die Kaffeekarte und dem großen Schaufenster funktioniert das gleiche Bild, nur in anderer Größe.</p>
<p>Seit dem Sommer hängt das Schild über der Tür. Almut erzählt, dass Stammgäste Lesezeichen aus Tütenresten falten, während an der Theke ihr Kaffee durchläuft. Mehr kann man sich von einem Zeichen kaum wünschen.</p>
</div>
</article>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const seite = document.querySelector('.seite');
const lauftext = document.querySelector('.lauftext');
const knoepfe = [...document.querySelectorAll('[data-satz-knopf]')];
const w = k => document.querySelector(`[data-w="${k}"]`);
const lineal = document.querySelector('[data-lineal]');
const profil = document.querySelector('[data-profil]');
const sonde = document.createElement('span');
sonde.setAttribute('aria-hidden', 'true');
sonde.style.cssText = 'position:fixed;visibility:hidden;width:var(--mass);height:0;left:0;top:0';
lauftext.append(sonde);
const geprueft = [document.querySelector('h1'), document.querySelector('.vorspann'), ...lauftext.querySelectorAll('p, figcaption')];
const KURZ = 0.2; // Schlusszeile kürzer als 20 % der Blockbreite gilt als „kurz“
const NAMEN = { roh: 'Roh', mass: 'Nur Maß', lesesatz: 'Lesesatz' };
let satz = 'lesesatz';
// Zeilen eines Blocks ermitteln: jedes Wort per Range vermessen, nach Oberkante gruppieren
function zeilen(el) {
const ergebnis = [];
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const r = document.createRange();
let n;
while ((n = walker.nextNode())) {
const re = /\S+/g; let m;
while ((m = re.exec(n.data))) {
r.setStart(n, m.index); r.setEnd(n, m.index + m[0].length);
const rects = r.getClientRects();
if (!rects.length) continue;
const top = Math.round(rects[0].top), letzte = rects[rects.length - 1];
let z = ergebnis[ergebnis.length - 1];
if (!z || Math.abs(z.top - top) > 4) { z = { top, links: rects[0].left, rechts: letzte.right, zeichen: 0, woerter: [] }; ergebnis.push(z); }
z.rechts = Math.max(z.rechts, letzte.right);
z.zeichen += m[0].length + (z.woerter.length ? 1 : 0);
z.woerter.push([n, m.index, m.index + m[0].length]);
}
}
return ergebnis;
}
function messen() {
const markiert = [];
let kurz = 0, laengen = [];
geprueft.forEach(el => {
const zs = zeilen(el);
if (!zs.length) return;
const box = el.getBoundingClientRect();
if (el.closest('.lauftext') && el.tagName === 'P') {
// Einzug der Folgeabsätze nicht mitzählen, letzte Zeile nicht ins Mittel
zs.slice(0, -1).forEach(z => laengen.push(z.zeichen));
}
const letzte = zs[zs.length - 1];
if (zs.length > 1 && (letzte.rechts - box.left) / box.width < KURZ) {
kurz++;
const [n0, a0] = letzte.woerter[0], [n1, , b1] = letzte.woerter[letzte.woerter.length - 1];
const rr = new Range(); rr.setStart(n0, a0); rr.setEnd(n1, b1); markiert.push(rr);
}
});
if (window.CSS && CSS.highlights) CSS.highlights.set('schluss', new Highlight(...markiert));
const breite = Math.round(lauftext.getBoundingClientRect().width);
const istMass = satz !== 'roh';
// Greift das Maß oder begrenzt der Bildschirm? Sonde mit 64ch in der Lauftextschrift
const massPx = Math.round(sonde.getBoundingClientRect().width);
const schirm = breite < massPx - 1; // schmaler Bildschirm: 64ch passen gar nicht hinein
const schnitt = laengen.length ? Math.round(laengen.reduce((a, b) => a + b, 0) / laengen.length) : 0;
const [unten, oben] = schirm ? [32, 75] : [55, 75];
const zeichenEl = w('zeichen');
zeichenEl.textContent = 'Ø ' + schnitt;
zeichenEl.className = schnitt >= unten && schnitt <= oben ? 'gut' : 'schlecht';
w('spanne').textContent = laengen.length ? Math.min(...laengen) + '–' + Math.max(...laengen) : '–';
const s = w('schluss'); s.textContent = String(kurz); s.className = kurz ? 'schlecht' : 'gut';
const schrift = parseFloat(getComputedStyle(lauftext).fontSize);
w('schrift').textContent = `${Math.round(schrift * 10) / 10}px / 1.6`;
const mass = !istMass ? '100%' : schirm ? '64ch → 100%' : '64ch';
w('mass').textContent = mass;
lineal.innerHTML = mass + ` <i>· ${breite}px · Ø ${schnitt} Zeichen</i>`;
// Zeilenprofil des ersten Absatzes
const erste = zeilen(lauftext.querySelector('p'));
const max = 110;
profil.innerHTML = erste.map((z, i) => `<i style="width:${Math.min(100, z.zeichen / max * 100)}%"${i === erste.length - 1 && erste.length > 1 && z.zeichen / (schnitt || 1) < KURZ ? ' class="kurz"' : ''}></i>`).join('');
}
// Während der Breitenänderung laufend nachmessen
let messUhr = null;
function nachmessen(dauer) {
cancelAnimationFrame(messUhr);
const ende = performance.now() + dauer;
let zuletzt = 0;
const schritt = t => {
if (t - zuletzt > 90) { messen(); zuletzt = t; }
if (t < ende) messUhr = requestAnimationFrame(schritt); else messen();
};
messUhr = requestAnimationFrame(schritt);
}
function setzen(neu) {
satz = neu;
seite.dataset.satz = neu;
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.satzKnopf === neu)));
const lese = neu === 'lesesatz';
w('wrap').textContent = lese ? 'balance · pretty' : 'wrap · wrap';
w('wrap').className = lese ? 'gut' : '';
if (reduziert()) messen(); else nachmessen(1300);
}
knoepfe.forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.satzKnopf); }));
addEventListener('resize', () => messen());
document.fonts.ready.then(() => setzen(satz));
// Vorführung: Lesesatz → Roh → Nur Maß, in Schleife
const FOLGE = [['lesesatz', 3800], ['roh', 2600], ['mass', 2600]];
let schleife = null, schrittNr = 0;
function takt() {
const [art, dauer] = FOLGE[schrittNr % FOLGE.length];
setzen(art);
schrittNr++;
schleife = setTimeout(takt, dauer);
}
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); schrittNr = 0; takt(); }
function pause() { clearTimeout(schleife); schleife = null; html.classList.remove('vorfuehrung'); }
function weiter() { if (!schleife) { html.classList.add('vorfuehrung'); takt(); } }
function neustart() { pause(); setzen('lesesatz'); }
})();
</script>
</body>
</html>