Optisch exakte Textabstände mit text-box-trim / text-box-edge
Ein Token text-box: trim-both cap alphabetic schneidet den unsichtbaren Leerraum über der Versalhöhe und unter der Grundlinie ab: Abstände stimmen sichtbar, Labels sitzen mittig.
Jede Textzeile hat über der Versalhöhe und unter der Grundlinie unsichtbaren Leerraum: den halben Durchschuss plus die Reserve für Ober- und Unterlängen. text-box-trim schneidet diesen Raum an der ersten und letzten Zeile ab (trim-start, trim-end, trim-both). text-box-edge legt fest, wo geschnitten wird, etwa cap alphabetic (oben an der Versalhöhe, unten an der Grundlinie) oder ex alphabetic (oben an der x-Höhe). Kurzform: text-box: trim-both cap alphabetic. So entsprechen Abstände zwischen Text und Kante genau den Token-Werten, Buttons sind optisch zentriert und Raster sitzen auf der Versalhöhe. Browserstand laut caniuse (31.10.2026): Chrome 133+, Safari 18.2+, Firefox 154+.
Figma kennt Text-Box-Trim seit Jahren, der Browser bisher nicht. Mit text-box stimmen Abstände 1:1 mit dem Entwurf überein. Labels in Buttons und Pills sitzen mittig, große Headlines schließen bündig an Linien und Bildkanten an. Die Seite wirkt gesetzt statt ungefähr.
Wann einsetzen
Für Headlines, Buttons, Badges und Pills, Kartentitel, Wortmarken im Hero und Swiss-Raster mit sichtbaren Linien. In Design-Systemen als globales Token (z. B. --text-trim).
Wann nicht
Bei mehrzeiligem Text mit Hintergrund-Markierung, weil die Markierung beschnitten wird (dort text-box: normal setzen). Bei Schriften mit fehlerhaften Metriken nicht ungeprüft einsetzen.
Varianten
- Globales Token: --global-style-text-box-trim: trim-both cap alphabetic (Piccalilli)
- In einer Feature-Query: @supports (text-box: trim-both cap alphabetic) (Linear)
- Nur Buttons: display: inline-flex mit text-box: trim-both cap alphabetic (Stripe)
- Labels: text-box: trim-both text alphabetic (WIRED)
- Nur das Ende trimmen: text-box-trim: trim-end für Kartentexte (Works in Progress)
- Ausnahme für Markierungen: text-box: normal auf Highlight-Spans (zachleat)
Umsetzung
Global: h1, h2, h3, .btn span, .badge { text-box: trim-both cap alphabetic; }. Als Token: :root { --text-trim: trim-both cap alphabetic; } und in Komponenten text-box: var(--text-trim). Bei Bedarf in @supports kapseln, damit ältere Browser ihre bisherigen, ausgeglichenen Abstände behalten. text-box wirkt auf Blockcontainer und Inline-Boxen, bei Buttons daher auf das Label (span mit display: block) setzen. padding-block symmetrisch wählen (z. B. .75em), ein Ausgleich ist nicht mehr nötig. Für Schriften mit Kleinbuchstaben-Look text-box-edge: ex alphabetic testen. Markierungen bekommen text-box: normal. Bei Fließtext betrifft der Schnitt nur die erste und letzte Zeile, line-height bleibt unverändert.
Robust umsetzen
Telefon. Wirkt identisch. Besonders wertvoll bei kleinen Pills und Buttons, wo 2 bis 3 px Versatz sofort auffallen.
Ohne Bewegung. Nicht relevant, die Technik bewegt nichts.
Barrierefrei. Ändert weder Schriftgröße noch Zeilenabstand. Bei Zoom und Text-Spacing-Overrides (WCAG 1.4.12) prüfen, dass nichts abgeschnitten wird: kein overflow: hidden auf getrimmten Labels.
Code
<!DOCTYPE html>
<!--
Optisch exakte Textabstände mit text-box-trim / text-box-edge
Fundus · Typo-Techniken · https://cecodesign.de/lab/fundus/typo-techniken/text-box-trim/
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>Optisch exakte Textabstände mit text-box-trim / text-box-edge · 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'; 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: '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: #f1eee7; --blatt: #faf8f3; --tinte: #191917; --tinte-leise: rgba(25, 25, 23, .64); --linie: rgba(25, 25, 23, .16);
--mess: #e2462c; --mess-text: #be3a22; /* Messrot für Schrift: 4,8:1 auf Papier, Weiß darauf 5,5:1 */ --mess-flaeche: rgba(226, 70, 44, .09);
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--gleiten: 900ms;
--ui: 150ms cubic-bezier(0.16, 1, 0.3, 1);
/* Das eigentliche Muster: ein Token, das alle Text-Komponenten benutzen */
--text-trim: trim-both cap alphabetic;
--raum: 32px;
}
* { 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.4vw, 48px); }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* ——— Werkzeugleiste ——— */
.leiste { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 28px; max-width: 1240px; margin: 0 auto 20px; color: var(--tinte-leise); }
.gruppe { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.umschalter { display: flex; gap: 3px; border: 1px solid var(--linie); border-radius: 999px; padding: 3px; }
.umschalter button, .schalter { font: inherit; letter-spacing: .06em; text-transform: none; color: var(--tinte-leise); background: none; border: 0; border-radius: 999px; padding: 7px 12px; cursor: pointer; transition: color var(--ui), background-color var(--ui); }
.umschalter button:hover, .schalter:hover { color: var(--tinte); }
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--blatt); }
.schalter { border: 1px solid var(--linie); text-transform: uppercase; letter-spacing: .14em; display: inline-flex; align-items: center; gap: 8px; }
.schalter::before { content: ""; width: 8px; height: 8px; border-radius: 50%; border: 1px solid currentColor; }
.schalter[aria-pressed="true"] { color: var(--tinte); }
.schalter[aria-pressed="true"]::before { background: var(--mess); border-color: var(--mess); }
.umschalter button:focus-visible, .schalter:focus-visible, .knopf:focus-visible { outline: 2px solid var(--mess); outline-offset: 2px; }
.hinweis { flex-basis: 100%; color: var(--mess-text); text-transform: none; letter-spacing: .02em; }
.hinweis[hidden] { display: none; }
/* ——— Bühne: ein Blatt aus dem Büro Lotrecht ——— */
.blatt {
position: relative; max-width: 1240px; margin: 0 auto;
background: var(--blatt); border-radius: 4px;
padding: 28px clamp(20px, 4vw, 56px) 56px clamp(44px, 5.4vw, 72px);
box-shadow: 0 1px 0 rgba(25, 25, 23, .05), 0 30px 70px -40px rgba(25, 25, 23, .35);
}
.blatt :is(h1, h2, .lead, .marke, .pille), .knopf span { text-box: var(--text-trim); }
.kopf { display: flex; align-items: center; gap: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--tinte); }
.marke { font-size: 19px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
.marke em { font: italic 400 1.12em 'Instrument Serif', Georgia, serif; letter-spacing: 0; }
.nav { display: flex; gap: 26px; margin-left: auto; font-size: 15px; color: var(--tinte-leise); }
.knopf {
display: inline-flex; align-items: center; gap: .5em;
padding: .75em 1.25em; /* symmetrisch: mit Trim ist kein Ausgleich mehr nötig */
font: 500 15px/1.2 'Instrument Sans', system-ui, sans-serif; color: var(--blatt); background: var(--tinte);
border-radius: 999px; text-decoration: none; white-space: nowrap;
}
.knopf span { display: block; }
.knopf.hohl { color: var(--tinte); background: none; box-shadow: inset 0 0 0 1px var(--tinte); }
.haupt {
display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); column-gap: 56px; row-gap: var(--raum);
padding-top: var(--raum); align-items: start;
}
h1 { font-size: clamp(2.5rem, 1.1rem + 5.4vw, 6rem); font-weight: 500; line-height: 1.04; letter-spacing: -.04em; text-wrap: balance; max-width: 11ch; }
h1 em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.015em; }
.bild { grid-column: 1; }
.bild img { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; object-fit: cover; border-radius: 2px; background: #e4dfd4; }
.titel { grid-column: 2; grid-row: 1; font-size: clamp(1.25rem, 1rem + .7vw, 1.625rem); font-weight: 500; letter-spacing: -.02em; line-height: 1.25; }
.titel small { display: block; margin-top: .55em; font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--tinte-leise); }
.text { grid-column: 2; grid-row: 2; align-self: stretch; display: flex; flex-direction: column; gap: 22px; }
.lead { font-size: 17px; line-height: 1.5; max-width: 38ch; text-wrap: pretty; }
.fakten { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; padding-top: 14px; border-top: 1px solid var(--linie); font-size: 14px; }
.fakten dt { color: var(--tinte-leise); }
.fuss { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.pillen { display: flex; gap: 6px; }
.pille { display: inline-block; padding: 7px 10px; border: 1px solid var(--tinte); border-radius: 999px; font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* Lauf-Marken für die Messung (vom Skript eingesetzt, unsichtbar) */
i.m { display: inline-block; width: 0; height: 0; vertical-align: baseline; }
/* Gleiten beim Umschalten (nur die Demo; text-box selbst ist nicht animierbar) */
[data-gleit] { transition: transform var(--gleiten) var(--kurve); }
/* ——— Messebene ——— */
.messung { position: absolute; inset: 0; pointer-events: none; transition: opacity 300ms var(--kurve); }
.aus .messung { opacity: 0; }
.messung > * { position: absolute; transition: top var(--gleiten) var(--kurve), left var(--gleiten) var(--kurve), width var(--gleiten) var(--kurve), height var(--gleiten) var(--kurve), opacity 300ms var(--kurve); }
.messung.sofort > * { transition: none; }
.kasten { background: var(--mess-flaeche); outline: 1px dashed rgba(226, 70, 44, .55); outline-offset: -1px; }
.kasten { mix-blend-mode: multiply; }
.kasten.dunkel { background: rgba(226, 70, 44, .45); outline: 0; mix-blend-mode: screen; }
.hilfslinie { height: 0; border-top: 1px solid var(--mess); }
.hilfslinie.schief { border-top: 1px dashed var(--tinte-leise); }
.hilfslinie b { position: absolute; right: 0; bottom: 3px; font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--mess-text); white-space: nowrap; background: var(--blatt); padding: 2px 0 2px 6px; }
.hilfslinie.links b { right: auto; left: 8px; padding: 2px 6px; }
.hilfslinie.schief b { color: var(--tinte-leise); top: 3px; bottom: auto; }
.klammer { width: 9px; border-block: 1px solid var(--mess); }
.klammer::before { content: ""; position: absolute; left: 4px; top: 0; bottom: 0; border-left: 1px solid var(--mess); }
.klammer b {
position: absolute; left: 50%; top: 50%; translate: -50% -50%;
font: 600 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .04em; font-variant-numeric: tabular-nums;
color: #fff; background: var(--mess-text); padding: 3px 5px; border-radius: 3px; white-space: nowrap;
transition: background-color var(--ui), color var(--ui);
}
.klammer.daneben, .klammer.daneben::before { border-color: var(--tinte); }
.klammer.daneben b { background: var(--tinte); color: var(--blatt); }
.weg { opacity: 0 !important; }
/* ——— Werte ——— */
.werte { max-width: 1240px; margin: 26px auto 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 6px 36px; padding-top: 18px; border-top: 1px solid var(--linie); font: 400 11.5px/1.6 'Geist Mono', ui-monospace, monospace; color: var(--tinte-leise); }
.werte div { display: flex; gap: 12px; min-width: 0; }
.werte dt { color: var(--tinte); flex: none; min-width: 11em; }
.werte dd { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.werte dd.ok { color: var(--mess-text); }
/* ——— Vorschau: nur Blatt und Zustand ——— */
.zustand { display: none; }
.vorschau body { overflow: hidden; padding: 22px 28px; }
.vorschau .leiste, .vorschau .werte { display: none; }
.vorschau .zustand { display: flex; justify-content: space-between; gap: 16px; max-width: 1240px; margin: 0 auto 12px; color: var(--tinte-leise); }
.vorschau .zustand b { color: var(--mess-text); font-weight: 500; }
.vorschau .blatt { padding-bottom: 40px; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 860px) {
.haupt { grid-template-columns: minmax(0, 1fr); }
.titel, .text { grid-column: 1; grid-row: auto; }
.titel { margin-top: 8px; }
.nav { display: none; }
.kopf { justify-content: space-between; }
}
@media (max-width: 600px) {
.blatt { padding: 22px 18px 36px 40px; }
.knopf { font-size: 14px; }
.leiste { gap: 12px; }
.gruppe, .umschalter { width: 100%; }
.umschalter { display: grid; grid-template-columns: 1fr 1fr; border-radius: 18px; }
.umschalter button { padding: 9px 2px; border-radius: 14px; font-size: 10.5px; letter-spacing: 0; }
.werte dt { min-width: 9em; }
}
@media (hover: none) {
.umschalter button:hover, .schalter:hover { color: inherit; }
.umschalter button[aria-pressed="true"]:hover { color: var(--blatt); }
}
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="leiste mono">
<div class="gruppe">
<span>text-box</span>
<div class="umschalter" role="group" aria-label="Wert für text-box">
<button type="button" data-variante="normal" aria-pressed="false">normal</button>
<button type="button" data-variante="cap" aria-pressed="true">trim-both cap alphabetic</button>
<button type="button" data-variante="text" aria-pressed="false">trim-both text alphabetic</button>
<button type="button" data-variante="ende" aria-pressed="false">trim-end cap alphabetic</button>
</div>
</div>
<button type="button" class="schalter" data-linien aria-pressed="true">Hilfslinien</button>
<p class="hinweis" data-hinweis hidden>Dieser Browser kennt text-box noch nicht. Alle Varianten sehen hier aus wie „normal“.</p>
</div>
<p class="zustand mono" aria-hidden="true"><span>text-box: <b data-zustand>trim-both cap alphabetic</b></span><span data-zustand-mass></span></p>
<section class="blatt" data-blatt aria-label="Beispielseite Büro Lotrecht">
<header class="kopf">
<span class="marke" data-gleit>Büro <em>Lotrecht</em></span>
<nav class="nav" aria-label="Beispielnavigation"><span>Arbeiten</span><span>Büro</span><span>Journal</span></nav>
<a class="knopf" href="#anfrage" data-knopf><span data-gleit>Projekt anfragen</span></a>
</header>
<div class="haupt">
<h1 data-gleit data-h1>Gestaltung, die auf der <em>Linie</em> sitzt.</h1>
<figure class="bild" data-gleit data-bild>
<img style="object-position: 50% 10%" src="bilder/typo-plakat.jpg" alt="Ausstellungsplakat LICHT: schwarze Riesenversalien, eine orange Scheibe und kursive Zeile auf Neongelb" width="1200" height="800">
</figure>
<h2 class="titel" data-gleit data-h2>Werkschau Licht <small>Plakat · Website</small></h2>
<div class="text">
<p class="lead" data-gleit data-lead>Ein Plakat aus Versalien verzeiht keine ungefähren Abstände. Wir haben die Werkschau auf ein 8-Punkt-Raster gesetzt, vom Plakat bis zur Eintrittskarte. Die Website folgt demselben Raster, und zwar so, wie man es sieht.</p>
<dl class="fakten">
<dt>Kunde</dt><dd>Werkhalle Nord, Kassel</dd>
<dt>Leistung</dt><dd>Gestaltungsraster, Plakate, Website</dd>
<dt>Jahr</dt><dd>2026</dd>
</dl>
<div class="fuss">
<div class="pillen"><span class="pille" data-pille>Plakat</span><span class="pille">Web</span></div>
<a class="knopf hohl" href="#fallstudie"><span data-gleit>Fallstudie lesen</span></a>
</div>
</div>
</div>
<div class="messung sofort" data-messung aria-hidden="true"></div>
</section>
<dl class="werte" data-werte></dl>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const blatt = $('[data-blatt]'), messung = $('[data-messung]'), werteListe = $('[data-werte]');
const h1 = $('[data-h1]'), h2 = $('[data-h2]'), lead = $('[data-lead]'), bild = $('[data-bild]');
const knopf = $('[data-knopf]'), pille = $('[data-pille]');
const kopf = $('.kopf');
const knoepfe = [...document.querySelectorAll('[data-variante]')];
const linienKnopf = $('[data-linien]');
const gleiter = [...document.querySelectorAll('[data-gleit]')];
const kann = window.CSS && CSS.supports('text-box', 'trim-both cap alphabetic');
if (!kann) $('[data-hinweis]').hidden = false;
const VARIANTEN = {
normal: 'normal',
cap: 'trim-both cap alphabetic',
text: 'trim-both text alphabetic',
ende: 'trim-end cap alphabetic'
};
const RAUM = 32; /* Token --raum */
let variante = 'cap';
/* Laufmarken: leeres inline-block am Anfang und Ende → Grundlinie der ersten und letzten Zeile */
[h1, h2, lead, knopf.querySelector('span'), pille].forEach(el => {
const a = document.createElement('i'), b = document.createElement('i');
a.className = b.className = 'm';
el.prepend(a); el.append(b);
});
const ersteGrund = el => el.querySelector(':scope > i.m').getBoundingClientRect().top;
const letzteGrund = el => el.querySelector(':scope > i.m:last-of-type').getBoundingClientRect().top;
/* Versalhöhe der Schriften (Anteil an der Schriftgröße), per Canvas gemessen */
const versal = {};
function versalMessen() {
const c = document.createElement('canvas').getContext('2d');
[['sans', '500 100px "Instrument Sans"', .7], ['mono', '500 100px "Geist Mono"', .71]].forEach(([k, f, ersatz]) => {
c.font = f;
const m = c.measureText('H');
versal[k] = m.actualBoundingBoxAscent ? m.actualBoundingBoxAscent / 100 : ersatz;
});
}
const gr = el => parseFloat(getComputedStyle(el).fontSize);
const versalOben = (el, art = 'sans') => ersteGrund(el) - versal[art] * gr(el);
/* Messebene aufbauen */
const mk = (cls, txt) => { const d = document.createElement('div'); d.className = cls; if (txt !== undefined) d.innerHTML = '<b></b>'; messung.append(d); return d; };
const kaesten = [h1, h2, lead, knopf.querySelector('span'), pille].map((el, i) => mk(i === 3 ? 'kasten dunkel' : 'kasten'));
const linieVersal = mk('hilfslinie links', ''), linieH2 = mk('hilfslinie schief', ''), linieBild = mk('hilfslinie', '');
const klOben = mk('klammer', ''), klUnten = mk('klammer', ''), klLead = mk('klammer', '');
const setz = (d, x, y, w, h) => { d.style.left = x + 'px'; d.style.top = y + 'px'; if (w !== undefined) d.style.width = Math.max(0, w) + 'px'; if (h !== undefined) d.style.height = Math.max(0, h) + 'px'; };
const rund = v => Math.round(v * 10) / 10;
const zahl = v => (Math.abs(v) < .05 ? 0 : rund(v)).toLocaleString('de-DE');
function klammer(d, x, y1, y2, soll) {
const ist = y2 - y1;
setz(d, x, Math.min(y1, y2), 9, Math.abs(ist));
const ok = Math.abs(ist - soll) < .75;
d.classList.toggle('daneben', !ok);
d.querySelector('b').textContent = ok ? zahl(soll) + ' ✓' : zahl(ist);
return { ist, ok };
}
let letzte = {};
function messen() {
const B = blatt.getBoundingClientRect();
const rel = r => ({ x: r.left - B.left, y: r.top - B.top, w: r.width, h: r.height, r: r.right - B.left, b: r.bottom - B.top });
/* Kästen zeigen die Inhaltsbox, also das, was text-box beschneidet (bei der Pille ohne Polster und Rand) */
[h1, h2, lead, knopf.querySelector('span'), pille].forEach((el, i) => {
const r = rel(el.getBoundingClientRect()), cs = getComputedStyle(el);
const pt = parseFloat(cs.paddingTop) + parseFloat(cs.borderTopWidth), pb = parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth);
const pl = parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth), pr = parseFloat(cs.paddingRight) + parseFloat(cs.borderRightWidth);
setz(kaesten[i], r.x + pl, r.y + pt, r.w - pl - pr, r.h - pt - pb);
});
const regel = rel(kopf.getBoundingClientRect()).b;
const H1 = rel(h1.getBoundingClientRect()), BI = rel(bild.getBoundingClientRect()), H2 = rel(h2.getBoundingClientRect()), LE = rel(lead.getBoundingClientRect());
const capH1 = versalOben(h1) - B.top;
const grundH1 = letzteGrund(h1) - B.top;
const capH2 = versalOben(h2) - B.top;
const capLead = versalOben(lead) - B.top;
const nebeneinander = H2.x > BI.r - 1;
const links = parseFloat(getComputedStyle(blatt).paddingLeft);
const rechts = B.width - parseFloat(getComputedStyle(blatt).paddingRight);
const kx = Math.max(6, links - 24);
const o = klammer(klOben, kx, regel, capH1, RAUM);
const u = klammer(klUnten, kx, grundH1, BI.y, RAUM);
setz(linieVersal, links, capH1, rechts - links);
linieVersal.querySelector('b').textContent = nebeneinander ? 'Versalhöhe' : 'Versalhöhe h1';
const flucht = capH2 - capH1;
setz(linieH2, H2.x, capH2, rechts - H2.x);
linieH2.querySelector('b').textContent = 'h2 ' + (flucht > 0 ? '+' : flucht < 0 ? '−' : '') + zahl(Math.abs(flucht)) + ' px';
linieH2.classList.toggle('weg', !nebeneinander || Math.abs(flucht) < .75);
setz(linieBild, links, BI.y, rechts - links);
linieBild.querySelector('b').textContent = 'Bildkante';
linieBild.classList.toggle('weg', !nebeneinander);
const l = klammer(klLead, LE.x - 34, BI.y, capLead, 0);
klLead.classList.toggle('weg', !nebeneinander || Math.abs(capLead - BI.y) < .75);
/* Knopf und Pille: Luft über der Versalhöhe und unter der Grundlinie */
const kr = knopf.getBoundingClientRect(), ks = knopf.querySelector('span');
const kOben = versalOben(ks) - kr.top, kUnten = kr.bottom - ersteGrund(ks);
const pr = pille.getBoundingClientRect(), pb = parseFloat(getComputedStyle(pille).borderTopWidth);
const pOben = versalOben(pille, 'mono') - pr.top - pb, pUnten = pr.bottom - pb - ersteGrund(pille);
letzte = { o, u, flucht, nebeneinander, lead: capLead - BI.y, kOben, kUnten, pOben, pUnten };
werte();
}
function werte() {
const L = letzte; if (!L.o) return;
const passt = (a, b) => Math.abs(a - b) < .75;
const zeilen = [
['text-box', 'var(--text-trim) → ' + VARIANTEN[variante], variante !== 'normal'],
['Token --raum', RAUM + ' px (Linie → h1, h1 → Bild)', false],
['Linie → Versalhöhe h1', zahl(L.o.ist) + ' px · Soll ' + RAUM, L.o.ok],
['Grundlinie h1 → Bild', zahl(L.u.ist) + ' px · Soll ' + RAUM, L.u.ok],
L.nebeneinander ? ['Flucht h2 zu h1', (L.flucht > 0 ? '+' : L.flucht < 0 ? '−' : '') + zahl(Math.abs(L.flucht)) + ' px · Soll 0', passt(L.flucht, 0)] : null,
L.nebeneinander ? ['Bildkante → Versalhöhe Text', zahl(L.lead) + ' px · Soll 0', passt(L.lead, 0)] : null,
['Knopf oben / unten', zahl(L.kOben) + ' / ' + zahl(L.kUnten) + ' px · padding-block .75em', passt(L.kOben, L.kUnten)],
['Pille oben / unten', zahl(L.pOben) + ' / ' + zahl(L.pUnten) + ' px · padding 7px 10px', passt(L.pOben, L.pUnten)],
['Übergang (nur Demo)', 'transform ' + parseInt(getComputedStyle(html).getPropertyValue('--gleiten')) + 'ms cubic-bezier(0.16, 1, 0.3, 1)', false]
].filter(Boolean);
werteListe.innerHTML = zeilen.map(([k, v, ok]) => `<div><dt>${k}</dt><dd${ok ? ' class="ok"' : ''}>${v}</dd></div>`).join('');
$('[data-zustand]').textContent = VARIANTEN[variante];
$('[data-zustand-mass]').textContent = 'Linie → h1 ' + zahl(L.o.ist) + ' px · h1 → Bild ' + zahl(L.u.ist) + ' px';
}
/* Variante setzen. Die Demo lässt die Elemente von der alten zur neuen Lage gleiten (FLIP),
damit man den Unterschied sieht. Auf echten Seiten ist text-box einfach gesetzt. */
const anker = el => el.querySelector(':scope > i.m') ? ersteGrund(el) : el.getBoundingClientRect().top;
function setze(v, gleiten = true) {
variante = v;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.variante === v)));
const vorher = gleiter.map(anker);
gleiter.forEach(el => { el.style.transition = 'none'; el.style.transform = ''; });
blatt.style.setProperty('--text-trim', VARIANTEN[v]);
const nachher = gleiter.map(anker);
messung.classList.toggle('sofort', !gleiten || reduziert());
messen();
if (!gleiten || reduziert()) { gleiter.forEach(el => { el.style.transition = ''; }); return; }
gleiter.forEach((el, i) => { const d = vorher[i] - nachher[i]; if (Math.abs(d) > .3) el.style.transform = `translateY(${d}px)`; });
void blatt.offsetWidth;
gleiter.forEach(el => { el.style.transition = ''; el.style.transform = ''; });
}
knoepfe.forEach(k => k.addEventListener('click', () => { pause(); setze(k.dataset.variante); }));
linienKnopf.addEventListener('click', () => {
const an = linienKnopf.getAttribute('aria-pressed') !== 'true';
linienKnopf.setAttribute('aria-pressed', String(an));
blatt.classList.toggle('aus', !an);
});
document.querySelectorAll('.blatt a').forEach(a => a.addEventListener('click', e => e.preventDefault()));
function neuMessen() {
gleiter.forEach(el => { el.style.transition = 'none'; el.style.transform = ''; });
messung.classList.add('sofort');
messen();
requestAnimationFrame(() => gleiter.forEach(el => { el.style.transition = ''; }));
}
let rz;
new ResizeObserver(() => { cancelAnimationFrame(rz); rz = requestAnimationFrame(neuMessen); }).observe(blatt);
const bereit = Promise.all([
document.fonts.load('500 100px "Instrument Sans"'),
document.fonts.load('500 11px "Geist Mono"'),
document.fonts.load('italic 400 100px "Instrument Serif"')
]).catch(() => {}).then(() => document.fonts.ready).then(() => { versalMessen(); neuMessen(); });
versalMessen(); messen();
/* Vorführung: abwechselnd ohne und mit Trim, zwischendurch die beiden anderen Varianten */
const FOLGE = ['normal', 'cap', 'normal', 'cap', 'text', 'ende'];
let schleife = null, schritt = 0;
function neustart() {
clearTimeout(schleife);
schritt = 0;
setze('cap', false);
}
function takt() {
setze(FOLGE[schritt % FOLGE.length]);
schritt++;
schleife = setTimeout(takt, FOLGE[(schritt - 1) % FOLGE.length] === 'normal' ? 1700 : 2500);
}
function vorfuehren() {
clearTimeout(schleife);
html.classList.add('vorfuehrung');
linienKnopf.setAttribute('aria-pressed', 'true');
blatt.classList.remove('aus');
schritt = 0;
bereit.then(() => { clearTimeout(schleife); schleife = setTimeout(takt, 500); });
}
function pause() { clearTimeout(schleife); html.classList.remove('vorfuehrung'); }
function weiter() { clearTimeout(schleife); html.classList.add('vorfuehrung'); schleife = setTimeout(takt, 300); }
})();
</script>
</body>
</html>