Showcase 59

Null JavaScript.

Diese Seite enthält kein einziges Skript. Alles, was du siehst, ist CSS und HTML. Scroll-Animationen, Tooltips, Tabs, ein Farbwähler, eine Karte, die du selbst breiter ziehen kannst.

0 KBJavaScript auf dieser Seite
gescrollt, gezählt von CSS

Dein Browser kann animation-timeline: scroll() noch nicht. Der Zähler und der Fortschrittsbalken fehlen deshalb, der Rest der Seite funktioniert.

Du hast reduzierte Bewegung eingestellt. Alle Scroll-Animationen sind aus, jeder Zustand ist direkt sichtbar.

Server Component0 Event-Handler0 Hooks1 CSS-Datei

Scrollen, der Rest erklärt sich

Feature
@property + animation-timeline: scroll(root)
Ersetzt
Einen Scroll-Listener, requestAnimationFrame und einen Zähler, der Zahlen ins DOM schreibt. Die Zahl oben ist ein CSS-Counter, dessen Wert eine registrierte Custom Property liefert.
So sieht es aus
@property --n { syntax: "<integer>"; initial-value: 0; inherits: false }
.pct { animation: count linear both; animation-timeline: scroll(root); counter-reset: n var(--n) }
.pct::after { content: counter(n) " %" }

01 · Scroll-getriebene Animationen

Die Timeline ist der Scrollbalken.

Eine Animation muss nicht an der Uhr hängen. Mit animation-timeline: view() läuft sie genau so weit, wie das Element im Sichtfenster ist. Kein IntersectionObserver, kein Schwellwert, kein Skript.

view()Die Timeline eines Elements: sichtbar von unten bis oben.
entryDie Phase, in der das Element gerade hereinkommt.
coverDie volle Strecke vom ersten bis zum letzten Pixel.

Dein Browser kann view()-Timelines noch nicht. Die Karten stehen deshalb einfach da, und das Bild bleibt still.

Feature
animation-timeline: view() + animation-range
Ersetzt
IntersectionObserver mit Klassen-Umschaltung und alle Scroll-Bibliotheken, die dafür sonst im Bundle liegen. Das Parallax-Bild verschiebt sich über eine benannte view-timeline des Rahmens.
So sieht es aus
.card { animation: fly-in linear both;
        animation-timeline: view();
        animation-range: entry 0% cover 40%; }

02 · Sticky-Stack

Karten, die sich stapeln.

Vier Abschnitte kleben per position: sticky oben fest. Jede Karte schrumpft und dunkelt ab, sobald die nächste hereinkommt. Was hier hereinkommt, weiß die Karte über die Timeline ihres Nachfolgers.

Ohne Scroll-Timelines stapeln sich die Karten trotzdem, sie schrumpfen nur nicht.

1 / 4

Sticky hält die Karte.

top: 5.5rem, mehr braucht es nicht, damit die Karte hängen bleibt, bis der Stapel zu Ende ist.

2 / 4

timeline-scope macht Geschwister sichtbar.

Normalerweise sehen nur Nachfahren eine benannte Timeline. Mit timeline-scope auf dem Stapel darf Karte 1 die Timeline von Karte 2 abhören.

3 / 4

entry 0% bis entry 100%.

Während die nächste Karte von unten hereinfährt, läuft auf der vorherigen die Animation von Skala 1 auf 0,9.

4 / 4

Und die letzte bleibt, wie sie ist.

Kein Nachfolger, keine Timeline, keine Animation. Der Effekt, den man von Apple-Produktseiten kennt, in 30 Zeilen CSS.

Feature
position: sticky + timeline-scope + view-timeline-name
Ersetzt
Einen Scroll-Handler, der für jede Karte die Position des Nachfolgers misst und daraus Transform-Werte rechnet.
So sieht es aus
.stack { timeline-scope: --c1, --c2, --c3, --c4 }
.card:nth-child(2) { view-timeline-name: --c2 }
.card:nth-child(1) { animation-timeline: --c2; animation-range: entry 0% entry 100% }

03 · Anchor Positioning & Popover

Tooltips, die wissen, wo ihr Knopf ist.

Ein Tooltip hängt an seinem Knopf wie an einem Anker. Passt er oben nicht mehr hin, klappt er nach unten. Das Popover öffnet sich mit dem popover-Attribut und schließt sich per Escape oder Klick daneben, eingebaut im Browser.

