Zum Inhalt
← Scroll & Section-Animationen

Raster/Layout formiert sich beim Scrollen

Ein Monitoring-Dashboard setzt sich beim Scrollen zusammen: Neun Kacheln steigen in ihre Fächer, fliegen aus dem Chaos ein oder fächern sich aus einem Stapel auf, erst dann löst sich der Pin.

Eine Bildsektion wird gepinnt. Beim Scrollen fliegen die Rasterelemente aus unterschiedlichen Startpunkten an ihre Zielposition, etwa aus der Mitte, von den Rändern oder aus einem Stapel. Erst wenn das Raster vollständig ist, löst sich der Pin. Oft wird das fertige Raster danach zur Navigation, ein Klick öffnet das Projekt.

Das Layout selbst wird zur Animation. Aus Unordnung entsteht sichtbar Ordnung, das zeigt Konstruktion, Fülle und Kuratierung zugleich. Ein Galerie-Einstieg bekommt so eine Inszenierung.

Wann einsetzen

Portfolio- und Projektübersichten, Fotografie-Portfolios, Bildarchive, Kollektionen, Studio-Intros und Kampagnen mit vielen Motiven. Auch Produktübersichten, deren Module ein Gesamtbild ergeben, etwa ein Dashboard.

Wann nicht

Bei weniger als etwa acht Elementen. Wenn einzelne Bilder wichtig sind und schnell anklickbar sein müssen. Auf schwacher Hardware mit vielen Videos im Raster.

Varianten

  • Gepinnter Aufbau: Elemente steigen gestaffelt in ihre Fächer
  • Zerstreut → Raster: zufällige Startpositionen und Drehungen
  • Stapel → Raster: alle Elemente liegen in der Mitte und fächern sich auf
  • Zoom-out vom Einzelbild zum Raster oder umgekehrt zum Vollbild
  • Gegenläufige Spalten
  • 3D-Perspektiv-Raster: Elemente kippen in Position

Umsetzung

Den Endzustand als normales CSS-Grid bauen. Den Startzustand relativ zur Endposition setzen, zum Beispiel alle Elemente in der Mitte gestapelt oder zufällige x, y und rotation. Dann eine gsap.timeline mit ScrollTrigger (pin, scrub) auf den Endzustand fahren: tl.fromTo(items, start, { x: 0, y: 0, rotation: 0, scale: 1, ease: 'power2.out', stagger: { each: 0.1, from: 'random' } }). Die Staffelung kann auch nach Abstand zur Mitte laufen (stagger: { grid: 'auto', from: 'center' }). Alternativ mit Flip: den Zustand des Streulayouts aufnehmen, Klasse umschalten und Flip.from scrollgekoppelt abspielen. Bilder mit festen Seitenverhältnissen einbinden, damit nichts springt. Fallstrick: Mit invalidateOnRefresh setzen from-Tweens, die noch nicht begonnen haben, nach einem Refresh ihren Startzustand nicht neu; besser fromTo nutzen und bei Größenänderung neu bauen.

Robust umsetzen

Telefon. Weniger Spalten und kürzerer Pin. Oder ganz ohne Pin: jedes Element einzeln einblenden, sobald es ins Bild kommt.

Ohne Bewegung. Kein Pin, das Raster steht sofort im Endzustand.

Barrierefrei. Die DOM-Reihenfolge entspricht dem Endlayout. Während des Pins keine fokussierbaren Elemente außerhalb des sichtbaren Bereichs. Bilder, die Projekte verlinken, brauchen einen Alternativtext.

Code

<!DOCTYPE html>
<!--
  Raster/Layout formiert sich beim Scrollen
  Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/layout-formation-scroll/
  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>Raster formiert sich beim Scrollen · 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: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; 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; }

