Ein Breakpoint ist eine Entscheidung, die an genau einer Stelle fällt. Links davon passiert nichts, rechts davon passiert nichts, und dazwischen liegt der Sprung. Die meisten Besucher sehen von der ganzen Mechanik nur einen einzigen, willkürlich getroffenen Zustand.
Was clamp() nicht kann
Bei einem Relaunch fiel mir eine Kleinigkeit auf, die sich erst beim Ziehen am
Fensterrand zeigte. Schriftgrößen und Abstände waren durchgehend mit
clamp() gerechnet und wuchsen sauber mit; die Seite wirkte auf jeder
Breite stimmig. Nur ein dekoratives Signet neben der Überschrift verschwand bei
genau 992 Pixeln von einem Frame auf den nächsten, weil dort ein Breakpoint saß und
display: none setzte.
Der naheliegende Gedanke war, das Signet stattdessen ausblenden zu lassen: Deckkraft von 1 auf 0,3, während das Fenster schmaler wird. Genau das geht aber nicht. Nicht weil es unpraktisch wäre, sondern weil CSS an dieser Stelle eine harte Grenze zieht.
clamp() interpoliert Längen. Eine Schriftgröße ist eine Länge, ein
Abstand ist eine Länge, eine Rasterspalte ist eine Länge. Deckkraft ist keine. Eine
Drehung ist keine. Das Mischungsverhältnis in color-mix() ist keine.
Eine Zeilenhöhe ohne Einheit ist keine. Für alles, was nicht in Pixeln gemessen
wird, bleibt nur der Breakpoint – und damit der Sprung.
Der Grund dafür steckt in einer einzigen Regel von calc(): Dividiert
werden darf nur durch eine Zahl, nie durch eine Länge. Die Rechnung, die man
bräuchte, ist damit ungültig:
/* Ungültig. Länge geteilt durch Länge ist in calc() nicht erlaubt,
und genau das wäre nötig, um aus einer Breite ein Verhältnis
zu machen. Der Browser verwirft die Deklaration wortlos. */
.zw-signet {
opacity: calc((100vw - 400px) / 800px);
}
Was fehlt, ist ein Übersetzer: etwas, das aus einer Länge eine einheitenlose Zahl macht. Sobald die Fensterbreite als Zahl zwischen 0 und 1 vorliegt, ist der Rest triviale Multiplikation – und jede Eigenschaft, egal welcher Typ, wird berechenbar.
Eine Funktion für genau das
Seit September 2026 gibt es diesen Übersetzer offiziell.
progress() nimmt drei Werte – den aktuellen, den Anfang und das Ende –
und gibt zurück, wie weit der aktuelle Wert zwischen den beiden anderen liegt:
:root {
/* 0 bei 400px Fensterbreite, 1 ab 1200px, dazwischen linear.
Außerhalb der Grenzen wird auf 0 bzw. 1 begrenzt. */
--breitenanteil: progress(100vw, 400px, 1200px);
}
Gerechnet wird schlicht (Wert − Anfang) / (Ende − Anfang). Die drei
Argumente müssen vom selben Typ sein – drei Längen, drei Winkel oder drei Zahlen,
aber nichts Gemischtes. Innerhalb eines Typs dürfen die Einheiten verschieden sein:
progress(100vw, 25rem, 75rem) ist gültig. Und weil die Begrenzung
eingebaut ist, braucht es kein zusätzliches clamp(); wer die
Extrapolation über die Grenzen hinaus will, schreibt das Schlüsselwort
no-clamp vor den ersten Wert.
- Chrome und Edge liefern progress() seit Version 138 im Juni 2025 aus.
- Safari seit Version 26 im September 2025.
- Firefox seit Version 155 – erschienen am 1. September 2026, und damit der Tag, an dem die Funktion Baseline wurde.
Die Funktion ist nicht auf den Viewport festgelegt, auch wenn das der häufigste Fall
ist. Jeder vergleichbare Wert geht: 100cqw für die Breite eines
Containers statt des Fensters, eine env()-Variable, eine aus JavaScript
gesetzte Custom Property. Der Rest des Artikels behandelt die Fensterbreite, weil
das der Fall ist, an dem die Wirkung am schnellsten sichtbar wird.
Derselbe Wert ohne progress()
Baseline seit wenigen Tagen heißt in der Praxis: noch nicht auf den Geräten deiner Besucher. Für alles, was jetzt ausgeliefert wird, brauchst du einen zweiten Weg – und den gibt es, mit einem Umweg über die Trigonometrie.
atan2(y, x) nimmt zwei Werte und gibt einen Winkel zurück. Das ist die
entscheidende Stelle: Ein Winkel ist keine Länge mehr, die Einheit der beiden
Eingaben ist beim Verlassen der Funktion verschwunden. Bildet man von diesem Winkel
den Tangens, kommt genau das Verhältnis y ÷ x zurück – jetzt aber als
reine Zahl. Multipliziert mit dem Bezugswert steht die Fensterbreite in Pixeln da,
ohne px daran:
@property --vw-laenge {
syntax: "<length>";
initial-value: 0px;
inherits: false;
}
:root {
--vw-laenge: 100vw;
/* tan(atan2(a, b)) ist a / b. Mit b = 10000px kommt die Breite
als Tausendstel zurück und wird sofort wieder hochgerechnet. */
--vw-zahl: calc(10000 * tan(atan2(var(--vw-laenge), 10000px)));
/* Ab hier dieselbe Bedeutung wie progress(100vw, 400px, 1200px).
Division durch 800 ist erlaubt: 800 ist eine Zahl, keine Länge. */
--breitenanteil: clamp(0, (var(--vw-zahl) - 400) / 800, 1);
}
Zwei Details daran sehen nach Zierrat aus und sind keiner.
Der Bezugswert 10000px ist mathematisch beliebig – gekürzt wird er
ohnehin wieder heraus. Die naheliegende Variante mit 1px hat aber nicht
in allen Engines zuverlässig gerechnet, weshalb sich der große Wert eingebürgert
hat. Übernimm ihn einfach, es kostet nichts.
Was 0 bis 1 hergibt
Ab hier ist es egal, welcher der beiden Wege den Wert geliefert hat. Ein einheitenloser Faktor lässt sich mit jeder Einheit multiplizieren, und damit wird jede Eigenschaft steuerbar, die vorher am Breakpoint springen musste:
.zw-signet {
/* Deckkraft. Das untere Ende ist bewusst nicht 0, sondern der
Wert, bei dem das Element gerade noch zu erkennen ist. */
opacity: calc(0.3 + var(--breitenanteil) * 0.7);
/* Farbe. Der Mischungsanteil ist ein Prozentwert und keine
Länge – mit clamp() wäre hier nichts zu holen. */
background: color-mix(
in oklab,
#3b82f6,
#e6195d calc(var(--breitenanteil) * 100%)
);
/* Drehung. Gleiche Zahl, andere Einheit. */
rotate: calc(var(--breitenanteil) * 8deg - 4deg);
}
.zw-hero__titel {
/* Zeilenhöhe ohne Einheit: eine Zahl, kein Längenmaß. Enge
Überschriften vertragen auf schmalen Displays mehr Luft. */
line-height: calc(1.35 - var(--breitenanteil) * 0.15);
}
Die Demo darunter ist genau das, live: ein Wert, vier Elemente. Der Balken zeigt den Stand zwischen den beiden Grenzen, die drei Kacheln hängen an derselben Variablen:
color-mix(… calc(a * 100%))
calc(0.3 + a * 0.7)
calc(a * 90deg - 45deg)
Mehrere Tempi aus einem Wert
Eine einzige lineare Rampe reicht selten. Zwei Rechnungen darauf decken fast alles ab, was man in der Praxis braucht.
Die erste ist ein Teilbereich. Wenn ein zweites Element erst loslegen soll, wenn das erste fertig ist, staffelst du den Wert einfach ein zweites Mal – mit sich selbst als Eingabe:
/* Läuft erst zwischen 0.6 und 1, davor konstant 0. */
--spaeter: clamp(0, (var(--breitenanteil) - 0.6) / 0.4, 1);
/* Dasselbe mit progress(), das hier besonders gut aussieht: */
--spaeter: progress(var(--breitenanteil), 0.6, 1);
Die zweite betrifft Bewegung. Wenn zwei Elemente aneinander vorbeimüssen und beide geradlinig laufen, überlagern sie sich irgendwo auf halber Strecke. Bei einer Animation wäre das ein Sekundenbruchteil; hier ist es eine Fensterbreite – und wer zufällig genau diese Breite eingestellt hat, sieht die Überlagerung dauerhaft. Eine Zwischenstellung ist in dieser Technik kein Moment, sondern ein Zustand.
Die Abhilfe ist eine Kurve statt einer Geraden, und die bekommst du, indem du beiden Achsen unterschiedliche Tempi gibst. Doppeltes Tempo waagerecht, gedeckelt bei 1, damit es nicht über das Ziel hinausläuft:
.zw-hero__notiz {
/* Waagerecht doppelt so schnell wie senkrecht: Das Element
beschreibt einen Bogen und läuft außen um das herum, was in
der Mitte steht, statt mittendurch. */
--tempo-x: min(var(--breitenanteil) * 2, 1);
translate:
calc((1 - var(--tempo-x)) * 50%)
calc((1 - var(--breitenanteil)) * -2.4rem);
}
Wo es schiefgeht
Vier Dinge sind mir dabei begegnet, die sich nicht von selbst erklären.
- 100vw rechnet die Scrollleiste mit. Auf dem Desktop ist der Wert deshalb einige Pixel größer als die tatsächlich sichtbare Fläche. Für ein Verhältnis ist das gleichgültig, für eine daraus berechnete Breite nicht – dort führt es zum waagerechten Überlauf.
- Der Viewport ist oft die falsche Größe. Was in einer schmalen Sidebar steht, sollte auf die Breite der Sidebar reagieren, nicht auf die des Fensters. Mit container-type: inline-size am Elternelement und 100cqw statt 100vw rechnet dieselbe Formel auf die richtige Bezugsgröße.
- Der gewählte Bereich muss zur Wirklichkeit passen. Wer ihn von 0 bis 1600 spannt, verteilt die ganze Wirkung auf eine Strecke, von der jeder Besucher nur einen kleinen Ausschnitt sieht – am Ende bewegt sich nichts Sichtbares. 400 bis 1200 ist ein Band, in dem sich echte Fenster tatsächlich bewegen.
- Browser-Zoom verändert die Breite in CSS-Pixeln. Wer auf 200 Prozent zoomt, landet im schmalen Ende der Rampe. Das ist meistens genau richtig – geprüft haben sollte man es trotzdem einmal.
Der wichtigste Punkt ist aber keiner der vier. Es gibt hier keine Zeitachse: Der Wert ist eine Funktion der Fensterbreite, nicht der Fortschritt einer Animation. Für den einzelnen Besucher ist er eine Konstante. Das flüssige Ziehen am Fensterrand siehst du beim Entwickeln, deine Besucher sehen einen einzigen Punkt der Kurve – und zwar jeder einen anderen. Jede Zwischenstellung muss deshalb für sich funktionieren, nicht nur die beiden Enden.
Sauber einbauen
Aus diesen zwei Verfügbarkeiten ergibt sich eine Staffelung in drei Stufen, von unten nach oben. Die Basis gilt überall und beschreibt den Zustand, den ein Browser ohne jede der beiden Techniken zeigen soll:
/* Stufe 1: gilt überall. 1 ist der breite, ruhige Endzustand –
nicht 0, sonst startet ein alter Browser mit einem halb
ausgeblendeten Element. */
.zw-hero {
--breitenanteil: 1;
}
/* Stufe 2: der Weg über die Trigonometrie, seit Juli 2024 in
allen Engines. */
@supports (rotate: atan2(1px, 1px)) {
.zw-hero {
--vw-laenge: 100vw;
--vw-zahl: calc(10000 * tan(atan2(var(--vw-laenge), 10000px)));
--breitenanteil: clamp(0, (var(--vw-zahl) - 400) / 800, 1);
}
}
/* Stufe 3: dieselbe Zahl in einer Zeile, seit September 2026.
Steht zuletzt, damit sie gewinnt, wo es sie gibt. */
@supports (opacity: progress(100vw, 400px, 1200px)) {
.zw-hero {
--breitenanteil: progress(100vw, 400px, 1200px);
}
}
Die Abfrage in Stufe 2 prüft die trigonometrischen Funktionen, nicht
@supports für @property. Ein Browser, der das eine kennt
und das andere nicht, liegt in einem schmalen Streifen älterer Firefox-Versionen –
vernachlässigbar, aber der Vollständigkeit halber erwähnt. Gegen den Fall hilft
ohnehin dieselbe Gewohnheit, die auch sonst richtig ist: An jeder Verwendungsstelle
einen Ersatzwert mitgeben.
/* Der zweite Parameter von var() greift, sobald --breitenanteil
fehlt oder zu einem ungültigen Wert ausrechnet. Ohne ihn fällt
die ganze Eigenschaft auf ihren Initialwert zurück. */
.zw-signet {
opacity: calc(0.3 + var(--breitenanteil, 1) * 0.7);
}
Fazit
Der Umweg über die Trigonometrie ist ein Kunstgriff, und er wird in ein paar Jahren
niemanden mehr interessieren. Was bleibt, ist das, was er sichtbar gemacht hat: dass
CSS eine normierte Zahl als Baustein gefehlt hat – einen Wert zwischen 0 und 1, aus
dem sich jede andere Einheit herleiten lässt. progress() macht daraus
eine gewöhnliche Funktion, und sobald sie überall angekommen ist, wird der ganze
Abschnitt über @property und Bezugswerte zu einer Fußnote.
Bis dahin funktionieren beide Wege nebeneinander, und der Aufwand dafür sind vier
Zeilen im Stylesheet. Ich würde sie nicht überall einsetzen. Aber die Stelle, an der
bisher ein display: none hinter einem Breakpoint saß, ist fast immer
eine, an der etwas Besseres möglich gewesen wäre.