Dein Browser kann Anchor Positioning noch nicht. Die Tooltips sitzen deshalb klassisch absolut über dem Knopf, das Popover in der Bildschirmmitte.

Dein Browser kann @starting-style noch nicht, deshalb erscheinen Tooltip und Popover ohne Einblendung.

Ein echtes Popover.

Top Layer, Light-Dismiss, Fokusverwaltung, alles vom Browser. Es hängt unter dem Knopf, weil der Knopf ein anchor-name trägt. Steht der Knopf am unteren Rand, klappt es nach oben.

Feature
anchor-name / position-anchor / position-area / @starting-style
Ersetzt
Floating UI oder Popper, eigene Klick-außerhalb-Logik und einen Portal-Container für das Overlay.
So sieht es aus
.btn { anchor-name: --pop }
[popover] { position-anchor: --pop; position-area: bottom span-right; position-try-fallbacks: flip-block }
@starting-style { [popover]:popover-open { opacity: 0 } }

04 · Text & Container

Satz, der sich selbst setzt.

Überschriften auf dieser Seite sind mit text-wrap: balance gesetzt, Absätze mit pretty. Der Browser sucht den Umbruch, der keine einzelnen Wörter auf der letzten Zeile zurücklässt.

Drop Caps waren lange ein Fall für Floats mit handgemessenem Zeilenabstand. initial-letter: 3 2 sagt stattdessen, was gemeint ist: drei Zeilen hoch, zwei Zeilen tief versenkt. Der Browser rechnet die Grundlinie selbst aus, unabhängig von der Schrift. Ändert sich die Schriftgröße, bleibt der Buchstabe im Lot.

Dieser Absatz brennt sich Zeile für Zeile ein, während er durch das Sichtfenster wandert. Ein Verlauf, in Text geschnitten, gesteuert von einer Prozentzahl, die am Scrollbalken hängt.

Ohne Scroll-Timeline steht der Absatz oben komplett eingefärbt da.

Zieh die Karte breiter.

Unten rechts sitzt ein Griff (resize: horizontal). Ab 30rem Breite wechselt die Karte per Container-Query ins Zwei-Spalten-Layout. Auf Touch-Geräten gibt es den Griff nicht.

Schmal · gestapeltBreit · zwei Spalten

Eine Karte, zwei Layouts.

Die Karte fragt nicht das Fenster, sondern ihren eigenen Container.

Dieser Satz existiert nur ab 30rem. Er ist nicht ausgeblendet, er wurde eingeblendet.

Feature
@container + resize + initial-letter + text-wrap
Ersetzt
ResizeObserver plus Klassen-Umschaltung, Float-Drop-Caps mit Pixelwerten und Widow-Vermeidung per Skript.
So sieht es aus
.box { container-type: inline-size; resize: horizontal; overflow: auto }
@container (min-width: 30rem) { .cq { grid-template-columns: 8rem 1fr } }
p::first-letter { initial-letter: 3 2 }

05 · :has()-Zustandsmaschine

Zustand ohne State.

Unsichtbare Radios und Checkboxen halten den Zustand. Der Rest der Seite liest ihn per :has(:checked). Das ist keine Spielerei, das ist ein vollständiger Zustandsautomat, den der Browser mitbringt.

Stimmung

Ich bin ein Block.

Hintergrund, Schrift, Farbe, Rundung und Abstand kommen aus Custom Properties. Welche gelten, entscheidet ein Selektor weiter oben im Baum, der nach den Radios schaut.

Drei Radios mit demselben name, drei Labels, drei Panels. Der Container prüft, welches Radio gewählt ist, und zeigt das passende Panel.

Radios sind von Haus aus per Pfeiltaste wechselbar. Der Fokus bleibt sichtbar, weil das Label auf :focus-visible des Radios reagiert.

Was CSS nicht kann: den Zustand in die URL schreiben, ihn speichern oder auf einen Server schicken. Wer das braucht, braucht ein Skript, und sollte es dann auch nehmen.

Warum ist das Akkordeon exklusiv?

Alle drei details tragen denselben name. Öffnet sich eins, schließt der Browser die anderen. Ohne Attribut wären es drei unabhängige Klappen.

