Fließende Schriftgrößen mit clamp() und Container-Query-Einheiten
Eine Studio-Seite in einem ziehbaren Rahmen: Headline, Lead, Kartentitel und Wortmarke wachsen stufenlos mit, Karten richten ihre Titel nach der eigenen Breite.
Jede Seite mit Typo-Skala: Headline, Lead, Kartentitel und Wortmarke wachsen stufenlos; Vergleich mit „ohne cqi“ und Breakpoints im selben Rahmen.
Jede Stufe der Typo-Skala wächst stufenlos zwischen einer Mindest- und einer Höchstgröße, statt an Breakpoints zu springen: font-size: clamp(min, rem + vi, max). Die Skala entsteht meist mit einem Rechner wie Utopia aus zwei Basisgrößen und zwei Verhältnissen. Sie liegt als Custom Properties vor (--step-0 bis --step-9), Abstände folgen derselben Logik. Container-Query-Einheiten (cqi, cqw: 1 % der Containerbreite) koppeln Größen an die Komponente statt an den Viewport. Dieselbe Karte hat im schmalen Seitenslot einen kleineren Titel als im Hauptbereich.
Typografie wirkt auf jeder Breite wie gesetzt. Es gibt keine plötzlichen Sprünge, große Headlines nutzen die Desktop-Fläche und sprengen trotzdem kein Telefon. Mit cqi werden Komponenten wirklich wiederverwendbar, ideal für Template-Baukästen.
Wann einsetzen
Für jede Typo-Skala und jedes Abstandssystem. Für Komponenten-Bibliotheken und Templates mit unterschiedlich breiten Slots. Für Wortmarken, die die Containerbreite füllen sollen.
Wann nicht
Fließtext mit einem Minimum unter etwa 16 px oder zu steiler Steigung (Zoom, WCAG 1.4.4). Reine vw-Werte ohne rem-Anteil. Container-Einheiten ohne gesetzten container-type: Sie fallen dann auf den Viewport zurück.
Varianten
- Utopia-Skala als Tokens (--step-0 bis --step-9)
- Abstands-Paare wie --space-s-l: clamp(…) für fließende Abstände
- Komponenten-Titel in cqi, z. B. clamp(14px, 14px + 3cqw, 20px)
- Komponentenbreiten in cqi, z. B. Karussell-Karte 26.27cqi
- Wortmarke über 100cqi, die genau die Breite füllt (siehe schrift-hero-viewportbreit)
- Zoom-sicherer Mittelterm: rem + vi statt nur vw
- Fließende Zustände: Radius, Abstand, color-mix()-Anteil und Schatten aus --p: progress(100cqi, 20rem, 60rem), Rückfall ein fester Zwischenwert
Umsetzung
Skala mit Utopia erzeugen, z. B. 18 px bei 320 px bis 20 px bei 1240 px, Verhältnis 1.2 bis 1.333. Ablegen als :root { --step-0: clamp(1.125rem, 1.0815rem + 0.2174vi, 1.25rem); … }. Komponenten: .karte { container-type: inline-size } und .karte h3 { font-size: clamp(1.125rem, 0.5rem + 5cqi, 2.25rem) }. Achtung: cqi bezieht sich auf die Inhaltsbreite des nächsten Containers; ein Element mit container-type misst sich an seinem Vorfahren. Immer rem im Mittelterm, Minimum für Fließtext mindestens 1rem, Steigung bei Fließtext flach halten (höchstens etwa 0.5vi). Zoom-Test: Bei 200 % muss Text sichtbar wachsen. Faustregel: Maximum höchstens 2,5 × Minimum.
Robust umsetzen
Telefon. Kern des Musters: Die Mindestwerte bestimmen die Größe auf dem Telefon. Das Headline-Minimum so wählen, dass lange deutsche Komposita nicht aus der Zeile ragen, notfalls hyphens: auto.
Ohne Bewegung. Nicht relevant, das Muster bewegt nichts.
Barrierefrei. Reine vw- oder cqi-Werte ignorieren den Browser-Zoom. Deshalb immer einen rem-Anteil einbauen und das Verhältnis von Maximum zu Minimum begrenzen, damit 200 % Zoom wirkt (WCAG 1.4.4). Container-Einheiten im Fließtext vorsichtig einsetzen.
Code
<!DOCTYPE html>
<!--
Fließende Schriftgrößen mit clamp() und Container-Query-Einheiten
Fundus · Typo-Techniken · https://cecodesign.de/lab/fundus/typo-techniken/fluide-typo-clamp-cqi/
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>Fließende Schriftgrößen mit clamp() und Container-Query-Einheiten · 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 {
--grund: #141413; --hell: #efebe3; --leise: rgba(239, 235, 227, .5); --linie: rgba(239, 235, 227, .14);
--papier: #efebe3; --tinte: #1a1917; --tinte-leise: rgba(26, 25, 23, .66); --tinte-linie: rgba(26, 25, 23, .14);
--akzent: #c4542b; --akzent-text: #a8441f; /* Akzent als Schrift auf Papier (5:1) */
--ui: 150ms cubic-bezier(0.16, 1, 0.3, 1);
/* Typo-Skala (Utopia): 18 px @ 320 → 20 px @ 1240, Verhältnis 1.2 → 1.333.
In echten Seiten steht hier vi; die Demo simuliert den Viewport mit einem Container, daher cqi. */
--step--1: 0.9375rem;
--step-0: clamp(1.125rem, 1.0815rem + 0.2174cqi, 1.25rem);
--step-1: clamp(1.35rem, 1.24rem + 0.55cqi, 1.6663rem);
--step-6: clamp(3.3592rem, 2.0884rem + 6.354cqi, 7.0128rem);
/* Abstands-Paare nach derselben Logik */
--space-xs-s: clamp(0.5625rem, 0.4973rem + 0.3261cqi, 0.75rem);
--space-s-l: clamp(1.125rem, 0.6467rem + 2.3913cqi, 2.5rem);
--space-m-xl: clamp(1.6875rem, 0.9701rem + 3.587cqi, 3.75rem);
/* Komponente: Kartentitel an der eigenen Breite */
--karte-titel: clamp(1.125rem, 0.5rem + 5cqi, 2.25rem);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--hell); 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 außerhalb der Bühne */
.leiste { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 28px; padding-bottom: 22px; color: var(--leise); }
.umschalter { display: flex; gap: 4px; 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 12px; cursor: pointer; transition: color var(--ui), background-color var(--ui); }
.umschalter button:hover { color: var(--hell); }
.umschalter button[aria-pressed="true"] { background: var(--hell); color: var(--grund); }
.umschalter button:focus-visible, .regler input:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.regler { display: flex; align-items: center; gap: 12px; }
.regler input { width: clamp(120px, 18vw, 220px); accent-color: var(--hell); }
.regler output { min-width: 5.5em; text-align: right; color: var(--hell); font-variant-numeric: tabular-nums; }
/* Bühne: ein Rahmen, dessen Breite den Viewport spielt */
.buehne { position: relative; }
.lineal { position: relative; height: 26px; margin-bottom: 8px; color: var(--leise); }
.lineal::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--linie); }
.lineal::after { content: ""; position: absolute; left: 0; right: 0; top: calc(50% - 5px); height: 10px; border-inline: 1px solid var(--leise); }
.lineal span { position: absolute; left: 50%; top: 50%; translate: -50% -50%; background: var(--grund); padding: 0 10px; color: var(--hell); white-space: nowrap; font-variant-numeric: tabular-nums; }
.lineal span b { font-weight: 500; color: #d4643a; }
.spur { position: absolute; left: 0; top: 34px; bottom: 0; border: 1px dashed rgba(239, 235, 227, .18); border-radius: 3px; pointer-events: none; }
.spur span { position: absolute; right: 10px; top: 10px; color: var(--leise); }
.halter { position: relative; width: max-content; transform-origin: 0 0; }
.rahmen {
container: seite / inline-size;
position: relative; width: 1240px; min-width: 320px;
background: var(--papier); color: var(--tinte); border-radius: 3px; overflow: hidden;
box-shadow: 0 1px 0 rgba(255, 255, 255, .06), 0 30px 80px -30px rgba(0, 0, 0, .7);
}
.rahmen-innen { padding: var(--space-s-l); } /* Polster innen, damit 100cqi = volle Rahmenbreite */
.griff {
position: absolute; top: min(50%, 260px); right: -15px; translate: 0 -50%; z-index: 2;
width: 10px; height: 64px; border-radius: 6px; background: var(--hell); cursor: ew-resize; touch-action: none;
box-shadow: 0 0 0 4px var(--grund);
transition: scale var(--ui), background-color var(--ui);
}
.griff::before { content: ""; position: absolute; inset: 22px 3px; border-block: 1px solid rgba(20, 20, 19, .5); }
.griff::after { content: ""; position: absolute; inset: -12px -14px; } /* größere Trefferfläche */
.griff:hover, .griff.zieht, .vorfuehrung .griff { background: var(--akzent); scale: 1.15; }
/* ——— Inhalt im Rahmen: Studio Halde ——— */
.s-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding-bottom: var(--space-xs-s); border-bottom: 1px solid var(--tinte-linie); }
.s-marke { font-weight: 600; font-size: var(--step-0); letter-spacing: -.02em; }
.s-nav { display: flex; gap: 1.6em; color: var(--tinte-leise); }
.feld { position: relative; }
.mass { position: absolute; left: 0; bottom: 100%; padding-bottom: 5px; font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; color: var(--akzent-text); white-space: nowrap; font-variant-numeric: tabular-nums; pointer-events: none; }
.mass::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border: 1px solid currentColor; border-radius: 50%; vertical-align: 0; }
.h1-feld { margin-top: calc(var(--space-m-xl) + 14px); }
h1 { font-size: var(--step-6); font-weight: 500; line-height: .96; letter-spacing: -.035em; max-width: 13ch; text-wrap: balance; hyphens: manual; }
h1 em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; }
.unter { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: end; gap: var(--space-s-l); margin-top: calc(var(--space-s-l) + 12px); }
.lead { font-size: var(--step-1); line-height: 1.35; letter-spacing: -.01em; max-width: 30ch; text-wrap: pretty; }
.unter .mono { color: var(--tinte-leise); }
.raster { display: grid; grid-template-columns: 1.9fr 1fr; gap: var(--space-s-l); margin-top: calc(var(--space-m-xl) + 14px); align-items: start; }
.spalte { display: grid; gap: calc(var(--space-s-l) + 14px); }
.karte { container-type: inline-size; position: relative; }
.karte img { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; object-fit: cover; border-radius: 2px; background: #ddd8cd; }
.karte .meta { display: flex; justify-content: space-between; gap: 12px; margin-top: 12px; color: var(--tinte-leise); }
.karte .feld { margin-top: calc(var(--space-xs-s) + 14px); }
.karte h3 { font-size: var(--karte-titel); font-weight: 500; line-height: 1.08; letter-spacing: -.02em; text-wrap: balance; }
.karte p.text { margin-top: .7em; font-size: var(--step--1); line-height: 1.5; color: var(--tinte-leise); max-width: 46ch; }
.spalte .karte p.text { display: none; }
.wortmarke-feld { margin-top: calc(var(--space-m-xl) + 14px); padding-top: var(--space-xs-s); border-top: 1px solid var(--tinte-linie); }
.wortmarke-feld .mass { bottom: auto; top: calc(var(--space-xs-s) + 2px); left: auto; right: 0; }
/* Fit-to-width: Breite der Zeile = Containerbreite minus Polster, geteilt durch die Breite des Wortes in em */
.wortmarke { display: block; font-size: calc((100cqi - 2 * var(--space-s-l)) / 6.9); font-weight: 600; line-height: .8; letter-spacing: -.045em; text-transform: uppercase; margin-top: .12em; } /* passt auf eine Zeile; bei Textabständen (1.4.12) bricht sie um statt abgeschnitten zu werden */
@container seite (max-width: 640px) {
.raster { grid-template-columns: 1fr; }
.s-nav span:not(:last-child) { display: none; }
}
/* Variante 2: Seiten-Skala ohne Container-Einheiten in der Karte.
Die Karte bekommt ihre Größe vom Rahmen; alle Karten sind gleich groß, egal wie breit ihr Platz ist. */
.ohne-cqi .karte { font-size: clamp(1.3125rem, 1.1168rem + 0.9783cqi, 1.875rem); }
.ohne-cqi .karte h3 { font-size: 1em; }
/* Variante 3: klassische Breakpoints, Sprünge statt Fluss */
.stufen .rahmen-innen { padding: 18px; }
.stufen h1 { font-size: 44px; }
.stufen .lead { font-size: 21px; }
.stufen .karte h3 { font-size: 20px; }
.stufen .wortmarke { font-size: 41px; }
.stufen .h1-feld, .stufen .raster, .stufen .wortmarke-feld { margin-top: 41px; }
@container seite (min-width: 560px) { .stufen .rahmen-innen h1 { font-size: 64px; } .stufen .rahmen-innen .wortmarke { font-size: 72px; } }
@container seite (min-width: 800px) { .stufen .rahmen-innen h1 { font-size: 88px; } .stufen .rahmen-innen .lead { font-size: 26px; } .stufen .rahmen-innen .wortmarke { font-size: 108px; } }
@container seite (min-width: 1040px) { .stufen .rahmen-innen h1 { font-size: 112px; } .stufen .rahmen-innen .wortmarke { font-size: 144px; } }
/* Variante 4 „zustaende-progress“: Radius, Abstand, Farbanteil und Schatten gleiten mit der Breite.
--p ist registriert (<number>): progress() wird einmal am Seiteninhalt berechnet und als Zahl vererbt,
sonst löste 100cqi in jeder Karte gegen die Karte selbst auf. */
@property --p { syntax: '<number>'; inherits: true; initial-value: 0.5; }
.zp .rahmen-innen { --p: 0.5; } /* Rückfall ohne progress(): fester Zwischenwert */
@supports (width: calc(progress(1px, 0px, 2px) * 1px)) {
.zp .rahmen-innen { --p: progress(100cqi, 20rem, 60rem); } /* 0 bei 320 px, 1 ab 960 px Breite */
}
.zp .raster { gap: calc(12px + var(--p) * 20px); }
.zp .spalte { gap: calc(12px + var(--p) * 20px); }
.zp .karte {
padding: calc(10px + var(--p) * 18px);
border-radius: calc(2px + var(--p) * 22px);
background: color-mix(in oklab, var(--akzent) calc(3% + var(--p) * 12%), #fffdf8);
box-shadow: 0 calc(1px + var(--p) * 17px) calc(2px + var(--p) * 38px) calc(var(--p) * -14px) rgb(26 25 23 / calc(.06 + var(--p) * .22));
}
.zp .karte img { border-radius: calc(1px + var(--p) * 13px); }
.zp .karte .meta { margin-top: calc(8px + var(--p) * 8px); }
.zp .p-mass { display: block; }
.p-mass { display: none; position: absolute; right: calc(10px + var(--p) * 18px); top: calc(10px + var(--p) * 18px); z-index: 1; padding: 5px 8px; border-radius: 99px; background: rgb(26 25 23 / .82); color: #efebe3; font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; font-variant-numeric: tabular-nums; pointer-events: none; }
.p-mass i { display: inline-block; width: 44px; height: 4px; margin-left: 8px; vertical-align: 1px; border-radius: 2px; background: linear-gradient(90deg, #d4643a calc(var(--p) * 100%), rgb(239 235 227 / .25) 0); }
.umschalter .trenner { width: 1px; margin: 4px 2px; background: var(--linie); }
.vorschau.zp .h1-feld, .vorschau.zp .unter { display: none; }
.vorschau.zp .raster { margin-top: 0; }
/* Werte unter der Bühne */
.werte { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 6px 32px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--linie); color: var(--leise); font: 400 11.5px/1.6 'Geist Mono', ui-monospace, monospace; }
.werte div { display: flex; gap: 12px; min-width: 0; }
.werte dt { color: var(--hell); flex: none; min-width: 8.5em; }
.werte dd { overflow-wrap: anywhere; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; padding: 28px 32px; }
.vorschau .leiste, .vorschau .werte { display: none; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (hover: none) {
.griff { width: 14px; right: -17px; }
.griff:hover { background: var(--hell); scale: 1; }
}
@media (max-width: 600px) {
.leiste { gap: 12px; }
.umschalter { width: 100%; }
.umschalter button { flex: 1; padding: 8px 4px; letter-spacing: .06em; }
.regler { width: 100%; }
.regler input { flex: 1; width: auto; }
.werte dt { min-width: 7em; }
}
/* 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="umschalter" role="group" aria-label="Variante">
<button type="button" data-variante="fliessend" aria-pressed="true">clamp + cqi</button>
<button type="button" data-variante="ohne-cqi" aria-pressed="false">ohne cqi</button>
<button type="button" data-variante="stufen" aria-pressed="false">Breakpoints</button>
<span class="trenner" aria-hidden="true"></span>
<button type="button" data-variante="zustaende-progress" aria-pressed="false">progress()</button>
</div>
<label class="regler">Breite <input type="range" min="320" max="1240" step="1" value="1240" data-regler aria-label="Breite des Containers in Pixel"><output data-ausgabe>1240 px</output></label>
</div>
<div class="buehne" data-buehne>
<div class="lineal mono" aria-hidden="true" data-lineal><span>Container <b data-breite>1240</b> px</span></div>
<div class="spur mono" aria-hidden="true" data-spur><span data-spur-text>max 1240 px</span></div>
<div class="halter" data-halter>
<div class="rahmen" data-rahmen>
<div class="rahmen-innen">
<header class="s-kopf">
<span class="s-marke">Studio Halde</span>
<nav class="s-nav mono" aria-label="Beispielnavigation"><span>Arbeiten</span><span>Studio</span><span>Kontakt</span></nav>
</header>
<div class="feld h1-feld">
<span class="mass" data-mass="h1" aria-hidden="true"></span>
<h1>Marken, die auf <em>jeder</em> Fläche sitzen.</h1>
</div>
<div class="unter">
<div class="feld">
<span class="mass" data-mass="lead" aria-hidden="true"></span>
<p class="lead">Wir entwerfen Erscheinungsbilder für Cafés, Handwerk und kleine Verlage. Vom Logo über das Ladenschild bis zur Speisekarte.</p>
</div>
<p class="mono">Leipzig · seit 2014</p>
</div>
<div class="raster">
<article class="karte">
<span class="p-mass" data-p-mass aria-hidden="true"></span>
<img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr: ein Blatt mit rosa umgeknickter Ecke neben kursivem Schriftzug auf Anthrazit" width="1200" height="800">
<p class="meta mono"><span>01 · Erscheinungsbild</span><span>2024</span></p>
<div class="feld">
<span class="mass" data-mass="karte" aria-hidden="true"></span>
<h3>Eselsohr: ein Zeichen wie ein Lesezeichen</h3>
</div>
<p class="text">Feine Linie, weiche Kursive. Die Marke trägt vom Kaffeebecher bis zur Fassade und bleibt dabei leise.</p>
</article>
<div class="spalte">
<article class="karte">
<img style="object-position: 50% 20%" src="bilder/marke-krume.jpg" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben unter einem Brotlaib auf Senfgelb" width="1200" height="800">
<p class="meta mono"><span>02 · Backstube</span><span>2023</span></p>
<div class="feld">
<span class="mass" data-mass="karte" aria-hidden="true"></span>
<h3>Krume</h3>
</div>
</article>
<article class="karte">
<img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin: Doppelseite „Die Ruhe der Dinge“ mit Keramikfoto auf Terrakotta" width="1200" height="800">
<p class="meta mono"><span>03 · Editorial</span><span>2022</span></p>
<div class="feld">
<span class="mass" data-mass="karte" aria-hidden="true"></span>
<h3>Kleinserie, Heft 04</h3>
</div>
</article>
</div>
</div>
<div class="feld wortmarke-feld">
<span class="mass" data-mass="wortmarke" aria-hidden="true"></span>
<span class="wortmarke" aria-hidden="true">Studio Halde</span>
</div>
</div>
</div>
<div class="griff" data-griff role="presentation" title="Ziehen, um die Breite zu ändern"></div>
</div>
</div>
<dl class="werte" data-werte></dl>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const buehne = document.querySelector('[data-buehne]');
const halter = document.querySelector('[data-halter]');
const rahmen = document.querySelector('[data-rahmen]');
const griff = document.querySelector('[data-griff]');
const regler = document.querySelector('[data-regler]');
const ausgabe = document.querySelector('[data-ausgabe]');
const breiteText = document.querySelector('[data-breite]');
const lineal = document.querySelector('[data-lineal]');
const spur = document.querySelector('[data-spur]');
const spurText = document.querySelector('[data-spur-text]');
const werteListe = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-variante]')];
const MIN = 320, MAX = 1240;
// Die tatsächlich verwendeten Werte je Variante (gleich wie im CSS oben)
const WERTE = {
fliessend: [
['h1', '--step-6', 'clamp(3.3592rem, 2.0884rem + 6.354cqi, 7.0128rem)'],
['Lead', '--step-1', 'clamp(1.35rem, 1.24rem + 0.55cqi, 1.6663rem)'],
['Kartentitel', 'eigener Container', 'clamp(1.125rem, 0.5rem + 5cqi, 2.25rem)'],
['Polster', '--space-s-l', 'clamp(1.125rem, 0.6467rem + 2.3913cqi, 2.5rem)'],
['Wortmarke', '100cqi', 'calc((100cqi - 2 × Polster) / 6.9)']
],
'ohne-cqi': [
['h1', '--step-6', 'clamp(3.3592rem, 2.0884rem + 6.354cqi, 7.0128rem)'],
['Lead', '--step-1', 'clamp(1.35rem, 1.24rem + 0.55cqi, 1.6663rem)'],
['Kartentitel', 'Seiten-Skala', 'clamp(1.3125rem, 1.1168rem + 0.9783cqi, 1.875rem)'],
['Folge', '', 'schmale Karten haben dieselbe Titelgröße wie breite']
],
'zustaende-progress': [
['--p', '@property <number>', 'progress(100cqi, 20rem, 60rem) · Rückfall 0.5'],
['Radius', 'Karte', 'calc(2px + var(--p) × 22px) · 2 → 24 px'],
['Abstand', 'Polster, Spalt', 'calc(10px + var(--p) × 18px), Spalt 12 → 32 px'],
['Farbanteil', 'color-mix', 'Akzent 3 % → 15 % in #fffdf8 · Text 14 : 1, leise ≥ 5,1 : 1'],
['Schatten', 'box-shadow', 'y 1 → 18 px, Blur 2 → 40 px, Deckkraft .06 → .28'],
['Schrift', '', 'wie clamp + cqi']
],
stufen: [
['h1', '@container', '44 → 64 → 88 → 112 px bei 560 / 800 / 1040'],
['Lead', '@container', '21 → 26 px bei 800'],
['Kartentitel', 'fest', '20 px'],
['Wortmarke', '@container', '41 → 72 → 108 → 144 px']
]
};
let variante = 'fliessend', breite = MAX, massstab = 1;
function platz() { return buehne.clientWidth - 18; }
// Auf großen Bildschirmen zeigt die Bühne echte Pixel; auf kleinen wird der Rahmen verkleinert dargestellt
function grenze() { const p = platz(); return p >= 700 ? Math.max(MIN, Math.min(MAX, p)) : MAX; }
function setzeBreite(w) {
breite = Math.round(Math.max(MIN, Math.min(grenze(), w)));
rahmen.style.width = breite + 'px';
massstab = Math.min(1, platz() / breite);
halter.style.transform = massstab < 1 ? `scale(${massstab})` : '';
halter.style.height = massstab < 1 ? rahmen.offsetHeight + 'px' : '';
buehne.style.height = massstab < 1 ? (rahmen.offsetHeight * massstab + 34) + 'px' : '';
lineal.style.width = breite * massstab + 'px';
spur.style.width = Math.min(platz(), grenze()) + 'px';
spurText.textContent = 'max ' + grenze() + ' px';
spur.style.opacity = massstab < 1 ? 0 : 1;
breiteText.textContent = breite;
regler.max = grenze();
regler.value = breite;
ausgabe.textContent = breite + ' px';
messen();
}
const px = el => parseFloat(getComputedStyle(el).fontSize);
function messen() {
const w = WERTE[variante];
document.querySelectorAll('[data-mass]').forEach(m => {
const art = m.dataset.mass;
const ziel = m.parentElement.querySelector(art === 'wortmarke' ? '.wortmarke' : art === 'lead' ? '.lead' : art === 'karte' ? 'h3' : 'h1');
const groesse = px(ziel).toFixed(1).replace('.', ',');
let marke = '';
if (art === 'h1') marke = variante === 'stufen' ? 'Breakpoint' : '--step-6';
if (art === 'lead') marke = variante === 'stufen' ? 'Breakpoint' : '--step-1';
if (art === 'karte') {
const k = m.closest('.karte').getBoundingClientRect().width / massstab;
marke = (variante === 'fliessend' || variante === 'zustaende-progress') ? `Karte ${Math.round(k)} px · 5cqi` : variante === 'ohne-cqi' ? 'Seiten-Skala' : 'fest';
}
if (art === 'wortmarke') marke = variante === 'stufen' ? 'Breakpoint' : '100cqi';
m.textContent = `${marke} · ${groesse} px`;
});
if (variante === 'zustaende-progress') {
const k = document.querySelector('.karte'), cs = getComputedStyle(k);
const pw = parseFloat(cs.getPropertyValue('--p')) || 0;
pMass.innerHTML = `p ${pw.toFixed(2).replace('.', ',')} · r ${Math.round(parseFloat(cs.borderTopLeftRadius))} px<i></i>`;
}
}
function zeigeWerte() {
werteListe.innerHTML = WERTE[variante].map(([a, b, c]) =>
`<div><dt>${a}</dt><dd>${b ? b + ': ' : ''}${c}</dd></div>`).join('');
}
const pMass = document.querySelector('[data-p-mass]');
function waehle(v, url) {
variante = v;
html.classList.toggle('zp', v === 'zustaende-progress');
if (url) { const q = new URLSearchParams(location.search); q.set('v', v === 'zustaende-progress' ? v : 'basis'); history.replaceState(null, '', location.pathname + '?' + q.toString().replace(/=(?=&|$)/g, '') + location.hash); }
html.classList.toggle('ohne-cqi', v === 'ohne-cqi');
html.classList.toggle('stufen', v === 'stufen');
knoepfe.forEach(k => k.setAttribute('aria-pressed', k.dataset.variante === v));
zeigeWerte();
setzeBreite(breite);
}
knoepfe.forEach(k => k.addEventListener('click', () => { stopp(); waehle(k.dataset.variante, true); }));
// Regler (Tastatur, Touch) und Griff (Maus, Finger)
regler.addEventListener('input', () => { stopp(); setzeBreite(+regler.value); });
let zug = null;
griff.addEventListener('pointerdown', e => {
stopp();
zug = { x: e.clientX, w: breite };
griff.setPointerCapture(e.pointerId);
griff.classList.add('zieht');
});
griff.addEventListener('pointermove', e => { if (zug) setzeBreite(zug.w + (e.clientX - zug.x) / massstab); });
const loslassen = () => { zug = null; griff.classList.remove('zieht'); };
griff.addEventListener('pointerup', loslassen);
griff.addEventListener('pointercancel', loslassen);
new ResizeObserver(() => setzeBreite(breite)).observe(buehne);
// Vorführung: der Rahmen atmet zwischen voller Breite und Telefonbreite
let uhr = null, tick = null, start = 0, versatz = 0;
const RUNDE = 9000, UNTEN = 340;
function stopp() { cancelAnimationFrame(uhr); clearTimeout(tick); uhr = tick = null; html.classList.remove('vorfuehrung'); }
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
if (reduziert()) {
// ohne Bewegung: feste Breiten, hart gewechselt
const stufen = [grenze(), 768, 480, 360];
let i = 0;
const weiterSchalten = () => { setzeBreite(stufen[i++ % stufen.length]); tick = setTimeout(weiterSchalten, 2200); };
weiterSchalten();
return;
}
start = performance.now() - versatz;
const schritt = t => {
versatz = (t - start) % RUNDE;
const p = versatz / RUNDE;
const oben = grenze();
setzeBreite(UNTEN + (oben - UNTEN) * (0.5 + 0.5 * Math.cos(p * 2 * Math.PI)));
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); clearTimeout(tick); uhr = tick = null; }
function weiter() { if (!uhr && !tick && html.classList.contains('vorfuehrung')) vorfuehren(); }
// Start: auf großen Bildschirmen volle Breite, auf Telefonen 1 : 1 in Gerätebreite
const startBreite = () => platz() >= 700 ? grenze() : Math.max(MIN, platz());
function neustart() { stopp(); versatz = 0; setzeBreite(startBreite()); }
// Regel 13: ?v=basis (drei Typo-Ansichten) oder ?v=zustaende-progress
waehle(new URLSearchParams(location.search).get('v') === 'zustaende-progress' ? 'zustaende-progress' : 'fliessend');
setzeBreite(startBreite());
if (document.fonts) document.fonts.ready.then(() => setzeBreite(breite));
})();
</script>
</body>
</html>