Animationsstil „Kinetisch filmisch“ (inOut-Raumwechsel)
Projekt-Slideshow eines Designstudios: Rahmen fährt 100 %, das Bild darin 30 % gegenläufig, Buchstaben tauschen zufällig versetzt, alles mit einer power4.inOut-Kurve.
Bewegungssprache für große Zustandswechsel wie Slide, Seite oder Raster zu Vollbild. Symmetrische inOut-Kurven mit starkem Anlauf und starkem Auslauf geben der Bewegung Masse. Mehrere Ebenen laufen gleichzeitig mit derselben Kurve, aber unterschiedlichen Distanzen: der Rahmen 100 %, das Bild darin 30 % gegenläufig. Oft in zwei Phasen mit Überlappung.
Es wirkt wie eine Kamerafahrt: kein abruptes Anfahren, kein Ruck am Ende. Die Mitte ist schnell. Dadurch wirkt die Bewegung energisch und trotzdem kontrolliert. Die gegenläufige Bildebene erzeugt Tiefe.
Wann einsetzen
Slideshows, Seitenübergänge, Menü-Overlays, Galerie-Vollbild und Hero-Wechsel.
Wann nicht
Nicht für Einblendungen beim Scrollen, dort wirkt der langsame Anlauf träge. Nicht für Mikrointeraktionen wie Buttons oder Formulare.
Varianten
- power4.inOut, cubic-bezier(0.83, 0, 0.17, 1): dramatisch, 1,2 bis 1,5 s
- power3.inOut, cubic-bezier(0.76, 0, 0.24, 1): Standard, 0,8 bis 1,2 s; in Produktion auch 0,5 bis 0,6 s für Menüs
- power2.inOut, cubic-bezier(0.65, 0, 0.35, 1): sanft, etwa 1,5 s bei großen 3D-Bewegungen
- Zweiphasig: erst expo.out 0,7 s (zurücktreten), dann power4.inOut 1 s ab 0,6 s (Schwenk)
- Extrem: cubic-bezier(1, 0, 0, 1) mit 0,5 s für Navigationslinien
Umsetzung
CSS-Token: --ease-cinematic: cubic-bezier(0.83, 0, 0.17, 1) (power4.inOut); --ease-cinematic-soft: cubic-bezier(0.76, 0, 0.24, 1) (power3.inOut); --ease-inout-gentle: cubic-bezier(0.65, 0, 0.35, 1) (power2.inOut). Dauern 0,9 / 1,2 / 1,5 s. In GSAP dieselben Werte per CustomEase oder die Namen power2/3/4.inOut. Rahmen mit overflow: hidden um xPercent 100 bewegen, das Bild darin um 30 gegenläufig. Buchstaben mit yPercent 101 aus Masken tauschen, stagger { each: 0.03 bis 0.05, from: 'random' }. Eingaben während der Animation sperren (isAnimating) und die letzte puffern. Für echte Seitenwechsel dieselbe Kurve in View Transitions verwenden (::view-transition-old/new mit animation-timing-function).
Robust umsetzen
Telefon. Dauern auf 0,8 bis 1,1 s kürzen. Wischen als Eingabe anbieten.
Ohne Bewegung. Crossfade 300 bis 500 ms mit power1.inOut, ohne Verschiebung und ohne Versatz.
Barrierefrei. Während der Sperre Eingaben puffern statt verwerfen. Den Wechsel über eine aria-live-Region ansagen und den Fokus auf dem Bedienelement lassen. Geteilte Buchstaben mit aria-hidden versehen und den Titel als ganzen Text für Screenreader bereitstellen.
Code
<!DOCTYPE html>
<!--
Animationsstil „Kinetisch filmisch“ (inOut-Raumwechsel)
Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-kinetisch-inout/
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>Animationsstil „Kinetisch filmisch“ · 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: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; 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; }
:root {
/* Die Token des Musters */
--ease-cinematic: cubic-bezier(0.83, 0, 0.17, 1); /* power4.inOut, dramatisch */
--ease-cinematic-soft: cubic-bezier(0.76, 0, 0.24, 1); /* power3.inOut, Standard */
--ease-phase-1: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, Phase 1 */
--dur-cinematic: 1.2s;
--dur-cinematic-soft: 0.9s;
--grund: #0b0b0c; --text: #f2f0ea; --leise: rgba(242, 240, 234, .52); --linie: rgba(242, 240, 234, .16);
--akzent: #d9b26a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: hidden; }
img { display: block; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Bühne */
.buehne { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto; gap: clamp(18px, 3vw, 34px); padding: clamp(18px, 3.2vw, 40px); }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 20px; flex-wrap: wrap; }
.marke { color: var(--text); }
.marke span { color: var(--leise); }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { letter-spacing: inherit; text-transform: inherit; font-size: 11px; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background-color .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(242, 240, 234, .4); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
/* Satzspiegel: Text links, Bildfenster rechts, Titel ragt ins Bild */
.feld { display: grid; grid-template-columns: minmax(0, .82fr) minmax(0, 1.6fr); gap: clamp(20px, 3vw, 44px); align-items: center; min-height: 0; }
.text { position: relative; display: grid; gap: clamp(14px, 1.8vw, 22px); align-content: end; padding-bottom: clamp(4px, 1vw, 14px); }
.stapel { display: grid; }
.stapel > * { grid-area: 1 / 1; }
.maske { overflow: hidden; display: block; }
.rubrik { display: flex; gap: 12px; align-items: center; }
.rubrik i { font-style: normal; color: var(--akzent); }
.titel { position: relative; z-index: 2; font-weight: 600; font-size: clamp(54px, 10.4vw, 156px); line-height: .9; letter-spacing: -.045em; margin-right: clamp(-260px, -16vw, -60px); mix-blend-mode: difference; white-space: nowrap; }
[data-stapel='titel'] { justify-items: start; }
.titel .maske { width: max-content; padding-right: .06em; margin-bottom: -.1em; }
.titel .z { display: inline-block; padding: .04em 0 .1em; will-change: transform; }
.zeile { font: italic 400 clamp(20px, 2.1vw, 30px)/1.2 'Instrument Serif', Georgia, serif; color: var(--text); max-width: 19ch; }
[data-stapel='rubrik'] > .maske > *, [data-stapel='zeile'] > .maske > * { height: 100%; }
[data-stapel='zeile'] > .maske > * { display: block; }
.zeile .maske > * { padding-bottom: .08em; }
.fenster { position: relative; z-index: 1; width: 100%; aspect-ratio: 3 / 2; max-height: min(66svh, 640px); justify-self: end; overflow: hidden; background: #161618; touch-action: pan-y; cursor: grab; user-select: none; }
.fenster:active { cursor: grabbing; }
.rahmen { position: absolute; inset: 0; overflow: hidden; visibility: hidden; will-change: transform; }
.rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; will-change: transform; -webkit-user-drag: none; }
.rahmen.aktiv { visibility: visible; }
.fenster-info { position: absolute; z-index: 3; right: 12px; bottom: 12px; color: rgba(255, 255, 255, .92); background: rgba(11, 11, 12, .55); padding: 6px 9px; border-radius: 2px; pointer-events: none; }
.wisch { display: none; }
/* Fuß: Zähler, Fortschritt, Pfeile, Werte */
.fuss { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 16px clamp(24px, 4vw, 56px); align-items: center; }
.steuer { display: flex; align-items: center; gap: 18px; }
.zaehler { display: flex; align-items: center; gap: 8px; color: var(--leise); }
.ziffern { height: 1.3em; overflow: hidden; color: var(--text); }
.ziffern .band, .ziffern .band span { display: block; }
.fortschritt { width: clamp(64px, 8vw, 120px); height: 1px; background: var(--linie); position: relative; }
.fortschritt i { position: absolute; inset: 0; background: var(--text); transform-origin: 0 50%; transform: scaleX(.2); }
.pfeile { display: flex; gap: 6px; }
.pfeile button { width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--linie); background: none; cursor: pointer; display: grid; place-items: center; transition: border-color .15s, background-color .15s, color .15s; }
.pfeile button:hover { border-color: var(--text); background: var(--text); color: var(--grund); }
.pfeile svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; }
.werte-zeile { display: flex; align-items: center; gap: 14px; justify-content: flex-end; }
.werte { display: flex; flex-wrap: wrap; gap: 5px 16px; justify-content: flex-end; }
.werte b { color: var(--text); font-weight: 500; }
.werte span { white-space: nowrap; }
.kurvenbild { width: 48px; height: 30px; overflow: visible; flex: none; }
.kurvenbild path { fill: none; stroke: var(--text); stroke-width: 1.25; vector-effect: non-scaling-stroke; }
.kurvenbild .achse { stroke: var(--linie); }
.kurvenbild circle { fill: var(--akzent); }
/* Ohne JS: nur das erste Projekt, alles sichtbar */
html:not(.js) .rahmen:first-child { visibility: visible; }
html:not(.js) .stapel > :not(:first-child) { display: none; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
@media (max-width: 760px) {
.feld { grid-template-columns: minmax(0, 1fr); align-items: start; align-content: start; gap: 22px; }
.lang { display: none; }
.buehne { gap: 22px; }
.fenster { order: -1; max-height: none; }
.titel { margin-right: 0; font-size: clamp(52px, 17vw, 88px); }
.text { padding-bottom: 0; }
.fuss { grid-template-columns: minmax(0, 1fr); }
.werte-zeile { justify-content: flex-start; align-items: flex-start; }
.werte { justify-content: flex-start; }
.steuer { justify-content: space-between; }
.fenster-info { display: none; }
.buehne { min-height: 0; grid-template-rows: auto auto auto; }
.werte { letter-spacing: .06em; }
}
@media (hover: none) {
.wisch { display: block; position: absolute; z-index: 3; left: 12px; bottom: 12px; color: rgba(255, 255, 255, .92); background: rgba(11, 11, 12, .55); padding: 6px 9px; border-radius: 2px; pointer-events: none; }
.pfeile button:hover { background: none; color: var(--text); border-color: var(--linie); }
}
:root.reduziert .titel .z, :root.reduziert .rahmen, :root.reduziert .rahmen img { will-change: auto; }
@media (max-width: 360px) { .steuer { gap: 12px; } .fortschritt { min-width: 0; flex: 1 1 40px; } .werte { min-width: 0; } .werte span { white-space: normal; } }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<section class="buehne" aria-roledescription="Karussell" aria-label="Ausgewählte Markenbilder von Atelier Ostwind">
<header class="kopf">
<p class="mono marke">Atelier Ostwind <span>· Markenbilder<span class="lang"> 2023 bis 2026</span></span></p>
<div class="modi mono" role="group" aria-label="Variante der Bewegung">
<button type="button" data-modus="dramatisch" aria-pressed="true">power4<span class="lang"> · 1,2 s</span></button>
<button type="button" data-modus="standard" aria-pressed="false">power3<span class="lang"> · 0,9 s</span></button>
<button type="button" data-modus="zweiphasig" aria-pressed="false">Zweiphasig</button>
</div>
</header>
<div class="feld">
<div class="text">
<div class="stapel mono rubrik-stapel" data-stapel="rubrik"></div>
<h1 class="titel"><span class="sr" data-titel-sr>Ocker</span><span class="stapel" data-stapel="titel" aria-hidden="true"></span></h1>
<div class="stapel zeile" data-stapel="zeile"></div>
</div>
<div class="fenster" data-fenster>
<p class="mono fenster-info" data-info>Rahmen 100 % · Bild 30 % gegenläufig</p>
<p class="mono wisch">Wischen</p>
</div>
</div>
<footer class="fuss">
<div class="steuer">
<div class="zaehler mono"><span class="ziffern" aria-hidden="true"><span class="band" data-ziffern></span></span><span>/ 05</span></div>
<div class="fortschritt" aria-hidden="true"><i data-fortschritt></i></div>
<div class="pfeile">
<button type="button" data-richtung="-1" aria-label="Vorheriges Projekt"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg></button>
<button type="button" data-richtung="1" aria-label="Nächstes Projekt"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5"/></svg></button>
</div>
</div>
<div class="werte-zeile">
<p class="werte mono" data-werte></p>
<svg class="kurvenbild" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
<path class="achse" d="M0 1 H1"/>
<path data-kurve d="M0 1 C0.83 1 0.17 0 1 0"/>
<circle data-punkt cx="0" cy="1" r=".07"/>
</svg>
</div>
</footer>
<p class="sr" aria-live="polite" data-ansage></p>
</section>
<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/CustomEase.min.js"></script>
<script>
(() => {
gsap.registerPlugin(CustomEase);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const mobil = () => matchMedia('(max-width: 760px)').matches;
// Kurven exakt als CSS-Token, damit CSS und GSAP dieselbe Bewegung haben
CustomEase.create('kin4', '0.83, 0, 0.17, 1'); // power4.inOut
CustomEase.create('kin3', '0.76, 0, 0.24, 1'); // power3.inOut
CustomEase.create('expoOut', '0.16, 1, 0.3, 1'); // expo.out
const PROJEKTE = [
{ bild: 'bilder/flakon.jpg', titel: 'Ocker', rubrik: 'Ocker 04 · Packaging', zeile: 'Bernstein im Glas, vor kräftigem Blau gestellt.', alt: 'Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Blau' },
{ bild: 'bilder/marke-krume.jpg', titel: 'Krume', rubrik: 'Krume Backstube · Wortmarke', zeile: 'Fette Kleinbuchstaben, warm wie die erste Ofenladung.', alt: 'Wortmarke krume in dunkelbraunen Kleinbuchstaben mit Brotlaib auf Sonnengelb' },
{ bild: 'bilder/typo-plakat.jpg', titel: 'Licht', rubrik: 'Werkschau Licht · Plakat', zeile: 'Versalien, die über den Rand des Bogens laufen.', alt: 'Ausstellungsplakat LICHT mit schwarzen Riesenversalien und oranger Scheibe auf Neongelb' },
{ bild: 'bilder/magazin-doppelseite.jpg', titel: 'Gefüge', rubrik: 'Gefüge Heft 06 · Editorial Design', zeile: '96 Seiten über Dinge, die bleiben, mit viel Luft gesetzt.', alt: 'Aufgeschlagenes Magazin mit Keramik-Stillleben auf Orange und der Überschrift Die Ruhe der Dinge' },
{ bild: 'bilder/marke-eselsohr.jpg', titel: 'Eselsohr', rubrik: 'Eselsohr · Wortbildmarke', zeile: 'Eine umgeknickte Ecke, für Buchladen und Lesecafé.', alt: 'Wortbildmarke Eselsohr, Buchhandlung und Lesecafé, in Weiß und Rosa auf Anthrazit' },
];
// Varianten. Dauern in Sekunden; mobil kürzer (0,8 bis 1,1 s)
const MODI = {
dramatisch: { kurve: 'kin4', name: 'power4.inOut', css: 'cubic-bezier(0.83, 0, 0.17, 1)', bez: [0.83, 0, 0.17, 1], dauer: 1.2, dauerMobil: 1.0, versatz: 0.05 },
standard: { kurve: 'kin3', name: 'power3.inOut', css: 'cubic-bezier(0.76, 0, 0.24, 1)', bez: [0.76, 0, 0.24, 1], dauer: 0.9, dauerMobil: 0.8, versatz: 0.03 },
zweiphasig: { kurve: 'kin4', name: 'expo.out → power4.inOut', css: 'cubic-bezier(0.83, 0, 0.17, 1)', bez: [0.83, 0, 0.17, 1], dauer: 1.0, dauerMobil: 0.9, versatz: 0.05,
p1: 0.7, p1Mobil: 0.6, start: 0.6, startMobil: 0.5, skala: 0.86, zoom: 1.12 },
};
const RAHMEN = 100, GEGEN = 30; // Rahmen 100 %, inneres Bild 30 % gegenläufig
const REDUZIERT_MS = 0.4;
const $ = s => document.querySelector(s);
const fenster = $('[data-fenster]');
const stapel = { rubrik: $('[data-stapel="rubrik"]'), titel: $('[data-stapel="titel"]'), zeile: $('[data-stapel="zeile"]') };
const ziffern = $('[data-ziffern]'), fortschritt = $('[data-fortschritt]');
const werte = $('[data-werte]'), punkt = $('[data-punkt]'), kurvePfad = $('[data-kurve]');
const ansage = $('[data-ansage]'), titelSr = $('[data-titel-sr]'), info = $('[data-info]');
// DOM aufbauen: Rahmen mit innerem Bild, gestapelte Texte in Masken, Zifferband
const rahmen = [], teile = [];
PROJEKTE.forEach((p, i) => {
const r = document.createElement('figure');
r.className = 'rahmen';
r.innerHTML = `<img src="${p.bild}" alt="${p.alt}" width="1200" height="800" ${i ? 'loading="lazy"' : ''}>`;
fenster.insertBefore(r, info);
rahmen.push(r);
const ru = document.createElement('span'); ru.className = 'maske';
ru.innerHTML = `<span class="rubrik"><i>${String(i + 1).padStart(2, '0')}</i>${p.rubrik}</span>`;
stapel.rubrik.append(ru);
const ti = document.createElement('span'); ti.className = 'maske';
ti.innerHTML = [...p.titel].map(z => `<span class="z">${z}</span>`).join('');
stapel.titel.append(ti);
const ze = document.createElement('span'); ze.className = 'maske';
ze.innerHTML = `<span>${p.zeile}</span>`;
stapel.zeile.append(ze);
teile.push({ rubrik: ru.firstChild, zeichen: ti.querySelectorAll('.z'), zeile: ze.firstChild });
const zi = document.createElement('span'); zi.textContent = String(i + 1).padStart(2, '0');
ziffern.append(zi);
});
let modus = 'dramatisch', aktiv = 0, laeuft = false, puffer = 0, zeitleiste = null;
function zeigeWerte() {
const m = MODI[modus], mo = mobil();
kurvePfad.setAttribute('d', `M0 1 C${m.bez[0]} ${1 - m.bez[1]} ${m.bez[2]} ${1 - m.bez[3]} 1 0`);
if (reduziert()) {
werte.innerHTML = `<span>Reduziert <b>Crossfade ${REDUZIERT_MS * 1000} ms</b></span><span>Kurve <b>power1.inOut</b></span><span>Eingaben <b>gepuffert</b></span>`;
info.textContent = 'Crossfade ohne Bewegung';
return;
}
const d = Math.round((mo ? m.dauerMobil : m.dauer) * 1000);
if (modus === 'zweiphasig') {
werte.innerHTML =
`<span>Phase 1 <b>expo.out ${Math.round((mo ? m.p1Mobil : m.p1) * 1000)} ms</b></span>` +
`<span>Phase 2 <b>${m.css} ${d} ms ab ${Math.round((mo ? m.startMobil : m.start) * 1000)} ms</b></span>` +
`<span>Fenster <b>scale ${String(m.skala).replace('.', ',')}</b></span>` +
`<span>Buchstaben <b>${m.versatz * 1000} ms zufällig</b></span>`;
info.textContent = 'Zurücktreten · Schwenk 100 / 30 %';
} else {
werte.innerHTML =
`<span>Kurve <b>${m.css}</b></span>` +
`<span>Dauer <b>${d} ms</b></span>` +
`<span>Buchstaben <b>${m.versatz * 1000} ms zufällig</b></span>` +
`<span>Eingaben <b>gepuffert</b></span>`;
info.textContent = `Rahmen ${RAHMEN} % · Bild ${GEGEN} % gegenläufig`;
}
}
// Kurvenpunkt läuft synchron zur Hauptbewegung
function punktTween(dauer, kurve) {
const p = { t: 0 }, e = gsap.parseEase(kurve);
return gsap.to(p, { t: 1, duration: dauer, ease: 'none', onUpdate() {
punkt.setAttribute('cx', p.t.toFixed(3)); punkt.setAttribute('cy', (1 - e(p.t)).toFixed(3));
} });
}
function grundzustand(i) {
if (zeitleiste) zeitleiste.kill();
gsap.killTweensOf([fenster, ziffern, fortschritt, ...rahmen, ...rahmen.map(r => r.firstChild), ...teile.flatMap(t => [t.rubrik, t.zeile, ...t.zeichen])]);
laeuft = false; puffer = 0; aktiv = i;
gsap.set(fenster, { scale: 1 });
rahmen.forEach((r, k) => {
r.classList.toggle('aktiv', k === i);
gsap.set(r, { xPercent: 0, opacity: 1 });
gsap.set(r.firstChild, { xPercent: 0, scale: 1 });
});
teile.forEach((t, k) => {
const y = k === i ? 0 : 101;
gsap.set([t.rubrik, t.zeile, ...t.zeichen], { yPercent: y, opacity: 1 });
if (reduziert()) gsap.set([t.rubrik, t.zeile, ...t.zeichen], { yPercent: 0, opacity: k === i ? 1 : 0 });
});
gsap.set(ziffern, { yPercent: -100 * i / PROJEKTE.length });
gsap.set(fortschritt, { scaleX: (i + 1) / PROJEKTE.length });
titelSr.textContent = PROJEKTE[i].titel;
punkt.setAttribute('cx', 1); punkt.setAttribute('cy', 0);
}
// Ein Raumwechsel. r = +1 (weiter) oder -1 (zurück); von = null für den Auftritt
function wechsel(neu, r, von) {
const m = MODI[modus], mo = mobil();
const d = mo ? m.dauerMobil : m.dauer;
const A = von == null ? null : rahmen[von], N = rahmen[neu];
const tA = von == null ? null : teile[von], tN = teile[neu];
const tl = gsap.timeline();
N.classList.add('aktiv');
if (reduziert()) {
const f = { duration: REDUZIERT_MS, ease: 'power1.inOut' };
gsap.set(N, { zIndex: 2 }); if (A) gsap.set(A, { zIndex: 1 });
tl.fromTo(N, { opacity: 0 }, { opacity: 1, ...f }, 0);
tl.fromTo([tN.rubrik, tN.zeile, ...tN.zeichen], { opacity: 0, yPercent: 0 }, { opacity: 1, ...f }, 0);
if (tA) tl.to([tA.rubrik, tA.zeile, ...tA.zeichen], { opacity: 0, ...f }, 0);
tl.set(ziffern, { yPercent: -100 * neu / PROJEKTE.length }, 0);
tl.set(fortschritt, { scaleX: (neu + 1) / PROJEKTE.length }, 0);
tl.add(() => { if (A) A.classList.remove('aktiv'); gsap.set([N, A].filter(Boolean), { clearProps: 'zIndex' }); });
return tl;
}
// Start der Hauptbewegung; zweiphasig erst nach Phase 1 (überlappend)
let s = 0;
if (modus === 'zweiphasig') {
const p1 = mo ? m.p1Mobil : m.p1;
s = mo ? m.startMobil : m.start;
// Phase 1: Fenster tritt zurück, Bild zoomt gegen, alter Titel geht – expo.out
tl.to(fenster, { scale: m.skala, duration: p1, ease: 'expoOut' }, 0);
if (A) tl.to(A.firstChild, { scale: m.zoom, duration: p1, ease: 'expoOut' }, 0);
if (tA) tl.to([tA.rubrik, tA.zeile, ...tA.zeichen], { yPercent: -101 * r, duration: p1, ease: 'expoOut', stagger: { each: 0.03, from: 'random' } }, 0);
// Phase 2: Fenster fährt wieder heran
tl.to(fenster, { scale: 1, duration: d, ease: m.kurve }, s);
tl.fromTo(N.firstChild, { scale: m.zoom }, { scale: 1, duration: d, ease: m.kurve }, s);
} else if (tA) {
// Alte Texte verlassen die Masken mit derselben Kurve
tl.to([tA.rubrik, tA.zeile], { yPercent: -101 * r, duration: d, ease: m.kurve }, 0);
tl.to(tA.zeichen, { yPercent: -101 * r, duration: d, ease: m.kurve, stagger: { each: m.versatz, from: 'random' } }, 0);
}
// Ebenen: Rahmen 100 %, inneres Bild 30 % gegenläufig, gleiche Kurve
if (A) {
tl.fromTo(A, { xPercent: 0 }, { xPercent: -RAHMEN * r, duration: d, ease: m.kurve }, s);
tl.fromTo(A.firstChild, { xPercent: 0 }, { xPercent: GEGEN * r, duration: d, ease: m.kurve }, s);
}
tl.fromTo(N, { xPercent: RAHMEN * r }, { xPercent: 0, duration: d, ease: m.kurve }, s);
tl.fromTo(N.firstChild, { xPercent: -GEGEN * r }, { xPercent: 0, duration: d, ease: m.kurve }, s);
// Neue Texte steigen aus den Masken, Buchstaben zufällig versetzt
tl.fromTo([tN.rubrik, tN.zeile], { yPercent: 101 * r }, { yPercent: 0, duration: d, ease: m.kurve }, s);
tl.fromTo(tN.zeichen, { yPercent: 101 * r }, { yPercent: 0, duration: d, ease: m.kurve, stagger: { each: m.versatz, from: 'random' } }, s);
// Zähler und Fortschritt laufen mit
tl.to(ziffern, { yPercent: -100 * neu / PROJEKTE.length, duration: d, ease: m.kurve }, s);
tl.to(fortschritt, { scaleX: (neu + 1) / PROJEKTE.length, duration: d, ease: m.kurve }, s);
tl.add(punktTween(d, m.kurve), s);
tl.add(() => { if (A) { A.classList.remove('aktiv'); gsap.set(A, { xPercent: 0 }); gsap.set(A.firstChild, { xPercent: 0, scale: 1 }); } });
return tl;
}
// Eingaben während des Wechsels puffern statt verwerfen (eine Richtung)
function gehe(r) {
if (laeuft) { puffer = r; return; }
laeuft = true;
const von = aktiv, neu = (aktiv + r + PROJEKTE.length) % PROJEKTE.length;
aktiv = neu;
titelSr.textContent = PROJEKTE[neu].titel;
ansage.textContent = `Projekt ${neu + 1} von ${PROJEKTE.length}: ${PROJEKTE[neu].rubrik}`;
zeitleiste = wechsel(neu, r, von);
zeitleiste.eventCallback('onComplete', () => {
laeuft = false;
if (puffer) { const p = puffer; puffer = 0; gehe(p); }
});
}
function auftritt() {
grundzustand(aktiv);
if (reduziert()) return;
const i = aktiv, t = teile[i];
gsap.set([t.rubrik, t.zeile, ...t.zeichen], { yPercent: 101 });
rahmen[i].classList.remove('aktiv');
laeuft = true;
zeitleiste = wechsel(i, 1, null);
zeitleiste.eventCallback('onComplete', () => { laeuft = false; if (puffer) { const p = puffer; puffer = 0; gehe(p); } });
}
// Bedienung
document.querySelectorAll('[data-richtung]').forEach(b => b.addEventListener('click', () => { stoppeSchleife(); gehe(+b.dataset.richtung); }));
addEventListener('keydown', e => {
if (e.key === 'ArrowRight') { stoppeSchleife(); gehe(1); }
if (e.key === 'ArrowLeft') { stoppeSchleife(); gehe(-1); }
});
let startX = null;
fenster.addEventListener('pointerdown', e => { startX = e.clientX; });
fenster.addEventListener('pointerup', e => {
if (startX == null) return;
const dx = e.clientX - startX; startX = null;
if (Math.abs(dx) > 40) { stoppeSchleife(); gehe(dx < 0 ? 1 : -1); }
});
fenster.addEventListener('pointercancel', () => { startX = null; });
function setzeModus(neu) {
modus = neu;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
zeigeWerte();
}
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-modus]');
if (!b) return;
stoppeSchleife();
setzeModus(b.dataset.modus);
grundzustand(aktiv);
gehe(1); // Variante gleich zeigen
});
matchMedia('(max-width: 760px)').addEventListener('change', zeigeWerte);
// Vorführung: je zwei Wechsel pro Variante
const REIHE = ['dramatisch', 'dramatisch', 'standard', 'standard', 'zweiphasig', 'zweiphasig'];
let uhr = null, schritt = 0;
function stoppeSchleife() { clearTimeout(uhr); uhr = null; }
function takt(ms) {
uhr = setTimeout(() => {
if (REIHE[schritt] !== modus) setzeModus(REIHE[schritt]);
gehe(1);
schritt = (schritt + 1) % REIHE.length;
takt(reduziert() ? 2200 : 3000);
}, ms);
}
function vorfuehren() {
stoppeSchleife();
gsap.globalTimeline.resume();
schritt = 0;
setzeModus(REIHE[0]);
aktiv = 0;
auftritt();
takt(1800);
}
function neustart() {
stoppeSchleife();
gsap.globalTimeline.resume();
aktiv = 0;
auftritt();
}
setzeModus(modus);
grundzustand(0);
document.fonts.ready.then(() => { if (!uhr) auftritt(); });
})();
</script>
</body>
</html>