/* Welt: Rezept saas-produkt-praezise. Graustufen tragen alles, ein Markenton (Seezeichen-Gelb). */
:root {
  --grund: #08090a; --panel: #111213; --panel-2: #161718; --linie: #242728; --linie-2: #34383b;
  --text: #f7f8f8; --text-2: #d0d6e0; --leise: #8a8f98;
  --marke: #f4c542; --marke-text: #0b0b0a;
  --ok: #59d499; --warn: #ff9f43; --stoer: #ff6161;
  --ui: cubic-bezier(0.25, 0.46, 0.45, 0.94);   /* easeOutQuad, UI 160 ms */
  --breite: 1200px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.ziffern { font-variant-numeric: tabular-nums; }
a { color: inherit; text-decoration: none; }
.innen { width: 100%; max-width: var(--breite); margin: 0 auto; }

/* ---------- Kopf und Hero ---------- */
.kopf { padding: 0 clamp(16px, 4vw, 48px); }
.leiste { height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; letter-spacing: -.012em; }
.marke svg { width: 20px; height: 20px; }
.leiste nav { display: flex; gap: 28px; font-size: 14px; color: var(--text-2); }
.leiste nav a, .leiste__rechts a { transition: color .16s var(--ui); }
.leiste nav a:hover, .leiste__rechts a:hover { color: var(--text); }
.leiste__rechts { display: flex; align-items: center; gap: 16px; font-size: 14px; color: var(--text-2); }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 8px; font: 500 14px/1 'Geist', sans-serif; letter-spacing: -.006em; border: 1px solid transparent; transition: background-color .16s var(--ui), border-color .16s var(--ui); }
.knopf--primaer { background: var(--marke); color: var(--marke-text); }
.knopf--primaer:hover { background: #ffd35c; }
.knopf--zweit { border-color: var(--linie-2); color: var(--text); }
.knopf--zweit:hover { background: var(--panel-2); }
.leiste .knopf { height: 32px; font-size: 13px; color: var(--marke-text); }

.hero { padding: clamp(56px, 10vh, 120px) 0 clamp(56px, 9vh, 104px); display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); column-gap: clamp(32px, 6vw, 96px); row-gap: 24px; align-items: end; }
.hero__titel { display: grid; gap: 28px; justify-items: start; }
.hero__text { display: grid; gap: 22px; }
.hinweis { display: inline-flex; width: fit-content; align-items: center; gap: 10px; height: 28px; padding: 0 12px 0 10px; border: 1px solid var(--linie); border-radius: 999px; font: 500 12px/1 'Geist Mono', monospace; color: var(--text-2); }
.hinweis i { width: 6px; height: 6px; border-radius: 50%; background: var(--marke); }
.hero h1 { font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 580; letter-spacing: -.022em; line-height: 1; max-width: 13ch; }
.hero h1 span { color: var(--leise); }
.hero p { font-size: 1.0625rem; line-height: 1.6; letter-spacing: -.006em; color: var(--text-2); max-width: 52ch; }
.hero__knoepfe { display: flex; gap: 10px; flex-wrap: wrap; }
.hero__knoepfe .knopf { height: 40px; padding: 0 16px; }
.hero .hero__fuss { font-size: 11px; line-height: 1.4; color: var(--leise); letter-spacing: .14em; padding-top: 18px; border-top: 1px solid var(--linie); max-width: none; }

