Animierter Filter- und Ansichtswechsel (FLIP / View Transitions)
Filter und Raster-Liste-Umschalter, bei denen jede Projektkachel sichtbar an ihren neuen Platz gleitet: wahlweise mit GSAP Flip oder nativ mit View Transitions.
Standard für Filter und Raster-Liste-Wechsel in allen Browsern, auch mit verschachtelten Bildern, Stagger und mitlaufender Containerhöhe.
Beim Filtern oder beim Wechsel zwischen Raster und Liste springen die Kacheln nicht, sondern gleiten von ihrer alten an ihre neue Position und Größe. Ablauf mit GSAP Flip: Zustand aller Kacheln erfassen (Flip.getState), Filter oder Layoutklasse anwenden, dann animiert Flip.from jede verbleibende Kachel an ihren neuen Platz. Ausgefilterte Kacheln schrumpfen auf scale 0,8 und blenden aus, neue blenden umgekehrt ein. Ein kleiner Versatz (20 bis 50 ms) und eine gemeinsame Kurve halten die Bewegung zusammen. Nativ geht dasselbe mit View Transitions: Jede Kachel bekommt einen Namen, der Browser animiert die Positionen selbst.
Ursache und Wirkung bleiben sichtbar: Man sieht, welche Elemente bleiben und wohin sie gehen. Das gibt Orientierung und zeigt, dass es dieselben Projekte sind, nur anders sortiert. Veränderung wird nachvollziehbar statt sprunghaft und wirkt hochwertig. Mit element-scoped View Transitions bleibt der Rest der Seite bedienbar, und mehrere Übergänge können parallel laufen.
Wann einsetzen
Übersichten mit Kategorien und mindestens acht Einträgen: Portfolios, Projekt- und Archivseiten, Shops mit Bild- und Listenmodus, Team- oder Veranstaltungsseiten.
Wann nicht
Bei Hunderten Elementen gleichzeitig: Das kostet Leistung, bei View Transitions je ein Schnappschuss pro Name. Nicht, wenn der Filter die Seite serverseitig neu lädt. Nicht bei sehr wenigen Elementen oder wenn eine Ansicht klar genügt. Nicht für Zustandswechsel per Hover.
Varianten
- Kategorie-Filter: verbleibende Kacheln gleiten nach, ausgefilterte skalieren heraus
- Raster ↔ Liste: dieselben Elemente in anderer Anordnung, gewählte Ansicht wird gemerkt
- Dichte-Regler (2/4/8 Spalten) oder groß ↔ klein
- Raster → Split-Screen → Vollbild oder Raster → Slideshow
- Scroll-gekoppelt: Layoutwechsel gepinnt und mit ScrollTrigger gescrubbt, Kinderbilder skalieren von 2 auf 1
- Ein Element wandert per Flip über mehrere Scroll-Wegpunkte
- Nativ: document.startViewTransition() oder element.startViewTransition() (ab Chrome 147) mit view-transition-name bzw. match-element und view-transition-class
Umsetzung
GSAP Flip: const state = Flip.getState(items); dann Filter anwenden (display: none) oder Layoutklasse wechseln; Flip.from(state, { duration: 0.6–0.8, ease: 'power3.inOut', absolute: true, stagger: 0.03, onEnter: els => gsap.fromTo(els, { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1 }), onLeave: els => gsap.to(els, { opacity: 0, scale: 0.8 }) }). Bei absolute: true die Containerhöhe von alt nach neu mitführen, sonst springt der Inhalt darunter. Ändert sich die Größe stark (Raster ↔ Liste), Bilder als verschachtelte Ziele mitnehmen (nested: true) und den neu umbrechenden Text kurz ausblenden. In React: useLayoutEffect bzw. batch, Ziele neu übergeben.
Nativ mit View Transitions: if (grid.startViewTransition) grid.startViewTransition(update); else if (document.startViewTransition) document.startViewTransition(update); else update(). CSS: .card { view-transition-name: match-element; view-transition-class: card } (oder eindeutige Namen je Element); ::view-transition-group(.card) { animation-duration: .6s; animation-timing-function: cubic-bezier(.76,0,.24,1) }; ::view-transition-old(.card):only-child und ::view-transition-new(.card):only-child bekommen eigene Scale-Animationen für Aus- und Eintritt. Versatz über animation-delay je Name. Die Pseudo-Ebene blockiert Hover, deshalb ::view-transition { pointer-events: none } (Hinweis von Cyd Stumpel). Der Scope-Root bekommt bei element-scoped Übergängen automatisch contain: layout.
Umschalter als Buttons mit aria-pressed oder als Radiogruppe; die gewählte Ansicht in localStorage merken (mit try/catch).
Robust umsetzen
Telefon. Gleiches Verhalten mit kürzerer Dauer (etwa 450 ms). Filter als waagerecht wischbare Chips. Oft genügt eine Liste oder ein zweispaltiges Raster; der Umschalter bleibt.
Ohne Bewegung. Kein Gleiten der Positionen: sofort umschalten oder höchstens 150 ms einblenden.
Barrierefrei. Filter als Buttons mit aria-pressed, Ansicht-Umschalter mit aria-pressed oder als Radiogruppe. Ergebnisanzahl in einer aria-live-Region melden. Der Fokus bleibt auf dem gedrückten Button. Ausgefilterte Elemente nach der Animation mit display: none wirklich aus dem Accessibility-Baum nehmen. Die Reihenfolge der Elemente ist in beiden Ansichten gleich.
Code
<!DOCTYPE html>
<!--
Animierter Filter- und Ansichtswechsel (FLIP / View Transitions)
Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/flip-zustandswechsel/
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>Animierter Filter- und Ansichtswechsel (FLIP / View Transitions) · 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: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Archivo'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/archivo:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-stretch: 62% 125%; 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; }
/* Regnitzwerk e. V., Bamberg – Rezept „Verein & Non-Profit: Wirkung zeigen“:
tiefer Markenton, helle Töne derselben Farbe, warmes Grau, Gelb nur für „Spenden“. */
:root {
--grund: #ffffff; --flaeche: #eaf2f6; --marke: #0a3550; --marke-2: #2c6a8c;
--text: #1f2421; --leise: #5b6670; --linie: #dcdbd3; --signal: #ffc61a;
--kurve: cubic-bezier(0.76, 0, 0.24, 1); /* power3.inOut, Kurve des Musters */
--ui: 150ms; /* Knöpfe, Chips */
--vt-dauer: 600ms; --vt-aus: 400ms; --vt-ein: 600ms; --vt-ein-versatz: 200ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Archivo', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr); }
button { font: inherit; color: inherit; cursor: pointer; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* Kopfzeile */
.leiste { display: flex; align-items: center; gap: 28px; padding: 0 clamp(16px, 4vw, 40px); height: 60px; background: var(--marke); color: #fff; }
.marke { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; margin-right: auto; }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { font: 800 22px/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .01em; }
.marke small { font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: rgba(255, 255, 255, .6); padding-top: 3px; }
.nav { display: flex; gap: 24px; list-style: none; padding: 0; font-size: 15px; font-weight: 500; }
.nav a { color: rgba(255, 255, 255, .78); text-decoration: none; transition: color var(--ui); }
.nav a:hover, .nav a[aria-current] { color: #fff; }
.spenden { display: inline-flex; align-items: center; height: 38px; padding: 0 18px; border-radius: 999px; background: var(--signal); color: var(--text); font-weight: 600; font-size: 15px; text-decoration: none; transition: filter var(--ui); }
.spenden:hover { filter: brightness(1.05); }
/* Bühne */
.buehne { min-width: 0; grid-template-columns: minmax(0, 1fr); padding: clamp(22px, 3.4vw, 40px) clamp(16px, 4vw, 40px) 24px; display: grid; align-content: start; gap: 20px; }
.kopf { display: flex; justify-content: space-between; align-items: end; gap: 20px 32px; flex-wrap: wrap; }
.kopf .mono { color: var(--marke-2); margin-bottom: 10px; }
h1 { font: 800 clamp(2.25rem, 1.2rem + 3.6vw, 4.5rem)/.95 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .005em; color: var(--marke); text-wrap: balance; }
.anzahl { display: flex; align-items: baseline; gap: 10px; color: var(--leise); font-size: 15px; }
.anzahl b { font: 800 clamp(2.4rem, 1.6rem + 2vw, 3.4rem)/1 'Big Shoulders', sans-serif; color: var(--marke); font-variant-numeric: tabular-nums; }
.werkzeug { min-width: 0; display: flex; justify-content: space-between; align-items: center; gap: 16px; border-block: 1px solid var(--linie); padding: 10px 0; }
.filter { display: flex; gap: 6px; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.filter::-webkit-scrollbar { display: none; }
.chip { flex: none; display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--linie); background: none; font-size: 14px; font-weight: 500; transition: background var(--ui), color var(--ui), border-color var(--ui); }
.chip i { font: 500 11px/1 'Geist Mono', monospace; font-style: normal; color: var(--leise); transition: color var(--ui); }
.chip:hover { border-color: var(--marke-2); }
.chip[aria-pressed='true'] { background: var(--marke); border-color: var(--marke); color: #fff; }
.chip[aria-pressed='true'] i { color: rgba(255, 255, 255, .6); }
.ansicht { position: relative; flex: none; display: flex; padding: 3px; border-radius: 999px; background: var(--flaeche); }
.ansicht button { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 13px; border: 0; background: none; border-radius: 999px; font-size: 14px; font-weight: 500; color: var(--leise); transition: color var(--ui); }
.ansicht button svg { width: 14px; height: 14px; }
.ansicht button[aria-pressed='true'] { color: var(--marke); }
.ansicht .schieber { position: absolute; z-index: 0; top: 3px; left: 3px; height: 30px; border-radius: 999px; background: #fff; box-shadow: 0 1px 2px rgba(10, 53, 80, .12); transition: translate .6s var(--kurve), width .6s var(--kurve); }
:focus-visible { outline: 2px solid var(--marke-2); outline-offset: 2px; }
/* Projekte: Raster */
.projekte { list-style: none; padding: 0; position: relative; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 22px 16px; }
.karte { min-width: 0; }
.karte.aus { display: none; }
.karte a { display: flex; flex-direction: column; gap: 12px; color: inherit; text-decoration: none; }
.bild { position: relative; aspect-ratio: 16 / 10; border-radius: 10px; overflow: hidden; background: var(--flaeche); }
.bild svg { display: block; width: 100%; height: 100%; transition: scale .6s var(--kurve); }
.karte a:hover .bild svg { scale: 1.04; }
.text { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: baseline; }
.thema { color: var(--marke-2); }
.termin { color: var(--leise); text-align: right; }
.titel { grid-column: 1 / -1; font: 700 clamp(19px, 1.1rem + .4vw, 24px)/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .01em; color: var(--marke); }
.ort, .plaetze { font-size: 14px; color: var(--leise); }
.plaetze { text-align: right; color: var(--text); font-weight: 500; }
/* Projekte: Liste (gleiche Elemente, gleiche Reihenfolge, andere Anordnung) */
.projekte.liste { grid-template-columns: 1fr; gap: 0; }
.liste .karte a { display: grid; grid-template-columns: 64px 1fr; align-items: center; gap: 18px; padding: 7px 0; border-bottom: 1px solid var(--linie); }
.liste .bild { border-radius: 6px; }
.liste .text { grid-template-columns: minmax(0, 1.4fr) 120px minmax(0, 1fr) 90px 130px; gap: 16px; align-items: center; }
.liste .titel { grid-column: auto; grid-row: 1; font-size: 22px; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.liste .thema { grid-column: 2; grid-row: 1; }
.liste .ort { grid-column: 3; grid-row: 1; }
.liste .termin { grid-column: 4; grid-row: 1; text-align: left; }
.liste .plaetze { grid-column: 5; grid-row: 1; }
/* Native Variante: benannte Elemente, eine Klasse für alle Karten (view-transition-class) */
.projekte.vt .bild { view-transition-group: nearest; }
::view-transition { pointer-events: none; }
::view-transition-group(.karte), ::view-transition-group(.bild) { animation-duration: var(--vt-dauer); animation-timing-function: var(--kurve); }
::view-transition-old(.karte):only-child, ::view-transition-old(.bild):only-child { animation: vt-aus var(--vt-aus) var(--kurve) both; }
::view-transition-new(.karte):only-child, ::view-transition-new(.bild):only-child { animation: vt-ein var(--vt-ein) var(--kurve) var(--vt-ein-versatz) both; }
/* Kartenflächen nicht strecken: Schnappschuss in Originalgröße, Gruppe schneidet zu, Text blendet über */
::view-transition-group(.karte) { overflow: clip; }
::view-transition-old(.karte), ::view-transition-new(.karte) { width: auto; height: auto; }
::view-transition-old(.karte) { animation: vt-text-aus calc(var(--vt-dauer) * .3) linear both; }
::view-transition-new(.karte) { animation: vt-text-ein calc(var(--vt-dauer) * .45) linear calc(var(--vt-dauer) * .55) both; }
@keyframes vt-text-aus { to { opacity: 0; } }
@keyframes vt-text-ein { from { opacity: 0; } }
@keyframes vt-aus { to { opacity: 0; scale: .8; } }
@keyframes vt-ein { from { opacity: 0; scale: .8; } }
/* Ausgefilterte Karten verlieren vor dem Start ihren Namen (Chromium bricht element-scoped Übergänge sonst ab)
und blenden mit dem Schnappschuss der Liste selbst aus: ::view-transition-old(root) am Scope-Element */
.projekte::view-transition-old(root) { animation: vt-text-aus var(--vt-aus) var(--kurve) both; }
.projekte::view-transition-new(root) { animation: none; }
/* Zweiter, paralleler Übergang: die Trefferzahl rollt in ihrem eigenen Scope */
.nativ .anzahl b { display: inline-block; view-transition-name: anzahl-zahl; }
.anzahl::view-transition-group(anzahl-zahl) { overflow: clip; animation-duration: var(--vt-dauer); animation-timing-function: var(--kurve); }
.anzahl::view-transition-old(anzahl-zahl) { animation: zahl-raus var(--vt-dauer) var(--kurve) both; }
.anzahl::view-transition-new(anzahl-zahl) { animation: zahl-rein var(--vt-dauer) var(--kurve) both; }
@keyframes zahl-raus { to { translate: 0 -70%; opacity: 0; } }
@keyframes zahl-rein { from { translate: 0 70%; opacity: 0; } }
/* Sichtbarer Hinweis, welche Weiche greift */
.weiche { display: inline-flex; align-items: center; gap: 6px; }
.weiche i { width: 7px; height: 7px; border-radius: 50%; background: #2f9e5b; }
.weiche[data-stufe="document"] i { background: #e0a100; }
.weiche[data-stufe="flip"] i { background: #c2412d; }
/* Technik-Umschalter und Werte, dezent */
.technik { display: flex; justify-content: space-between; align-items: center; gap: 10px 24px; flex-wrap: wrap; padding: 12px clamp(16px, 4vw, 40px) 16px; border-top: 1px solid var(--linie); color: var(--leise); background: var(--grund); }
.modi { display: flex; align-items: center; gap: 4px; }
.modi span { margin-right: 8px; }
.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 11px; transition: color var(--ui), background var(--ui), border-color var(--ui); }
.modi button:hover { color: var(--text); border-color: var(--leise); }
.modi button[aria-pressed='true'] { background: var(--text); border-color: var(--text); color: #fff; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { color: var(--text); font-weight: 500; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 50; width: 22px; height: 22px; pointer-events: none; opacity: 0; transition: transform .7s var(--kurve), opacity .3s; }
.vorfuehrung .zeiger { opacity: 1; }
.zeiger.drueckt path { transform: scale(.82); transform-origin: 3px 2px; }
.zeiger path { transition: transform var(--ui); }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .technik { position: fixed; left: 0; right: 0; bottom: 0; }
/* Reduzierte Bewegung: kein Gleiten, nur kurzes Einblenden */
:root.reduziert *, :root.reduziert ::view-transition-group(*) { transition-duration: .01ms !important; animation-duration: .01ms !important; }
.reduziert .karte a:hover .bild svg { scale: 1; }
@media (max-width: 1080px) { .nav { display: none; } }
@media (max-width: 900px) {
.projekte { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.liste .text { grid-template-columns: minmax(0, 1fr) 110px 80px 120px; }
.liste .ort { display: none; }
.liste .termin { grid-column: 3; } .liste .plaetze { grid-column: 4; }
}
@media (max-width: 640px) {
.leiste { height: 54px; }
.marke small { display: none; }
.spenden { height: 34px; padding: 0 14px; }
.werkzeug { flex-direction: column; align-items: stretch; gap: 10px; }
.filter { margin-inline: calc(-1 * clamp(16px, 4vw, 40px)); padding-inline: clamp(16px, 4vw, 40px); }
.ansicht { align-self: flex-start; }
.projekte { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 12px; }
.text { grid-template-columns: 1fr; }
.termin, .plaetze { text-align: left; }
.termin { grid-row: 1; }
.thema { display: none; }
.liste .karte a { grid-template-columns: 56px 1fr; gap: 14px; }
.liste .text { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; }
.liste .titel { grid-column: 1 / -1; grid-row: 1; font-size: 20px; }
.liste .termin { grid-column: 1; grid-row: 2; }
.liste .plaetze { grid-column: 2; grid-row: 2; text-align: right; font-size: 13px; }
.werte { display: none; }
.technik .modi { width: 100%; }
}
@media (max-width: 380px) { .ansicht button span { display: none; } }
@media (hover: none) {
/* Touch: kein Hover-Zoom; Chips wischen statt umbrechen; Zeiger nie zeigen */
.karte a:hover .bild svg { scale: 1; }
.chip:hover { border-color: var(--linie); }
.chip[aria-pressed='true']:hover { border-color: var(--marke); }
}
</style>
</head>
<body>
<header class="leiste">
<a class="marke" href="#start" aria-label="Regnitzwerk e. V., Startseite">
<svg viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="14" fill="none" stroke="#fff" stroke-width="1.6"/><path d="M6 12.5c2.2-1.6 3.8-1.6 6 0s3.8 1.6 6 0 3.8-1.6 6 0M6 17.5c2.2-1.6 3.8-1.6 6 0s3.8 1.6 6 0 3.8-1.6 6 0" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/></svg>
<b>Regnitzwerk</b><small>e. V. · Bamberg</small>
</a>
<ul class="nav">
<li><a href="#projekte" aria-current="page">Projekte</a></li>
<li><a href="#mitmachen">Mitmachen</a></li>
<li><a href="#transparenz">Transparenz</a></li>
<li><a href="#verein">Über uns</a></li>
</ul>
<a class="spenden" href="#spenden">Spenden</a>
</header>
<main class="buehne" id="projekte">
<div class="kopf">
<div>
<p class="mono">Mitmachen 2026 · Fluss, Gärten, Artenschutz</p>
<h1>Such dir deinen Einsatz aus.</h1>
</div>
<p class="anzahl" data-anzahl aria-live="polite"><b>8</b> <span>Projekte</span></p>
</div>
<div class="werkzeug">
<div class="filter" role="group" aria-label="Projekte nach Thema filtern" data-filter>
<button class="chip" type="button" data-thema="alle" aria-pressed="true">Alle <i>8</i></button>
<button class="chip" type="button" data-thema="wasser" aria-pressed="false">Wasser <i>2</i></button>
<button class="chip" type="button" data-thema="gaerten" aria-pressed="false">Gärtnerstadt <i>2</i></button>
<button class="chip" type="button" data-thema="arten" aria-pressed="false">Artenschutz <i>2</i></button>
<button class="chip" type="button" data-thema="bildung" aria-pressed="false">Bildung <i>2</i></button>
</div>
<div class="ansicht" role="group" aria-label="Ansicht" data-ansicht>
<span class="schieber" data-schieber aria-hidden="true"></span>
<button type="button" data-ansicht-wert="raster" aria-pressed="true"><svg viewBox="0 0 14 14" aria-hidden="true"><path fill="currentColor" d="M0 0h6v6H0zM8 0h6v6H8zM0 8h6v6H0zM8 8h6v6H8z"/></svg><span>Raster</span></button>
<button type="button" data-ansicht-wert="liste" aria-pressed="false"><svg viewBox="0 0 14 14" aria-hidden="true"><path fill="currentColor" d="M0 1h14v2.4H0zM0 5.8h14v2.4H0zM0 10.6h14V13H0z"/></svg><span>Liste</span></button>
</div>
</div>
<ul class="projekte" data-projekte>
<li class="karte" data-thema="wasser"><a href="#uferputz">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#eaf2f6"/>
<path d="M0 96c40-18 80-22 120-12s90 6 120-6 60-10 80-4v40H0z" fill="#cfe4ee"/>
<rect x="128" y="52" width="64" height="58" fill="#dcdbd3"/><path d="M122 54 160 30l38 24z" fill="#c8553d"/>
<rect x="153" y="16" width="14" height="22" fill="#dcdbd3"/><path d="M151 18 160 6l9 12z" fill="#0a3550"/>
<path d="M138 66h10v14h-10zM155 66h10v14h-10zM172 66h10v14h-10z" fill="#2c6a8c"/>
<path d="M0 108h320v26H0z" fill="#0a3550"/>
<path d="M44 134a34 22 0 0 1 68 0zM208 134a34 22 0 0 1 68 0zM132 134a28 18 0 0 1 56 0z" fill="#2c6a8c"/>
<rect y="134" width="320" height="66" fill="#2c6a8c"/>
<path d="M18 152c10-5 20-5 30 0s20 5 30 0M120 166c10-5 20-5 30 0s20 5 30 0M222 150c10-5 20-5 30 0s20 5 30 0M60 182c10-5 20-5 30 0s20 5 30 0M200 186c10-5 20-5 30 0" fill="none" stroke="#7fb3cc" stroke-width="3" stroke-linecap="round"/>
<rect x="236" y="160" width="12" height="20" rx="3" fill="#eaf2f6" transform="rotate(-24 242 170)"/><circle cx="92" cy="160" r="5" fill="#dcdbd3"/>
</svg></div>
<div class="text"><span class="thema mono">Wasser</span><span class="termin mono">Sa 14.03.</span><h2 class="titel">Uferputz an der Regnitz</h2><span class="ort">Markusbrücke</span><span class="plaetze">26 Plätze frei</span></div>
</a></li>
<li class="karte" data-thema="arten"><a href="#nistkaesten">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#cfe4ee"/>
<circle cx="250" cy="52" r="26" fill="#eaf2f6"/>
<path d="M0 160c60-14 120-16 180-8s100 6 140-2v50H0z" fill="#8db08f"/>
<rect x="128" y="0" width="38" height="200" fill="#0a3550"/>
<path d="M166 70c30-6 60-16 90-34" stroke="#0a3550" stroke-width="7" fill="none" stroke-linecap="round"/>
<rect x="98" y="88" width="56" height="64" rx="3" fill="#b9ae95"/><path d="M90 92 126 64l36 28z" fill="#c8553d"/>
<circle cx="126" cy="114" r="9" fill="#0a3550"/><rect x="123" y="130" width="6" height="12" rx="3" fill="#0a3550"/>
<path d="M218 46c6-6 14-7 20-2 4-4 10-4 12 0-6 0-9 3-11 7-4 7-14 8-20 3l-10 2z" fill="#0a3550"/>
<path d="M60 196c4-14 2-26-4-36M74 196c2-12 6-22 14-28M268 196c-2-12-6-20-14-26" stroke="#4f7a5a" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg></div>
<div class="text"><span class="thema mono">Artenschutz</span><span class="termin mono">Sa 07.02.</span><h2 class="titel">Nistkästen im Hain</h2><span class="ort">Hain, Bootshaus</span><span class="plaetze">12 Plätze frei</span></div>
</a></li>
<li class="karte" data-thema="gaerten"><a href="#saatgut">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#eaf2f6"/>
<circle cx="70" cy="48" r="22" fill="#dcdbd3"/>
<path d="M0 78h320v122H0z" fill="#b9ae95"/>
<path d="M0 86 120 78 0 110zM0 128 150 80 40 200H0zM90 200 175 80 200 80 170 200zM240 200 210 80h20l70 120zM320 150 250 80h30l40 30z" fill="#4f7a5a"/>
<path d="M0 110 120 78 0 128zM40 200 150 80 90 200zM170 200 200 80h10l30 120zM300 200 230 80h20l70 70v50z" fill="#8db08f" opacity=".55"/>
<g transform="rotate(-6 210 130)"><rect x="168" y="98" width="84" height="98" rx="4" fill="#ffffff"/><rect x="176" y="106" width="68" height="44" rx="2" fill="#0a3550"/>
<path d="M196 140c4-14 12-22 14-26 2 4 10 12 14 26" fill="none" stroke="#8db08f" stroke-width="3" stroke-linecap="round"/><path d="M178 160h48M178 170h36M178 180h52" stroke="#dcdbd3" stroke-width="4" stroke-linecap="round"/></g>
</svg></div>
<div class="text"><span class="thema mono">Gärtnerstadt</span><span class="termin mono">So 22.03.</span><h2 class="titel">Saatgutbörse Gärtnerstadt</h2><span class="ort">Mittelstraße</span><span class="plaetze">Tische frei</span></div>
</a></li>
<li class="karte" data-thema="bildung"><a href="#flusslabor">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#2c6a8c"/>
<path d="M0 40c30-8 50-8 80 0s50 8 80 0 50-8 80 0 50 8 80 0" fill="none" stroke="#7fb3cc" stroke-width="3"/>
<path d="M0 170c30-8 50-8 80 0s50 8 80 0 50-8 80 0 50 8 80 0" fill="none" stroke="#7fb3cc" stroke-width="3"/>
<path d="M206 136 270 196" stroke="#0a3550" stroke-width="18" stroke-linecap="round"/>
<circle cx="160" cy="96" r="66" fill="#eaf2f6" stroke="#0a3550" stroke-width="10"/>
<path d="M128 92c6-8 14-8 20 0s14 8 20 0" fill="none" stroke="#2c6a8c" stroke-width="5" stroke-linecap="round"/>
<ellipse cx="176" cy="70" rx="12" ry="6" fill="#4f7a5a"/><path d="M188 70l10-6M188 70l10 6" stroke="#4f7a5a" stroke-width="3" stroke-linecap="round"/>
<circle cx="140" cy="122" r="4" fill="#c8553d"/><circle cx="152" cy="128" r="3" fill="#c8553d"/><circle cx="190" cy="116" r="5" fill="#b9ae95"/>
</svg></div>
<div class="text"><span class="thema mono">Bildung</span><span class="termin mono">Mai–Juli</span><h2 class="titel">Flusslabor für Schulklassen</h2><span class="ort">Hainbadestelle</span><span class="plaetze">8 Termine frei</span></div>
</a></li>
<li class="karte" data-thema="wasser"><a href="#bachpaten">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#8db08f"/>
<rect width="320" height="44" fill="#eaf2f6"/>
<path d="M0 44c40-10 70 6 110 0s60-12 100-4 80 8 110 0v8H0z" fill="#4f7a5a"/>
<path d="M190 44c-30 30 40 50-10 80s-90 30-70 76" fill="none" stroke="#2c6a8c" stroke-width="40" stroke-linecap="round"/>
<path d="M190 44c-30 30 40 50-10 80s-90 30-70 76" fill="none" stroke="#7fb3cc" stroke-width="4" stroke-dasharray="14 18" stroke-linecap="round"/>
<ellipse cx="224" cy="112" rx="14" ry="8" fill="#dcdbd3"/><ellipse cx="88" cy="160" rx="10" ry="6" fill="#dcdbd3"/><ellipse cx="160" cy="70" rx="8" ry="5" fill="#dcdbd3"/>
<path d="M44 120v-40M52 120v-52M60 120v-34M260 172v-44M268 172v-56M276 172v-36" stroke="#0a3550" stroke-width="3" stroke-linecap="round"/>
<ellipse cx="52" cy="66" rx="3" ry="9" fill="#c8553d"/><ellipse cx="268" cy="114" rx="3" ry="9" fill="#c8553d"/>
</svg></div>
<div class="text"><span class="thema mono">Wasser</span><span class="termin mono">ganzjährig</span><h2 class="titel">Bachpatenschaft Sendelbach</h2><span class="ort">Gaustadt</span><span class="plaetze">Paten gesucht</span></div>
</a></li>
<li class="karte" data-thema="gaerten"><a href="#gemeinschaftsbeet">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#cfe4ee"/>
<path d="M0 140h320v60H0z" fill="#dcdbd3"/>
<path d="M40 112c8-30 18-44 22-50 4 10 6 30 2 50M78 112c-2-26 6-44 14-52 2 14 2 34-4 52M120 112c6-22 16-34 22-38 0 12-4 28-12 38M200 112c-4-24 2-40 10-48 4 12 4 32-2 48M236 112c4-28 14-42 20-46 2 12 0 32-8 46" fill="#4f7a5a"/>
<path d="M60 112c-8-20-20-30-28-32 0 12 8 26 18 32M104 112c4-18 14-26 22-28-2 12-8 22-16 28M222 112c-6-16-16-24-24-26 2 10 8 20 16 26" fill="#8db08f"/>
<circle cx="164" cy="104" r="11" fill="#c8553d"/><circle cx="182" cy="108" r="8" fill="#c8553d"/>
<path d="M28 112h264l-12 48H40z" fill="#b9ae95"/><path d="M34 126h252M38 142h244" stroke="#0a3550" stroke-opacity=".25" stroke-width="2"/>
<path d="M250 60h34v26a8 8 0 0 1-8 8h-18a8 8 0 0 1-8-8z" fill="#0a3550"/><path d="M250 66l-22-14" stroke="#0a3550" stroke-width="6" stroke-linecap="round"/><path d="M284 66c10 0 12 16 2 18" fill="none" stroke="#0a3550" stroke-width="4"/>
</svg></div>
<div class="text"><span class="thema mono">Gärtnerstadt</span><span class="termin mono">donnerstags</span><h2 class="titel">Gemeinschaftsbeet Wunderburg</h2><span class="ort">Wunderburg</span><span class="plaetze">3 Beete frei</span></div>
</a></li>
<li class="karte" data-thema="arten"><a href="#kroetenzaun">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#eaf2f6"/>
<path d="M30 0h18v150H30zM98 0h12v150H98zM176 0h22v150h-22zM256 0h14v150h-14z" fill="#0a3550"/>
<path d="M0 26c40 14 90 10 130 0s110-12 190 6V0H0z" fill="#4f7a5a"/>
<path d="M0 140h320v60H0z" fill="#8db08f"/>
<path d="M0 138h320v18H0z" fill="#dcdbd3"/><path d="M20 128v32M90 128v32M160 128v32M230 128v32M300 128v32" stroke="#b9ae95" stroke-width="5" stroke-linecap="round"/>
<ellipse cx="190" cy="178" rx="28" ry="15" fill="#b9ae95"/><circle cx="174" cy="166" r="7" fill="#b9ae95"/><circle cx="196" cy="164" r="7" fill="#b9ae95"/>
<circle cx="174" cy="165" r="3" fill="#0a3550"/><circle cx="196" cy="163" r="3" fill="#0a3550"/>
<path d="M164 190c-6 2-12 2-16-2M216 190c6 2 12 2 16-2" stroke="#b9ae95" stroke-width="5" stroke-linecap="round" fill="none"/>
<rect x="58" y="164" width="30" height="24" rx="3" fill="#2c6a8c"/>
</svg></div>
<div class="text"><span class="thema mono">Artenschutz</span><span class="termin mono">ab 01.03.</span><h2 class="titel">Krötenzaun Hauptsmoorwald</h2><span class="ort">Hauptsmoorwald</span><span class="plaetze">Schichten offen</span></div>
</a></li>
<li class="karte" data-thema="bildung"><a href="#ferien">
<div class="bild"><svg viewBox="0 0 320 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="200" fill="#eaf2f6"/>
<circle cx="244" cy="54" r="28" fill="#dcdbd3"/>
<path d="M0 120c40-12 80-12 120 0s80 12 120 0 60-12 80-6v86H0z" fill="#7fb3cc"/>
<path d="M0 146c40-12 80-12 120 0s80 12 120 0 60-12 80-6v60H0z" fill="#2c6a8c"/>
<path d="M0 172c40-10 80-10 120 0s80 10 120 0 60-10 80-4v32H0z" fill="#0a3550"/>
<path d="M92 118h112l-18 22h-76z" fill="#ffffff"/><path d="M148 118V66l-36 52z" fill="#cfe4ee"/><path d="M148 118V66l30 52z" fill="#ffffff"/>
<path d="M92 118h112l-18 22h-76zM148 118V66l-36 52M148 66l30 52" fill="none" stroke="#0a3550" stroke-width="2.5" stroke-linejoin="round"/>
</svg></div>
<div class="text"><span class="thema mono">Bildung</span><span class="termin mono">August</span><h2 class="titel">Ferien-Wasserwerkstatt</h2><span class="ort">ERBA-Insel</span><span class="plaetze">Anmeldung ab Juni</span></div>
</a></li>
</ul>
</main>
<footer class="technik mono">
<div class="modi" role="group" aria-label="Variante">
<span>Variante</span>
<button type="button" data-variante-wahl="flip" aria-pressed="true">GSAP Flip</button>
<button type="button" data-variante-wahl="nativ" aria-pressed="false">Nativ · element-scoped</button>
</div>
<p class="werte" data-werte aria-live="polite"></p>
</footer>
<svg class="zeiger" data-zeiger viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2 19 11.2l-7.2 1.5-3.4 6.8Z" fill="#1f2421" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"/></svg>
<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/Flip.min.js"></script>
<script>
(() => {
gsap.registerPlugin(Flip);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const telefon = matchMedia('(max-width: 640px)');
// Alle Bewegungswerte an einer Stelle (power3.inOut aus den Messwerten des Musters)
const W = {
kurve: 'power3.inOut', css: 'cubic-bezier(0.76, 0, 0.24, 1)',
filter: 0.6, ansicht: 0.8, telefon: 0.45, // Sekunden
versatz: 0.03, // Stagger
aus: 0.4, ein: 0.6, einVersatz: 0.2, // Austritt / Eintritt, scale 0.8 ↔ 1
textEin: 0.45, // Text blendet nach dem Ansichtswechsel ein
fade: 0.15, // reduzierte Bewegung
};
const liste = document.querySelector('[data-projekte]');
const karten = [...liste.querySelectorAll('.karte')];
const bilder = karten.map(k => k.querySelector('.bild'));
const texte = karten.map(k => k.querySelector('.text'));
const chips = [...document.querySelectorAll('[data-filter] .chip')];
const ansichtKnoepfe = [...document.querySelectorAll('[data-ansicht-wert]')];
const schieber = document.querySelector('[data-schieber]');
const variantenKnoepfe = [...document.querySelectorAll('[data-variante-wahl]')];
const anzahl = document.querySelector('[data-anzahl]');
const werte = document.querySelector('[data-werte]');
const zeiger = document.querySelector('[data-zeiger]');
const NAMEN = { alle: 'Projekte', wasser: 'Projekte · Wasser', gaerten: 'Projekte · Gärtnerstadt', arten: 'Projekte · Artenschutz', bildung: 'Projekte · Bildung' };
const VARIANTEN = ['flip', 'nativ'];
// Weiche der nativen Variante: element-scoped (Chrome 147+) → Dokument-Übergang → GSAP Flip
const STUFE = typeof Element.prototype.startViewTransition === 'function' ? 'element'
: typeof document.startViewTransition === 'function' ? 'document' : 'flip';
let thema = 'alle', ansicht = 'raster', variante = 'flip', laufend = null;
const technik = () => (variante === 'nativ' && STUFE !== 'flip' ? 'vt' : 'flip');
// Eindeutige Namen für die native Variante (alternativ: view-transition-name: match-element)
karten.forEach((k, i) => {
k.style.viewTransitionName = `karte-${i + 1}`; k.style.viewTransitionClass = 'karte';
bilder[i].style.viewTransitionName = `bild-${i + 1}`; bilder[i].style.viewTransitionClass = 'bild';
});
// Versatz je Name: 30 ms pro sichtbarer Position
const versatzStil = document.createElement('style');
document.head.appendChild(versatzStil);
function versatzSetzen() {
let n = 0;
versatzStil.textContent = karten.map((k, i) => {
const d = k.classList.contains('aus') ? 0 : Math.round(n++ * W.versatz * 1000);
return `::view-transition-group(karte-${i + 1}),::view-transition-group(bild-${i + 1}){animation-delay:${d}ms}`;
}).join('');
}
function dauer(art) { return telefon.matches ? W.telefon : W[art]; }
function zeigeWerte() {
const d = s => `${Math.round(s * 1000)} ms`;
const weiche = variante !== 'nativ' ? ''
: STUFE === 'element' ? '<span class="weiche" data-stufe="element"><i></i><b>element-scoped aktiv</b></span>'
: STUFE === 'document' ? '<span class="weiche" data-stufe="document"><i></i>Rückfall: <b>document.startViewTransition</b></span>'
: '<span class="weiche" data-stufe="flip"><i></i>Rückfall: <b>GSAP Flip</b> (keine View Transitions)</span>';
werte.innerHTML = weiche + (reduziert()
? '<span>Reduzierte Bewegung: <b>kein Gleiten, Einblenden 150 ms</b></span>'
: technik() === 'flip'
? `<span>Flip.from · <b>power3.inOut</b></span><span>Filter <b>${d(dauer('filter'))}</b></span><span>Ansicht <b>${d(dauer('ansicht'))}</b></span><span>Stagger <b>30 ms</b></span><span>Aus/Ein <b>scale 0.8</b></span>`
: `<span>${STUFE}.startViewTransition · <b>cubic-bezier(.76,0,.24,1)</b></span><span>Gruppe <b>${d(dauer('filter'))}/${d(dauer('ansicht'))}</b></span><span>Versatz <b>30 ms</b></span><span>Ein <b>scale 0.8 → 1</b>, Aus <b>Deckkraft ${d(W.aus)}</b></span>${STUFE === 'element' ? '<span>Zahl <b>eigener Scope, parallel</b></span>' : ''}`);
}
// Zustand ins DOM schreiben (ohne Animation)
function anwenden() {
let n = 0;
karten.forEach(k => { const an = thema === 'alle' || k.dataset.thema === thema; k.classList.toggle('aus', !an); if (an) n++; });
liste.classList.toggle('liste', ansicht === 'liste');
chips.forEach(c => c.setAttribute('aria-pressed', String(c.dataset.thema === thema)));
ansichtKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.ansichtWert === ansicht)));
anzahl.querySelector('b').textContent = n;
anzahl.querySelector('span').textContent = NAMEN[thema];
schieberSetzen();
}
function schieberSetzen() {
const b = ansichtKnoepfe.find(x => x.dataset.ansichtWert === ansicht);
schieber.style.width = b.offsetWidth + 'px';
schieber.style.translate = (b.offsetLeft - 3) + 'px 0';
}
// Ein Zustandswechsel: Flip, View Transition oder (reduziert) kurzes Einblenden
function wechsel(aendern, art) {
if (reduziert()) {
if (laufend) laufend.kill();
aendern(); anwenden();
gsap.set(karten, { clearProps: 'opacity,scale,transform' });
liste.animate([{ opacity: 0 }, { opacity: 1 }], { duration: W.fade * 1000, easing: 'linear' });
return;
}
if (technik() === 'vt') return vtWechsel(aendern, art);
const d = dauer(art);
const stand = Flip.getState([...karten, ...bilder]);
if (laufend) laufend.kill();
const h0 = liste.offsetHeight;
aendern(); anwenden();
const h1 = liste.offsetHeight;
const sichtbar = karten.filter(k => !k.classList.contains('aus'));
laufend = Flip.from(stand, {
targets: [...karten, ...bilder],
absolute: karten, // nur die Karten lösen sich aus dem Raster, Bilder wandern mit (nested)
nested: true,
duration: d, ease: W.kurve,
// Karte und ihr Bild teilen sich denselben Versatz (30 ms je Position)
stagger: (i, el) => W.versatz * Math.max(0, sichtbar.indexOf(el.closest('.karte'))),
onEnter: els => gsap.fromTo(els, { opacity: 0, scale: 0.8 }, { opacity: 1, scale: 1, duration: W.ein, delay: W.einVersatz, ease: W.kurve }),
onLeave: els => gsap.to(els, { opacity: 0, scale: 0.8, duration: W.aus, ease: W.kurve }),
});
// Containerhöhe mitführen, damit nichts darunter springt
laufend.fromTo(liste, { height: h0 }, { height: h1, duration: d, ease: W.kurve, clearProps: 'height' }, 0);
if (art === 'ansicht') {
// Text wird neu umbrochen: erst ausblenden, nach der Bewegung einblenden
laufend.fromTo(sichtbar.map(k => k.querySelector('.text')), { opacity: 0 }, { opacity: 1, duration: W.textEin, ease: 'power1.out', stagger: W.versatz, immediateRender: true }, d * 0.55);
}
}
function vtWechsel(aendern, art) {
const d = dauer(art);
gsap.set([...karten, ...bilder, ...texte], { clearProps: 'opacity,scale,transform' });
[liste, html, anzahl].forEach(el => {
el.style.setProperty('--vt-dauer', Math.round(d * 1000) + 'ms');
});
// Neuen Zustand vorausberechnen: wer fällt weg, wie viele bleiben?
const alt = { thema, ansicht };
aendern();
const neu = { thema, ansicht };
({ thema, ansicht } = alt);
const bleibt = k => neu.thema === 'alle' || k.dataset.thema === neu.thema;
const weg = karten.filter(k => !k.classList.contains('aus') && !bleibt(k));
const h0 = liste.offsetHeight;
const update = () => {
({ thema, ansicht } = neu);
anwenden(); versatzSetzen();
// Höhe der Liste mitführen, sonst springt alles darunter
const h1 = liste.offsetHeight;
if (h1 !== h0) liste.animate([{ height: h0 + 'px' }, { height: h1 + 'px' }], { duration: d * 1000, easing: W.css });
};
versatzSetzen();
if (STUFE === 'element') {
// Wegfallende Karten ohne Namen: sie blenden mit ::view-transition-old(root) der Liste aus
weg.forEach(k => { k.style.viewTransitionName = 'none'; k.querySelector('.bild').style.viewTransitionName = 'none'; });
// Zwei Übergänge gleichzeitig: Trefferzahl im eigenen Scope, Liste in ihrem
const n = karten.filter(bleibt).length;
if (String(n) !== anzahl.querySelector('b').textContent) anzahl.startViewTransition(() => { anzahl.querySelector('b').textContent = n; }).ready.catch(() => {});
const t = liste.startViewTransition(update);
t.ready.catch(() => {});
t.finished.finally(() => karten.forEach((k, i) => { k.style.viewTransitionName = `karte-${i + 1}`; bilder[i].style.viewTransitionName = `bild-${i + 1}`; }));
} else {
const t = document.startViewTransition(update); // Rückfall: Übergang auf dem ganzen Dokument
t.ready.catch(() => {});
}
}
function setzeThema(t) { if (t !== thema) wechsel(() => { thema = t; }, 'filter'); }
function setzeAnsicht(a) { if (a !== ansicht) wechsel(() => { ansicht = a; }, 'ansicht'); }
function setzeVariante(v, schreiben = true) {
if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
variante = v;
document.body.classList.toggle('nativ', v === 'nativ' && STUFE === 'element');
liste.classList.toggle('vt', technik() === 'vt');
variantenKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.varianteWahl === v)));
if (laufend) { laufend.progress(1).kill(); laufend = null; }
gsap.set([...karten, ...bilder, ...texte], { clearProps: 'opacity,scale,transform,width,height,position,top,left' });
if (schreiben) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
zeigeWerte();
}
chips.forEach(c => c.addEventListener('click', () => setzeThema(c.dataset.thema)));
ansichtKnoepfe.forEach(b => b.addEventListener('click', () => setzeAnsicht(b.dataset.ansichtWert)));
variantenKnoepfe.forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.varianteWahl)));
liste.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });
document.querySelectorAll('.leiste a').forEach(a => a.addEventListener('click', e => e.preventDefault()));
telefon.addEventListener('change', zeigeWerte);
addEventListener('resize', schieberSetzen);
new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });
document.fonts.ready.then(schieberSetzen);
// Vorführung: ein gezeichneter Zeiger klickt sich durch Filter und Ansichten
const ABLAUF = [
['thema', 'wasser'], ['thema', 'gaerten'], ['ansicht', 'liste'], ['thema', 'alle'],
['thema', 'bildung'], ['ansicht', 'raster'], ['thema', 'arten'], ['thema', 'alle'],
];
let uhren = [], schritt = 0, runde = 0, aktiv = false;
function spaeter(fn, ms) { uhren.push(setTimeout(fn, ms)); }
function ziel(art, wert) {
return art === 'thema' ? chips.find(c => c.dataset.thema === wert) : ansichtKnoepfe.find(b => b.dataset.ansichtWert === wert);
}
function naechster() {
if (!aktiv) return;
const [art, wert] = ABLAUF[schritt];
const knopf = ziel(art, wert);
const r = knopf.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + r.width * 0.55}px, ${r.top + r.height * 0.6}px)`;
spaeter(() => {
zeiger.classList.add('drueckt');
if (art === 'thema') setzeThema(wert); else setzeAnsicht(wert);
spaeter(() => zeiger.classList.remove('drueckt'), 160);
}, 750);
schritt = (schritt + 1) % ABLAUF.length; // die Vorführung bleibt in der gewählten Variante
spaeter(naechster, 2400);
}
function pause() { uhren.forEach(clearTimeout); uhren = []; aktiv = false; }
function grundzustand() {
pause();
if (laufend) { laufend.progress(1).kill(); laufend = null; }
thema = 'alle'; ansicht = 'raster';
setzeVariante(variante, false);
anwenden();
}
function neustart() { html.classList.remove('vorfuehrung'); grundzustand(); }
function vorfuehren() {
grundzustand();
html.classList.add('vorfuehrung');
zeiger.style.transform = `translate(${innerWidth * 0.62}px, ${innerHeight * 0.9}px)`;
schritt = 0; aktiv = true;
spaeter(naechster, 500);
}
function weiter() { if (!aktiv && html.classList.contains('vorfuehrung')) { aktiv = true; naechster(); } }
// Start in der Variante aus der URL (?v=flip oder ?v=nativ), sonst Grundvariante
setzeVariante(new URLSearchParams(location.search).get('v'), false);
anwenden(); zeigeWerte();
})();
</script>
</body>
</html>