Ist das barrierefrei?

details und summary sind native Elemente mit Rolle, Tastaturbedienung und Zustand. Besser wird es mit eigenem Markup selten.

Und wenn ich es animieren will?

Dafür gibt es ::details-content und interpolate-size. Beides ist neuer als der Rest hier, deshalb bleibt es auf dieser Seite beim harten Umschalten.

Feature
:has(:checked) + <details name>
Ersetzt
useState, Klassen-Toggles und ein Tab- oder Akkordeon-Widget aus der Komponentenbibliothek.
So sieht es aus
.state:has(#ink:checked) .panel { --bg: var(--ink); --fg: var(--paper) }
.tabs:has(#tab-2:checked) [data-tab="2"] { display: block }
<details name="acc"> …exklusive Gruppe, nativ…

06 · Farben

Eine Zahl, alle Farben.

Der Farbton ist eine registrierte Custom Property mit Syntax <integer>. Wählst du unten einen anderen, wechselt die ganze Seite: Akzent, Verläufe, Buttons, das Parallax-Bild oben. Und weil die Property typisiert ist, kann sie sogar weich übergehen.

Farbton

20 % + white
50 % + white
--acc
60 % + black
hue + 180
light-dark()

Das letzte Feld nutzt light-dark(): eine Farbe für hell, eine für dunkel, der Browser nimmt die zur Systemeinstellung passende. Die Seite folgt deinem System, nicht dem Rest der Website. Das fett und das kursiv in diesem Abschnitt sind per @scope eingefärbt, der Code-Block unten bleibt davon unberührt, weil er das Ende des Scopes ist.

Feature
@property + oklch() + color-mix() + light-dark() + @scope
Ersetzt
Eine Farbfunktion in JavaScript, die Paletten ausrechnet und als Inline-Styles verteilt, plus ein Theme-Provider für Hell und Dunkel.
So sieht es aus
.page:has(#hue-3:checked) { --hue: 150 }
--acc: oklch(58% 0.19 var(--hue)); --tint: color-mix(in oklch, var(--acc) 20%, white);
@scope (.palette) to (pre) { strong { color: var(--acc) } }

07 · Was das bedeutet

Weniger Bundle, weniger Bugs, mehr Nachsehen.

Jede Zeile JavaScript, die du nicht ausliefern musst, kann nicht langsam laden, nicht bei der Hydration stolpern und nicht im falschen Moment einen Listener vergessen. Scroll-Animationen laufen im Compositor, nicht im Main Thread. Tooltips und Popover bekommen Fokusverwaltung und Escape gratis.

Die Rechnung hat eine Rückseite: Browser-Support. Ein Teil dieser Seite läuft heute nur in Chromium und Safari. Firefox zieht bei Scroll-Timelines und Anchor Positioning nach, hat es aber Stand heute noch hinter einer Einstellung. Deshalb steht bei jedem Abschnitt ein @supports-Fallback, der ehrlich sagt, was fehlt, statt kaputt zu wirken. Genau das ist die Arbeit: nicht das Feature, sondern der Weg ohne das Feature.

FeatureChrome / EdgeFirefoxSafari
animation-timeline: scroll() / view()ja seit 115teils hinter Flagja seit 26
@propertyja seit 85ja seit 128ja seit 16.4
anchor-name / position-areaja seit 125teils hinter Flagja seit 26
popover-Attributja seit 114ja seit 125ja seit 17
@starting-style + allow-discreteja seit 117ja seit 129ja seit 17.5
text-wrap: balance / prettyja 114 / 117teils balance ja, pretty spaetja 17.5 / 26
initial-letterja seit 110nein Fallback: floatja seit 9 (Prefix)
@container + resizeja seit 105ja seit 110ja seit 16
:has()ja seit 105ja seit 121ja seit 15.4
<details name="…">ja seit 120ja seit 130ja seit 17.2
@scopeja seit 118ja seit 143ja seit 17.4
oklch() / color-mix() / light-dark()ja seit 123ja seit 120ja seit 17.5

Stand: Kenntnisstand des Autors, September 2026. Verbindlich ist caniuse.com.

Diese Seite: 1 Server Component, 1 CSS-Datei, 0 Skripte. Die Navigation oben gehört zur Website und zählt nicht mit.