Zwischen den Breakpoints
liegt eine Zahl.

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:

resources/css/signet.css
/* 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:

resources/css/basis.css
: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:

resources/css/basis.css
@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:

resources/css/hero.css
.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:

Farbmischung color-mix(… calc(a * 100%))
Deckkraft calc(0.3 + a * 0.7)
Drehung calc(a * 90deg - 45deg)
Zieh das Browserfenster schmaler, dann wandern Balken, Farbe, Deckkraft und Drehung gemeinsam. Unterhalb von 400px steht alles am linken Anschlag, ab 1200px am rechten. Auf einem Gerät ohne verstellbares Fenster – und in jedem Browser, der weder progress() noch tan(atan2()) beherrscht – bleibt der Endzustand stehen; die Demo ist dann unbewegt, aber nicht kaputt.

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:

resources/css/hero.css
/* 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:

resources/css/hero.css
.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:

resources/css/hero.css
/* 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.

resources/css/signet.css
/* 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.

Fragen zum Thema – oder ein Projekt, das genau daran hängt?

Schreib mir über das Kontaktformular kurz, worum es geht. Ich schaue mir deinen Fall an und sage dir konkret, was sich verbessern lässt – unverbindlich und ohne Verkaufsgespräch.