/* ---------- Bühne: wird gepinnt, Raster formiert sich ---------- */
.buehne { height: 100svh; display: flex; flex-direction: column; gap: clamp(14px, 2.4vh, 24px); padding: clamp(18px, 3.4vh, 36px) clamp(16px, 4vw, 48px); overflow: clip; border-top: 1px solid var(--linie); }
.buehne__kopf { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap; }
.buehne__kopf h2 { font-size: clamp(1.75rem, 1rem + 1.7vw, 2.5rem); font-weight: 580; letter-spacing: -.018em; line-height: 1.1; margin-top: 10px; max-width: 22ch; text-wrap: balance; }
.steuer { display: grid; gap: 10px; justify-items: end; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: color .16s var(--ui), border-color .16s var(--ui), background-color .16s var(--ui); }
.modi button:hover { color: var(--text); border-color: var(--linie-2); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.werte { font-size: 10px; letter-spacing: .1em; text-align: right; }
.werte b { color: var(--text-2); font-weight: 500; }
.zustand { display: inline-flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; border-radius: 999px; background: var(--panel-2); border: 1px solid var(--linie); color: var(--text-2); font-size: 10px; }
.zustand i { width: 6px; height: 6px; border-radius: 50%; background: var(--leise); transition: background-color .16s var(--ui), box-shadow .16s var(--ui); }
.zustand.fertig i { background: var(--ok); box-shadow: 0 0 0 3px rgba(89, 212, 153, .18); }

.raster { flex: 1; min-height: 0; max-height: 760px; display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-rows: repeat(3, minmax(0, 1fr)); gap: 12px; }
.zelle { position: relative; border-radius: 14px; min-width: 0; }
/* Leeres Fach: Skelett des Endlayouts, wird von der Kachel überdeckt */
.zelle::before { content: ''; position: absolute; inset: 0; border: 1px dashed #2a2d30; border-radius: inherit; }
.zelle::after { content: attr(data-fach); position: absolute; left: 14px; top: 12px; font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .14em; color: #3a3e42; }
.raster[data-variante='aufsteigen'] .zelle { overflow: hidden; }
.z-uptime { grid-area: 1 / 1 / 2 / 6; } .z-p95 { grid-area: 1 / 6 / 2 / 10; } .z-ruf { grid-area: 1 / 10 / 2 / 13; }
.z-vorfall { grid-area: 2 / 1 / 4 / 5; } .z-regionen { grid-area: 2 / 5 / 3 / 8; } .z-status { grid-area: 2 / 8 / 3 / 13; }
.z-regel { grid-area: 3 / 5 / 4 / 8; } .z-deploy { grid-area: 3 / 8 / 4 / 11; } .z-checks { grid-area: 3 / 11 / 4 / 13; }

.kachel { position: absolute; inset: 0; z-index: 1; background: var(--panel); border: 1px solid var(--linie); border-radius: 14px; padding: clamp(12px, 1.6vh, 16px) 16px; display: flex; flex-direction: column; gap: 10px; overflow: hidden; will-change: transform; transition: border-color .16s var(--ui); }
@media (hover: hover) { .kachel:hover { border-color: var(--linie-2); } }
.kachel__kopf { display: flex; justify-content: space-between; align-items: center; gap: 10px; white-space: nowrap; }
.kachel__kopf .mono { font-size: 10px; }
.kachel__kopf em { font: 500 11px/1 'Geist Mono', monospace; font-style: normal; color: var(--leise); }
.zahl { font-size: clamp(26px, 2.4vw, 38px); font-weight: 560; letter-spacing: -.022em; line-height: 1; font-variant-numeric: tabular-nums; }
.zahl small { font-size: .45em; font-weight: 500; letter-spacing: 0; color: var(--leise); margin-left: 4px; }
.fuss { margin-top: auto; }
.klein { font-size: 13px; line-height: 1.4; color: var(--text-2); letter-spacing: -.006em; }
.punkt { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex: none; }
.punkt.ok { background: var(--ok); } .punkt.warn { background: var(--warn); } .punkt.stoer { background: var(--stoer); }

.wert { display: flex; align-items: center; gap: 8px; font-weight: 500; font-size: 15px; letter-spacing: -.01em; }
/* Verfügbarkeit */
.balken { display: flex; gap: 2px; height: clamp(24px, 4.4vh, 40px); margin-top: auto; }
.balken span { flex: 1; border-radius: 1.5px; background: var(--ok); opacity: .8; }
.balken .w { background: var(--warn); opacity: 1; } .balken .s { background: var(--stoer); opacity: 1; }
.achse { display: flex; justify-content: space-between; font: 500 10px/1 'Geist Mono', monospace; color: #7f848c; }
/* Antwortzeit */
.kurve { flex: 1; min-height: 0; width: 100%; display: block; }
/* Rufbereitschaft */
.person { display: flex; align-items: center; gap: 12px; }
.person b { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; background: var(--marke); color: var(--marke-text); font: 600 13px/1 'Geist', sans-serif; flex: none; }
.person strong { display: block; font-weight: 500; font-size: 15px; letter-spacing: -.01em; }
/* Vorfälle */
.vorfaelle { list-style: none; display: grid; gap: 2px; }
.vorfaelle li { display: grid; grid-template-columns: 7px 1fr auto; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--linie); }
.vorfaelle li:last-child { border-bottom: 0; }
.vorfaelle strong { display: block; font-weight: 500; font-size: 14px; letter-spacing: -.008em; }
.vorfaelle span { font-size: 12.5px; color: var(--leise); }
.vorfaelle time { font: 500 11px/1 'Geist Mono', monospace; color: var(--leise); }
.vorfaelle .neu strong { color: var(--text); }
/* Regionen */
.regionen { display: grid; gap: 7px; margin-top: auto; }
.regionen div { display: grid; grid-template-columns: 70px 1fr 48px; align-items: center; gap: 10px; font-size: 12.5px; color: var(--text-2); }
.regionen i { height: 4px; border-radius: 2px; background: var(--linie-2); position: relative; overflow: hidden; }
.regionen i::after { content: ''; position: absolute; inset: 0 auto 0 0; width: var(--w); background: var(--text-2); border-radius: inherit; }
.regionen em { font: 500 11px/1 'Geist Mono', monospace; font-style: normal; text-align: right; color: var(--leise); }
/* Statusseite */
.browser { display: flex; align-items: center; gap: 8px; height: 24px; padding: 0 10px; border-radius: 6px; background: var(--panel-2); font: 500 11px/1 'Geist Mono', monospace; color: var(--leise); }
.browser svg { width: 10px; height: 10px; }
.gesamt { display: flex; align-items: center; gap: 10px; font-weight: 500; font-size: 15px; letter-spacing: -.01em; }
.komponenten { display: grid; gap: 7px; margin-top: auto; }
.komponenten div { display: grid; grid-template-columns: 84px 1fr 52px; align-items: center; gap: 10px; font-size: 12.5px; color: var(--text-2); }
.mini { display: flex; gap: 1.5px; height: 12px; }
.mini span { flex: 1; border-radius: 1px; background: var(--ok); opacity: .7; }
.mini .w { background: var(--warn); opacity: 1; }
.komponenten em { font: 500 11px/1 'Geist Mono', monospace; font-style: normal; text-align: right; color: var(--leise); }
/* Alarmregel */
.regel { font: 400 12.5px/1.75 'Geist Mono', monospace; color: var(--text-2); margin-top: auto; white-space: pre; }
.regel b { font-weight: 400; color: var(--leise); }
.regel u { text-decoration: none; color: var(--marke); }
.schalter { width: 28px; height: 16px; border-radius: 999px; background: var(--marke); position: relative; flex: none; }
.schalter::after { content: ''; position: absolute; right: 2px; top: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--marke-text); }
/* Deploys */
.deploys { list-style: none; display: grid; gap: 8px; margin-top: auto; font: 500 12px/1 'Geist Mono', monospace; color: var(--text-2); }
.deploys li { display: grid; grid-template-columns: 7px 1fr auto; gap: 10px; align-items: center; }
.deploys time { color: var(--leise); }

/* ---------- Abspann ---------- */
.abspann { padding: clamp(72px, 12vh, 140px) clamp(16px, 4vw, 48px) 48px; border-top: 1px solid var(--linie); }
.abspann h2 { font-size: clamp(2rem, 1.4rem + 2vw, 3rem); font-weight: 580; letter-spacing: -.018em; line-height: 1.1; max-width: 18ch; }
.abspann > .innen > p { margin-top: 18px; font-size: 1.0625rem; line-height: 1.6; color: var(--text-2); max-width: 56ch; }
.fakten { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 48px; border-top: 1px solid var(--linie); }
.fakten div { padding: 22px 22px 0 0; display: grid; gap: 8px; }
.fakten div + div { padding-left: 22px; border-left: 1px solid var(--linie); }
.fakten strong { font-size: 2rem; font-weight: 560; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.sitemap { display: grid; grid-template-columns: 1.4fr repeat(4, 1fr); gap: 24px; margin-top: clamp(64px, 10vh, 120px); padding-top: 32px; border-top: 1px solid var(--linie); font-size: 13.5px; color: var(--text-2); }
.sitemap ul { list-style: none; display: grid; gap: 9px; }
.sitemap .mono { margin-bottom: 4px; }
.sitemap p { line-height: 1.6; color: var(--leise); font-size: 13px; }
.sitemap .zustand { display: inline-flex; width: fit-content; white-space: nowrap; margin-top: 16px; font-size: 10px; line-height: 1; color: var(--text-2); }

/* ---------- Reduzierte Bewegung: Endlayout statisch, kein Pin ---------- */
.reduziert .kachel { transform: none !important; }
.reduziert .buehne { height: auto; min-height: 100svh; }
.reduziert .raster { min-height: 560px; }
.reduziert .werte, .reduziert .modi { display: none; }

/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .abspann { display: none; }
.vorschau .buehne { border-top: 0; }

/* ---------- Mittlere Breiten ---------- */
@media (max-width: 1100px) {
  .leiste nav { display: none; }
}
@media (max-width: 900px) {
  .hero { grid-template-columns: minmax(0, 1fr); }
  .vorfaelle li:nth-child(4) { display: none; }
}
@media (max-height: 760px) and (min-width: 720px) {
  .z-vorfall .vorfaelle li:nth-child(4) { display: none; }
  .kachel { gap: 8px; }
}
/* ---------- Telefon: zwei Spalten, kein Pin ---------- */
@media (max-width: 719px) {
  .leiste__rechts > a:not(.knopf) { display: none; }
  .hero h1 { max-width: 11ch; }
  .buehne { height: auto; padding-block: 48px 64px; }
  .buehne__kopf { align-items: flex-start; }
  .steuer { justify-items: start; }
  .werte { text-align: left; }
  .raster { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: none; grid-auto-rows: auto; max-height: none; gap: 10px; }
  .zelle { grid-area: auto !important; grid-column: span 2 !important; min-height: 150px; }
  .z-p95, .z-ruf, .z-regel, .z-deploy { grid-column: span 1 !important; }
  .z-vorfall { min-height: 280px; } .z-p95 { min-height: 170px; } .z-ruf { min-height: 170px; }
  .kachel { padding: 14px; }
  .regionen div { grid-template-columns: 64px 1fr 44px; }
  .komponenten div { grid-template-columns: 78px 1fr 48px; }
  .regel { font-size: 11.5px; }
  .z-ruf .klein { font-size: 12px; }
  .z-ruf .kachel__kopf em, .z-p95 .kachel__kopf em { display: none; }
  .vorfaelle li:nth-child(4) { display: grid; }
  .fakten { grid-template-columns: 1fr; }
  .fakten div + div { padding-left: 0; border-left: 0; border-top: 1px solid var(--linie); padding-bottom: 22px; }
  .sitemap { grid-template-columns: 1fr 1fr; }
  .sitemap > div:first-child { grid-column: span 2; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<header class="kopf">
  <div class="leiste innen">
    <a class="marke" href="#" aria-label="Seezeichen, Startseite">
      <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5 15 8H5z" fill="#f4c542"/><path d="M10 9 15 15.5H5z" fill="#f4c542"/><rect x="9.3" y="15.5" width="1.4" height="3" fill="#8a8f98"/></svg>
      Seezeichen
    </a>
    <nav aria-label="Hauptmenü"><a href="#">Produkt</a><a href="#">Statusseiten</a><a href="#">Preise</a><a href="#">Doku</a><a href="#">Changelog</a></nav>
    <div class="leiste__rechts"><a href="#">Anmelden</a><a class="knopf knopf--primaer" href="#">Kostenlos testen</a></div>
  </div>
  <section class="hero innen">
    <div class="hero__titel">
      <a class="hinweis" href="#"><i></i>Neu: Statusseiten mit eigener Domain</a>
      <h1>Wissen, was läuft. <span>Bevor es klemmt.</span></h1>
    </div>
    <div class="hero__text">
      <p>Seezeichen prüft Websites, APIs und Cronjobs jede Minute aus vier Regionen. Fällt etwas aus, weckt es die richtige Person, nicht das ganze Team.</p>
      <div class="hero__knoepfe"><a class="knopf knopf--primaer" href="#">14 Tage kostenlos testen</a><a class="knopf knopf--zweit" href="#">Demo ansehen</a></div>
      <p class="hero__fuss mono">Server in Frankfurt und Helsinki · DSGVO · Aus Rostock</p>
    </div>
  </section>
</header>

<section class="buehne" aria-labelledby="buehne-titel">
  <div class="buehne__kopf innen">
    <div>
      <p class="mono">02 · Übersicht</p>
      <h2 id="buehne-titel">Ein Bildschirm für alles, was läuft.</h2>
    </div>
    <div class="steuer">
      <p class="zustand mono" data-zustand aria-live="polite"><i></i><span>Raster wird aufgebaut · 0/9</span></p>
      <div class="modi mono" role="group" aria-label="Variante der Formation">
        <button type="button" data-variante="aufsteigen" aria-pressed="true">Aufsteigen</button>
        <button type="button" data-variante="zerstreut" aria-pressed="false">Zerstreut</button>
        <button type="button" data-variante="stapel" aria-pressed="false">Stapel</button>
      </div>
      <p class="werte mono" data-werte></p>
    </div>
  </div>

  <div class="raster innen" data-variante="aufsteigen">
    <div class="zelle z-uptime" data-fach="A1">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Verfügbarkeit · 60 Tage</span><em>shop.warnowkiste.de</em></div>
        <p class="zahl">99,982<small>%</small></p>
        <div class="balken" data-balken="60" aria-label="Tagesbalken: 57 ohne Störung, 2 Warnungen, 1 Ausfall"></div>
        <div class="achse"><span>vor 60 Tagen</span><span>heute</span></div>
      </article>
    </div>
    <div class="zelle z-p95" data-fach="A2">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Antwortzeit p95</span><em>24 h</em></div>
        <p class="zahl">184<small>ms</small></p>
        <svg class="kurve" viewBox="0 0 240 60" preserveAspectRatio="none" aria-hidden="true">
          <defs><linearGradient id="flaeche" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f4c542" stop-opacity=".22"/><stop offset="1" stop-color="#f4c542" stop-opacity="0"/></linearGradient></defs>
          <path data-flaeche fill="url(#flaeche)"/><path data-linie fill="none" stroke="#f4c542" stroke-width="1.5" vector-effect="non-scaling-stroke" stroke-linejoin="round"/>
        </svg>
      </article>
    </div>
    <div class="zelle z-ruf" data-fach="A3">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Rufbereitschaft</span><em>Backend</em></div>
        <div class="person"><b>JB</b><div><strong>Jule Brandt</strong><span class="klein">bis 18:00 Uhr</span></div></div>
        <p class="klein fuss" style="color:var(--leise)">Danach: Mats Kröger</p>
      </article>
    </div>
    <div class="zelle z-vorfall" data-fach="B1">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Vorfälle</span><em>2 offen</em></div>
        <ul class="vorfaelle">
          <li class="neu"><i class="punkt stoer"></i><div><strong>Bilder-CDN Helsinki</strong><span>Zeitüberschreitung</span></div><time>3 min</time></li>
          <li class="neu"><i class="punkt warn"></i><div><strong>Checkout-API</strong><span>p95 bei 412 ms</span></div><time>12 min</time></li>
          <li><i class="punkt ok"></i><div><strong>Login</strong><span>Behoben nach 6 min</span></div><time>08:14</time></li>
          <li><i class="punkt ok"></i><div><strong>Newsletter-Cron</strong><span>Behoben nach 22 min</span></div><time>gestern</time></li>
        </ul>
        <p class="mono fuss">Ø Lösungszeit 14 min</p>
      </article>
    </div>
    <div class="zelle z-regionen" data-fach="B2">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Regionen</span><em>Ping</em></div>
        <p class="wert"><i class="punkt ok"></i>4 von 4 erreichbar</p>
        <div class="regionen">
          <div>Frankfurt<i style="--w:19%"></i><em>38 ms</em></div>
          <div>Helsinki<i style="--w:28%"></i><em>54 ms</em></div>
          <div>Virginia<i style="--w:57%"></i><em>112 ms</em></div>
          <div>Singapur<i style="--w:100%"></i><em>196 ms</em></div>
        </div>
      </article>
    </div>
    <div class="zelle z-status" data-fach="B3">
      <article class="kachel">
        <div class="browser"><svg viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="4.5" width="7" height="5" rx="1" fill="#8a8f98"/><path d="M3 4.5V3a2 2 0 0 1 4 0v1.5" fill="none" stroke="#8a8f98" stroke-width="1.2"/></svg>status.warnowkiste.de</div>
        <p class="gesamt"><i class="punkt ok"></i>Alle Systeme betriebsbereit</p>
        <div class="komponenten">
          <div>Shop<span class="mini" data-mini="40"></span><em>100 %</em></div>
          <div>Zahlung<span class="mini" data-mini="40" data-warn="31"></span><em>99,94 %</em></div>
        </div>
      </article>
    </div>
    <div class="zelle z-regel" data-fach="C1">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Alarmregel</span><span class="schalter" role="img" aria-label="aktiv"></span></div>
        <p class="wert">Checkout zu langsam</p>
        <p class="regel"><b>wenn</b> p95 &gt; <u>400 ms</u>
<b>für </b> 5 min
<b>dann</b> Rufbereitschaft</p>
      </article>
    </div>
    <div class="zelle z-deploy" data-fach="C2">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Deploys</span><em>heute 1</em></div>
        <p class="wert">Ohne Fehler seit 09:42</p>
        <ul class="deploys">
          <li><i class="punkt ok"></i>v2.41.0<time>09:42</time></li>
          <li><i class="punkt warn"></i>v2.40.3<time>gestern</time></li>
          <li><i class="punkt ok"></i>v2.40.2<time>Mo</time></li>
        </ul>
      </article>
    </div>
    <div class="zelle z-checks" data-fach="C3">
      <article class="kachel">
        <div class="kachel__kopf"><span class="mono">Checks</span></div>
        <p class="zahl">1.284</p>
        <p class="klein fuss" style="color:var(--leise)">pro Minute aus 4 Regionen</p>
      </article>
    </div>
  </div>
</section>

<footer class="abspann">
  <div class="innen">
    <h2>Neun Module. Ein Blick. Kein Tab-Chaos.</h2>
    <p>Jede Kachel ist eine echte Ansicht aus Seezeichen. Sie ordnen sie einmal an, danach sieht jede Person im Team dasselbe Bild. Auf dem Bürobildschirm, im Browser und in der App.</p>
    <div class="fakten">
      <div><strong>1 min</strong><span class="mono">Prüfintervall</span></div>
      <div><strong>4</strong><span class="mono">Messregionen</span></div>
      <div><strong>0 €</strong><span class="mono">für 10 Monitore</span></div>
    </div>
    <div class="sitemap">
      <div><a class="marke" href="#"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 1.5 15 8H5z" fill="#f4c542"/><path d="M10 9 15 15.5H5z" fill="#f4c542"/><rect x="9.3" y="15.5" width="1.4" height="3" fill="#8a8f98"/></svg>Seezeichen</a><p style="margin-top:12px">Monitoring und Statusseiten.<br>Entwickelt in Rostock.</p><p class="zustand mono"><i style="background:var(--ok)"></i>Alle Systeme betriebsbereit</p></div>
      <div><p class="mono">Produkt</p><ul><li>Monitore</li><li>Statusseiten</li><li>Rufbereitschaft</li><li>Preise</li></ul></div>
      <div><p class="mono">Ressourcen</p><ul><li>Doku</li><li>API</li><li>Changelog</li><li>Status</li></ul></div>
      <div><p class="mono">Unternehmen</p><ul><li>Über uns</li><li>Jobs</li><li>Kontakt</li></ul></div>
      <div><p class="mono">Rechtliches</p><ul><li>Impressum</li><li>Datenschutz</li><li>Sicherheit</li><li>AV-Vertrag</li></ul></div>
    </div>
  </div>
</footer>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
(() => {
  gsap.registerPlugin(ScrollTrigger);
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const buehne = document.querySelector('.buehne');
  const raster = document.querySelector('.raster');
  const zellen = [...raster.querySelectorAll('.zelle')];
  const kacheln = zellen.map(z => z.querySelector('.kachel'));
  const zustand = document.querySelector('[data-zustand]');
  const werteFeld = document.querySelector('[data-werte]');

  /* ---------- Inhalte der Kacheln (fest, damit jede Runde gleich aussieht) ---------- */
  const zufall = (s => () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; })(1855);
  document.querySelectorAll('[data-balken]').forEach(el => {
    el.innerHTML = Array.from({ length: +el.dataset.balken }, (_, i) => `<span${i === 17 || i === 44 ? ' class="w"' : i === 31 ? ' class="s"' : ''}></span>`).join('');
  });
  document.querySelectorAll('[data-mini]').forEach(el => {
    el.innerHTML = Array.from({ length: +el.dataset.mini }, (_, i) => `<span${String(i) === el.dataset.warn ? ' class="w"' : ''}></span>`).join('');
  });
  (() => {
    const n = 48, pkt = [];
    for (let i = 0; i < n; i++) {
      const tag = Math.sin(i / n * Math.PI * 2 - 1.2) * 9;          // Tageskurve
      const spitze = i === 33 ? -22 : i === 34 ? -12 : 0;            // Lastspitze am Nachmittag
      pkt.push([i / (n - 1) * 240, Math.max(4, Math.min(56, 34 - tag + spitze + (zufall() - .5) * 8))]);
    }
    const d = pkt.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
    document.querySelector('[data-linie]').setAttribute('d', d);
    document.querySelector('[data-flaeche]').setAttribute('d', d + ' L240 60 L0 60 Z');
  })();
  const streu = kacheln.map(() => ({ x: zufall() * 2 - 1, y: zufall() * 2 - 1, r: zufall() * 2 - 1 }));

  /* ---------- Varianten: Startzustand relativ zum Fach im Endlayout ---------- */
  const KURVE = 'power2.out';                 // gemessen (Stewart & Partners), = cubic-bezier(0.33, 1, 0.68, 1)
  const STAGGER = 0.1;                         // gemessen: each .1
  const ZIEL = { x: 0, y: 0, xPercent: 0, yPercent: 0, rotation: 0, scale: 1 };   // Endlayout = normales CSS-Grid
  const PIN = 1.5;                             // Pin-Länge in Viewport-Höhen
  const VORLAUF = () => html.classList.contains('vorschau') ? 0 : 0.6;   // Formation beginnt, wenn die Bühne bei 60 % der Höhe ist
  const VARIANTEN = {
    aufsteigen: {
      werte: '<b>yPercent 100 → 0</b> im Fach · stagger 0,1 random',
      start: () => ({ yPercent: 100 }),
      telefon: () => ({ yPercent: 100 }),
      von: 'random',
    },
    zerstreut: {
      werte: '<b>x ±40vw · y ±30vh · rot ±10° → 0</b> · stagger 0,1 random',
      start: () => ({ x: i => streu[i].x * 40 + 'vw', y: i => streu[i].y * 30 + 'vh', rotation: i => streu[i].r * 10 }),
      telefon: () => ({ x: i => streu[i].x * 40 + 'vw', y: 60, rotation: i => streu[i].r * 10 }),
      von: 'random',
    },
    stapel: {
      werte: '<b>Stapel Mitte · Breite 260 px · rot ±3° → Raster</b> · stagger 0,1 from end',
      start: () => ({
        x: i => mitte(i).x, y: i => mitte(i).y - i * 4,
        scale: i => Math.min(1, 260 / zellen[i].offsetWidth),
        rotation: i => streu[i].r * 3,
      }),
      telefon: () => ({ scale: .86, y: 48, rotation: i => (i % 2 ? 1 : -1) * 3 }),
      von: 'end',
    },
  };
  function mitte(i) {   // Abstand Fachmitte → Rastermitte
    const r = raster.getBoundingClientRect(), z = zellen[i].getBoundingClientRect();
    return { x: r.left + r.width / 2 - (z.left + z.width / 2), y: r.top + r.height / 2 - (z.top + z.height / 2) };
  }

  let variante = 'aufsteigen', mm = null, st = null;
  const breit = '(min-width: 720px)';

  function zaehlen() {
    const n = kacheln.filter(k => Math.abs(gsap.getProperty(k, 'x')) < 1 && Math.abs(gsap.getProperty(k, 'y')) < 1 && Math.abs(gsap.getProperty(k, 'yPercent')) < 1 && Math.abs(gsap.getProperty(k, 'rotation')) < .2 && Math.abs(gsap.getProperty(k, 'scale') - 1) < .005).length;
    const fertig = n === kacheln.length;
    zustand.classList.toggle('fertig', fertig);
    const text = fertig ? 'Alle 9 Module live' : `Raster wird aufgebaut · ${n}/9`;
    if (zustand.lastChild.textContent !== text) zustand.lastChild.textContent = text;
  }

  function bauen() {
    if (mm) mm.revert();
    st = null;
    raster.dataset.variante = variante;
    document.querySelectorAll('[data-variante]').forEach(b => b.tagName === 'BUTTON' && b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
    const V = VARIANTEN[variante];
    werteFeld.innerHTML = V.werte + `<br>${KURVE} · scrub 0,5 · ab top 60 % · Pin +${PIN * 100} %`;
    mm = gsap.matchMedia();
    if (reduziert()) { gsap.set(kacheln, { clearProps: 'transform' }); zaehlen(); return; }

    mm.add(breit, () => {
      // Desktop: Bühne pinnen; die Formation beginnt schon beim Hereinscrollen und endet im Pin
      const pin = ScrollTrigger.create({ trigger: buehne, start: 'top top', end: () => '+=' + innerHeight * PIN, pin: true });
      const tl = gsap.timeline({
        defaults: { ease: KURVE, duration: 1 },
        onUpdate: zaehlen,
        scrollTrigger: { start: () => pin.start - innerHeight * VORLAUF(), end: () => pin.end, scrub: 0.5, onRefresh: zaehlen },
      });
      tl.fromTo(kacheln, V.start(), { ...ZIEL, stagger: { each: STAGGER, from: V.von } }, 0);
      tl.to({}, { duration: 0.45 });   // fertiges Raster kurz halten, dann löst sich der Pin
      st = { get start() { return tl.scrollTrigger.start; }, get end() { return pin.end; } };
    });
    mm.add('(max-width: 719px)', () => {
      // Telefon: kein Pin, jede Kachel formiert sich beim Eintritt einzeln
      kacheln.forEach((k, i) => {
        const s = V.telefon();
        Object.keys(s).forEach(key => { if (typeof s[key] === 'function') s[key] = s[key](i); });
        gsap.fromTo(k, s, { ...ZIEL, ease: KURVE, scrollTrigger: { trigger: zellen[i], start: 'top bottom', end: 'top 62%', scrub: 0.5 }, onUpdate: zaehlen });
      });
    });
    zaehlen();
  }

  function setzeVariante(v, scrollHalten) {
    const y = scrollY;
    variante = v;
    bauen();
    ScrollTrigger.refresh();
    if (scrollHalten) { scrollTo(0, y); ScrollTrigger.update(); }
  }
  document.querySelectorAll('button[data-variante]').forEach(b => b.addEventListener('click', () => { pause(); setzeVariante(b.dataset.variante, true); }));
  new MutationObserver(() => { const r = reduziert(); if (r !== bauen.r) { bauen.r = r; setzeVariante(variante, true); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
  bauen.r = reduziert();
  bauen();

  /* ---------- Vorführung: Seite scrollt selbst durch den Pin, jede Runde eine Variante ---------- */
  const RUNDE = 7600, FOLGE = ['aufsteigen', 'zerstreut', 'stapel'];
  let uhr = null, t0 = 0, vorher = 0;
  const bereich = () => {
    if (st) return [st.start, st.end];
    const r = raster.getBoundingClientRect();
    return [Math.max(0, scrollY + r.top - innerHeight * .9), scrollY + r.bottom - innerHeight * .6];
  };
  function vorfuehren() {
    pause();
    if (reduziert()) { neustart(); return; }
    ScrollTrigger.refresh();
    let [a] = bereich(); scrollTo(0, a);
    t0 = performance.now(); vorher = 0;
    const schritt = () => {
      const p = ((performance.now() - t0) % RUNDE) / RUNDE;
      if (p < vorher) {   // neue Runde: nächste Variante, zurück an den Anfang
        scrollTo(0, bereich()[0]);
        setzeVariante(FOLGE[(FOLGE.indexOf(variante) + 1) % FOLGE.length]);
      }
      vorher = p;
      const [s, e] = bereich();
      const q = Math.min(1, Math.max(0, (p - 0.06) / 0.62));
      scrollTo(0, s + (e - s) * (0.5 - 0.5 * Math.cos(q * Math.PI)));
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() { pause(); scrollTo(0, 0); setzeVariante(variante); }
  ['wheel', 'touchstart', 'keydown'].forEach(e => addEventListener(e, () => { if (!html.classList.contains('vorschau')) pause(); }, { passive: true }));
  document.fonts.ready.then(() => ScrollTrigger.refresh());
  // Startwerte (Fachmitte, vw/vh) werden beim Bau gemessen: bei geänderter Fenstergröße neu bauen
  let groesse = [innerWidth, innerHeight], umbau = null;
  addEventListener('resize', () => {
    clearTimeout(umbau);
    umbau = setTimeout(() => {
      const neu = [innerWidth, innerHeight];
      if (neu[0] !== groesse[0] || (matchMedia('(hover: hover)').matches && neu[1] !== groesse[1])) setzeVariante(variante, true);
      groesse = neu;
    }, 200);
  });
  addEventListener('load', () => ScrollTrigger.refresh());

})();
</script>
</body>
</html>