Animationsstil „Asymmetrisch auf/zu“ (schnell zu, langsam auf)
Ein Mega-Menü öffnet mit Ausklang in 1,1 s und schließt mit Anlauf-Kurve in zwei Dritteln der Zeit, ohne wie zurückgespult zu wirken.
Öffnen und Schließen bekommen bewusst unterschiedliche Kurven und Tempi. Öffnen läuft genüsslich mit Ausklang-Kurve (expo.out oder back.out). Schließen ist schneller und hat eine Anlauf-Kurve (expo.in, sine.in) oder eine eigene kurze Kurve. So wirkt es nicht wie ein rückwärts abgespieltes Video. Seit GSAP 3.15 (April 2026) geht das mit easeReverse direkt am Tween oder in den Timeline-Defaults.
Öffnen ist eine Einladung, Schließen eine Bestätigung, die schnell aus dem Weg gehen soll. Exakt rückwärts abgespielte Animationen fühlen sich falsch an: Eine Überschwinger-Kurve holt beim Schließen erst aus, und das Schließen dauert so lange wie das Öffnen. Die eigene Schließbewegung wirkt natürlicher und souveräner.
Wann einsetzen
Alle umschaltbaren UI-Teile: Menüs, Overlays, Modals, Lightboxen, Drawer, Dropdowns, Akkordeons, Tooltips und Hover-Effekte mit Rückweg.
Wann nicht
Bei Scroll-gescrubbten Animationen, dort muss rückwärts exakt vorwärts spiegeln. Bei sehr kurzen Mikro-Übergängen unter 150 ms, dort ist der Unterschied nicht wahrnehmbar.
Varianten
- GSAP easeReverse mit eigener Kurve, z. B. ease back.out(1.7), easeReverse expo.in oder sine.in
- GSAP easeReverse: true (gleiche Kurve, für die Rückrichtung angepasst)
- Zusätzlich timeScale 1,5 beim Schließen, beim unterbrochenen Schließen 2
- CSS: eigene transition im offenen Zustand und im Grundzustand (die Regel am Zielzustand gilt)
- Nur Tempo: rein langsam, raus schnell, z. B. 600/350 ms oder 800/300 ms
Umsetzung
GSAP: gsap.timeline({ paused: true, defaults: { ease: 'back.out(1.7)', easeReverse: 'expo.in' } }). Öffnen mit tl.timeScale(1).play(), Schließen mit tl.timeScale(1.5).reverse(). Wird mitten im Öffnen geschlossen, rechnet GSAP die Rückwärtskurve ab dem Wendepunkt neu; dazu timeScale(2). CSS: die transition am Zielzustand gilt. Beispiel: .panel { transition: clip-path .3s cubic-bezier(.5,.3,0,1) } .offen .panel { transition: clip-path .8s cubic-bezier(.19,1,.22,1) }. Staffelung beim Öffnen 40 bis 60 ms, beim Schließen 20 bis 30 ms in umgekehrter Reihenfolge. Schließen dauert typisch 40 bis 67 % des Öffnens.
Robust umsetzen
Telefon. Unverändert. Auf Touch gilt dasselbe Paar aus Kurven und Dauern.
Ohne Bewegung. Beide Richtungen als 150-ms-Überblendung ohne Weg.
Barrierefrei. aria-expanded wechselt mit dem Zustand, nicht mit dem Animationsende. Beim Öffnen geht der Fokus in den Inhalt, beim Schließen zurück zum Auslöser. Esc schließt. Geschlossene Inhalte sind inert. Das Fokus-Management hängt nicht an der Animation.
Code
<!DOCTYPE html>
<!--
Animationsstil „Asymmetrisch auf/zu“ (schnell zu, langsam auf)
Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-toggle-asymmetrisch/
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 „Asymmetrisch auf/zu“ (schnell zu, langsam auf) · 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: '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; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
--papier: #ecebe6; --tinte: #161615; --leise: rgba(22, 22, 21, .66); --linie: rgba(22, 22, 21, .14);
--nacht: #161615; --hell: #ecebe6; --hell-leise: rgba(236, 235, 230, .5); --hell-linie: rgba(236, 235, 230, .14);
--akzent: #2aa79b;
/* Kurvenpaar der Demo */
--auf: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--zu: cubic-bezier(0.7, 0, 0.84, 0); /* expo.in */
--css-auf: cubic-bezier(0.19, 1, 0.22, 1); /* Collins, Öffnen 800 ms */
--css-zu: cubic-bezier(0.5, 0.3, 0, 1); /* Collins, Schließen 300 ms */
--panel-h: 100svh;
}
* { box-sizing: border-box; margin: 0; }
html { overflow-x: clip; background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(16px, 2.6vw, 34px) clamp(16px, 3.6vw, 52px) clamp(14px, 2vw, 24px); display: grid; grid-template-rows: auto 1fr auto; gap: clamp(18px, 3vw, 34px); overflow-x: clip; background: var(--papier); }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
/* Kopf: liegt über dem Panel und wechselt die Farbe mit (auch asymmetrisch) */
.kopf { position: relative; z-index: 40; display: flex; align-items: center; justify-content: space-between; gap: 20px; color: var(--tinte); transition: color 150ms linear; }
.kopf-hell .kopf { color: var(--hell); }
.logo { display: flex; align-items: baseline; gap: 10px; font-weight: 600; font-size: 21px; letter-spacing: -.03em; }
.logo .mono { color: inherit; opacity: .66; font-size: 10px; }
.kopf-rechts { display: flex; align-items: center; gap: clamp(14px, 2.4vw, 30px); }
.kopf-rechts .mono { color: inherit; opacity: .66; }
.menue-knopf { display: inline-flex; align-items: center; gap: 12px; height: 44px; padding: 0 8px 0 20px; border-radius: 999px; box-shadow: inset 0 0 0 1px currentColor; font-size: 15px; font-weight: 600; transition: scale 150ms var(--auf); }
.menue-knopf:active, .menue-knopf.gedrueckt { scale: .95; }
.menue-knopf .wort { position: relative; display: grid; overflow: hidden; height: 1.3em; line-height: 1.3em; }
.menue-knopf .wort span { grid-area: 1 / 1; transition: translate 300ms var(--zu); }
.menue-knopf .wort span + span { translate: 0 110%; }
.offen .menue-knopf .wort span { translate: 0 -110%; transition: translate 600ms var(--auf); }
.offen .menue-knopf .wort span + span { translate: 0 0; }
.menue-knopf .punkt { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--tinte); color: var(--papier); transition: background-color 150ms linear, color 150ms linear; }
.kopf-hell .menue-knopf .punkt { background: var(--akzent); color: var(--nacht); }
.menue-knopf .punkt svg { width: 12px; height: 12px; transition: rotate 300ms var(--zu); }
.offen .menue-knopf .punkt svg { rotate: 135deg; transition: rotate 800ms var(--auf); }
/* Seite unter dem Menü */
.inhalt { display: grid; grid-template-columns: 1fr 1.1fr; align-items: center; gap: clamp(24px, 4vw, 64px); }
.text { display: grid; gap: clamp(14px, 2vw, 24px); justify-items: start; }
h1 { font-weight: 600; font-size: clamp(40px, 5.6vw, 86px); line-height: .94; letter-spacing: -.045em; }
h1 em { font: italic 400 1.1em/1 'Instrument Serif', serif; letter-spacing: -.02em; color: #229387; padding-right: .03em; }
.lead { font-size: clamp(15px, 1.3vw, 18px); line-height: 1.5; max-width: 40ch; color: rgba(22, 22, 21, .74); }
.bild { position: relative; margin: 0; }
.bild img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 6px; background: #e3e2dc; }
.bild figcaption { display: flex; justify-content: space-between; gap: 12px; padding-top: 10px; }
/* Das Menü-Panel */
.panel { position: fixed; z-index: 30; left: 0; right: 0; top: 0; height: var(--panel-h); background: var(--nacht); color: var(--hell);
clip-path: inset(0% 0% 100% 0% round 0px 0px 24px 24px); pointer-events: none;
display: grid; grid-template-rows: 1fr auto; padding: calc(clamp(16px, 2.6vw, 34px) + 44px + clamp(22px, 4vh, 48px)) clamp(16px, 3.6vw, 52px) clamp(16px, 2.4vw, 28px); gap: 20px; }
.offen .panel { pointer-events: auto; }
.panel-raster { display: grid; grid-template-columns: 1.15fr 1fr; gap: clamp(20px, 4vw, 64px); align-items: start; min-height: 0; }
.links { list-style: none; padding: 0; display: grid; gap: 0; }
.links li { overflow: hidden; padding: .06em 0 .02em; border-bottom: 1px solid var(--hell-linie); }
.links a { display: flex; align-items: baseline; gap: clamp(12px, 1.6vw, 22px); }
.links .innen { display: flex; align-items: baseline; gap: clamp(12px, 1.6vw, 22px); transform: translateY(115%); will-change: transform; }
.links .nr { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .14em; color: var(--akzent); translate: 0 -.9em; }
.links .wort { position: relative; font-weight: 600; font-size: clamp(34px, min(6vw, 8.6vh), 84px); line-height: 1.04; letter-spacing: -.045em; }
.links .wort::after { content: ''; position: absolute; left: 0; right: 0; bottom: .1em; height: 2px; background: currentColor; scale: 0 1; transform-origin: right; transition: scale 350ms var(--zu); }
@media (hover: hover) {
.links a:hover .wort::after, .links a:focus-visible .wort::after { scale: 1 1; transform-origin: left; transition: scale 600ms var(--auf); }
}
.links em { font: italic 400 .62em/1 'Instrument Serif', serif; letter-spacing: 0; color: var(--hell-leise); }
.zuletzt { display: grid; gap: 14px; }
.zuletzt > .mono { color: var(--hell-leise); }
.karten { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(10px, 1.2vw, 16px); }
.karte { display: grid; gap: 9px; transform: scale(.9); opacity: 0; transform-origin: 50% 0; }
.karte img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 4px; background: #2a2a28; max-height: 38vh; }
.karte b { font-weight: 600; font-size: 14px; letter-spacing: -.01em; }
.karte .mono { color: var(--hell-leise); font-size: 10px; display: block; margin-top: 2px; }
.panel-fuss { display: flex; justify-content: space-between; gap: 10px 24px; flex-wrap: wrap; opacity: 0; }
.panel-fuss .mono { color: var(--hell-leise); }
.panel-fuss a:hover { color: var(--hell); }
/* Variante „CSS-Transition“: Zielzustand bestimmt die Transition (Collins-Werte) */
body[data-variante="css"] .panel { transition: clip-path 300ms var(--css-zu) 75ms; }
body[data-variante="css"] .links .innen { transition: transform 300ms var(--css-zu) calc((4 - var(--i)) * 25ms); }
body[data-variante="css"] .karte { transition: transform 300ms var(--css-zu) calc((2 - var(--i)) * 25ms), opacity 300ms var(--css-zu) calc((2 - var(--i)) * 25ms); }
body[data-variante="css"] .panel-fuss { transition: opacity 200ms linear; }
.offen body[data-variante="css"] .panel { clip-path: inset(0% 0% 0% 0% round 0px 0px 24px 24px); transition: clip-path 800ms var(--css-auf); }
.offen body[data-variante="css"] .links .innen { transform: none; transition: transform 800ms var(--css-auf) calc(120ms + var(--i) * 50ms); }
.offen body[data-variante="css"] .karte { transform: none; opacity: 1; transition: transform 800ms var(--css-auf) calc(180ms + var(--i) * 50ms), opacity 500ms linear calc(180ms + var(--i) * 50ms); }
.offen body[data-variante="css"] .panel-fuss { opacity: 1; transition: opacity 600ms linear 300ms; }
/* Fuß: Varianten, Messleiste, Werte */
.fuss { position: relative; z-index: 40; display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 14px 32px; border-top: 1px solid var(--linie); padding-top: 14px; background: var(--papier); }
.varianten { display: flex; gap: 4px; flex-wrap: wrap; }
.varianten button { height: 28px; padding: 0 12px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--linie); transition: background-color 150ms linear, color 150ms linear, scale 150ms var(--auf); }
.varianten button:active { scale: .95; }
.varianten button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); box-shadow: none; }
.mess { display: grid; grid-template-columns: auto 30px 140px auto; justify-content: end; row-gap: 4px; }
.spur { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; column-gap: 12px; opacity: .64; transition: opacity 150ms linear; }
.spur > .mono:first-child { min-width: 2.6em; }
.spur.laeuft { opacity: 1; }
.spur .mono { color: var(--tinte); }
.spur [data-text] b { font-weight: 500; }
.spur .kurve { width: 30px; height: 24px; overflow: visible; }
.spur .kurve path { fill: none; stroke: var(--tinte); stroke-width: 1.4; vector-effect: non-scaling-stroke; }
.spur .kurve .rahmen { stroke: var(--linie); stroke-width: 1; }
.spur .kurve circle { fill: var(--akzent); }
.balken { position: relative; width: calc(var(--ms) * .12px); height: 6px; border-radius: 3px; background: var(--linie); overflow: hidden; }
.balken i { position: absolute; inset: 0; background: var(--tinte); transform-origin: left; scale: var(--p, 0) 1; }
.spur.zu .balken i { background: var(--akzent); }
.werte { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px 22px; }
.werte div { display: flex; gap: 8px; }
.werte dt { color: var(--leise); }
.werte dd { color: var(--tinte); }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 22px; height: 22px; pointer-events: none; display: none; transition: transform .8s var(--auf), scale 150ms var(--auf); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .8; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .werte, .vorschau .kopf-rechts > .mono { display: none; }
/* Telefon und schmale Fenster */
@media (max-width: 860px) {
.inhalt { grid-template-columns: 1fr; align-content: start; }
.bild { order: -1; }
.panel-raster { grid-template-columns: 1fr; gap: 28px; }
.karte img { max-height: 24vh; }
.fuss { grid-template-columns: 1fr; }
.mess { justify-content: start; }
}
@media (max-width: 560px) {
.kopf-rechts > .mono { display: none; }
.logo .mono { display: none; }
.links .wort { font-size: clamp(34px, 11vw, 46px); }
.karte b { font-size: 12px; }
.karte .mono { display: none; }
.panel-fuss .mono:last-child { display: none; }
.spur [data-text] .name { display: none; }
}
/* Touch: gleiches Verhalten wie am Desktop (Tippen statt Klicken), Hover-Linien entfallen */
@media (hover: none) { .menue-knopf { height: 48px; } }
/* Reduzierte Bewegung: beide Richtungen als 150-ms-Überblendung, keine Wege */
.reduziert .panel { clip-path: none !important; opacity: 0; transition: opacity 150ms linear !important; }
.offen.reduziert .panel { opacity: 1; }
.reduziert .links .innen, .reduziert .karte { transform: none !important; opacity: 1 !important; transition: none !important; }
.reduziert .panel-fuss { opacity: 1 !important; transition: none !important; }
.reduziert .menue-knopf .wort span, .reduziert .menue-knopf .punkt svg, .reduziert .links .wort::after { transition-duration: 150ms !important; transition-timing-function: linear !important; transition-delay: 0ms !important; }
.reduziert .zeiger { transition: none; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="gsap">
<header class="kopf">
<a class="logo" href="#">Atelier Wendt <span class="mono">Erscheinungsbilder</span></a>
<div class="kopf-rechts">
<span class="mono">Köln · seit 2012</span>
<button class="menue-knopf" type="button" aria-expanded="false" aria-controls="menue" data-knopf>
<span class="wort" aria-hidden="true"><span>Menü</span><span>Schließen</span></span>
<span class="sr" style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)">Menü</span>
<span class="punkt"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M6 0v12M0 6h12" stroke="currentColor" stroke-width="1.6"/></svg></span>
</button>
</div>
</header>
<main class="inhalt">
<div class="text">
<p class="mono">Werkverzeichnis 2012–2026</p>
<h1>Zeichen, die <em>bleiben,</em> wenn der Trend geht.</h1>
<p class="lead">Wir entwickeln Erscheinungsbilder und Hausmagazine für Cafés, Werkstätten und kleine Verlage. Zwölf Jahre, über neunzig Marken, kein Zeichen nach Schablone.</p>
</div>
<figure class="bild">
<img src="bilder/magazin-doppelseite.jpg" width="1200" height="800" alt="Aufgeschlagenes Hausmagazin: links ein Keramik-Stillleben auf Terrakotta, rechts der Titel Die Ruhe der Dinge in Serifenschrift">
<figcaption><span class="mono">Die Ruhe der Dinge · Hausmagazin</span><span class="mono">Ausgabe 01</span></figcaption>
</figure>
</main>
<nav class="panel" id="menue" aria-label="Hauptmenü" inert data-panel>
<div class="panel-raster">
<ul class="links">
<li style="--i:0"><a href="#"><span class="innen"><span class="nr">01</span><span class="wort">Arbeiten</span></span></a></li>
<li style="--i:1"><a href="#"><span class="innen"><span class="nr">02</span><span class="wort">Atelier</span></span></a></li>
<li style="--i:2"><a href="#"><span class="innen"><span class="nr">03</span><span class="wort">Haltung <em>und Preise</em></span></span></a></li>
<li style="--i:3"><a href="#"><span class="innen"><span class="nr">04</span><span class="wort">Journal</span></span></a></li>
<li style="--i:4"><a href="#"><span class="innen"><span class="nr">05</span><span class="wort">Kontakt</span></span></a></li>
</ul>
<div class="zuletzt">
<p class="mono">Zuletzt abgeschlossen</p>
<div class="karten">
<a class="karte" href="#" style="--i:0"><img src="bilder/marke-eselsohr-hoch.jpg" width="800" height="1000" alt="Wortmarke Eselsohr in heller Kursive auf Anthrazit, darüber ein Blatt mit umgeknickter rosa Ecke"><span><b>Eselsohr</b><span class="mono">Bücher · 2026</span></span></a>
<a class="karte" href="#" style="--i:1"><img src="bilder/marke-roestkontor-hoch.jpg" width="800" height="1000" alt="Wortmarke Röstkontor in breiten weißen Versalien auf Ziegelrot, darüber eine Kaffeebohne"><span><b>Röstkontor</b><span class="mono">Rösterei · 2025</span></span></a>
<a class="karte" href="#" style="--i:2"><img src="bilder/marke-krume-hoch.jpg" width="800" height="1000" alt="Wortmarke krume in fetter dunkelbrauner Schrift auf Sonnengelb, darüber ein Brotlaib"><span><b>Krume</b><span class="mono">Backstube · 2025</span></span></a>
</div>
</div>
</div>
<div class="panel-fuss">
<a class="mono" href="#">post@atelier-wendt.de</a>
<span class="mono">Brüsseler Straße 41 · 50674 Köln</span>
</div>
</nav>
<footer class="fuss">
<div class="varianten mono" role="group" aria-label="Variante" data-varianten>
<button type="button" data-v="gsap" aria-pressed="true">easeReverse</button>
<button type="button" data-v="css" aria-pressed="false">CSS-Transition</button>
<button type="button" data-v="spiegel" aria-pressed="false">Gespiegelt</button>
</div>
<div class="mess" aria-hidden="true">
<div class="spur auf" data-spur="auf"><span class="mono">Auf</span><svg class="kurve" viewBox="0 -0.7 1 2.4" preserveAspectRatio="none"><path class="rahmen" d="M0 0H1M0 1H1"/><path data-pfad/><circle r="0" data-punkt/></svg><span class="balken"><i></i></span><span class="mono" data-text></span></div>
<div class="spur zu" data-spur="zu"><span class="mono">Zu</span><svg class="kurve" viewBox="0 -0.7 1 2.4" preserveAspectRatio="none"><path class="rahmen" d="M0 0H1M0 1H1"/><path data-pfad/><circle r="0" data-punkt/></svg><span class="balken"><i></i></span><span class="mono" data-text></span></div>
</div>
<dl class="werte mono" data-werte></dl>
</footer>
<svg class="zeiger" viewBox="0 0 22 22" aria-hidden="true" data-zeiger><path d="M3 2l15 8.5-6.6 1.7L8.6 19z" fill="#161615" stroke="#ecebe6" 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>
(() => {
const html = document.documentElement, body = document.body;
const reduziert = () => html.classList.contains('reduziert');
const knopf = document.querySelector('[data-knopf]');
const panel = document.querySelector('[data-panel]');
const innen = [...panel.querySelectorAll('.links .innen')];
const karten = [...panel.querySelectorAll('.karte')];
const panelFuss = panel.querySelector('.panel-fuss');
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const spuren = { auf: document.querySelector('[data-spur="auf"]'), zu: document.querySelector('[data-spur="zu"]') };
const hatGsap = typeof window.gsap !== 'undefined';
/* Die drei Varianten. Kurven als Bezier für die Anzeige, GSAP-Namen für die Timeline. */
const VARIANTEN = {
gsap: {
auf: { ms: 1100, name: 'back.out(1.7)', bez: [0.34, 1.56, 0.64, 1] },
zu: { ms: 733, name: 'expo.in · ×1,5', bez: [0.7, 0, 0.84, 0] },
werte: [['Panel', 'clip-path 800 ms expo.out / easeReverse expo.in'], ['Elemente', 'back.out(1.7) / easeReverse expo.in'], ['Schließen', 'timeScale 1,5 (unterbrochen 2)'], ['Versatz', 'auf 50/60 ms · zu 33/40 ms rückwärts'], ['Weg', 'yPercent 115 → 0 · scale 0,9 → 1']]
},
css: {
auf: { ms: 1080, name: 'cubic-bezier(.19,1,.22,1)', bez: [0.19, 1, 0.22, 1] },
zu: { ms: 400, name: 'cubic-bezier(.5,.3,0,1)', bez: [0.5, 0.3, 0, 1] },
werte: [['Auf', '800 ms cubic-bezier(.19,1,.22,1)'], ['Zu', '300 ms cubic-bezier(.5,.3,0,1)'], ['Verhältnis', '0,375'], ['Versatz', 'auf 50 ms · zu 25 ms rückwärts'], ['Regel', 'transition am Zielzustand']]
},
spiegel: {
auf: { ms: 1100, name: 'back.out(1.7)', bez: [0.34, 1.56, 0.64, 1] },
zu: { ms: 1100, name: 'gespiegelt = back.in', bez: [0.36, 0, 0.66, -0.56] },
werte: [['Gegenbeispiel', 'tl.reverse() ohne easeReverse'], ['Zu', 'gleiche Dauer, Kurve rückwärts'], ['Folge', 'Ausholen vor dem Schließen, wirkt wie zurückgespult']]
}
};
const REDUZIERT = { auf: { ms: 150, name: 'linear · Fade', bez: [0, 0, 1, 1] }, zu: { ms: 150, name: 'linear · Fade', bez: [0, 0, 1, 1] },
werte: [['Reduziert', 'beide Richtungen 150 ms Überblendung'], ['Weg', 'keiner']] };
let variante = 'gsap', offen = false, tl = null, timer = [], lauf = null;
const v = () => reduziert() ? REDUZIERT : VARIANTEN[variante];
/* Bezier-Hilfe für die Anzeige der Kurve und des laufenden Punkts */
function bezier([x1, y1, x2, y2]) {
const c = (a, b, t) => 3 * a * t * (1 - t) ** 2 + 3 * b * t * t * (1 - t) + t ** 3;
return x => { let lo = 0, hi = 1, t = x; for (let k = 0; k < 24; k++) { t = (lo + hi) / 2; c(x1, x2, t) < x ? lo = t : hi = t; } return c(y1, y2, t); };
}
function zeigeWerte() {
const d = v();
for (const r of ['auf', 'zu']) {
const s = spuren[r], [x1, y1, x2, y2] = d[r].bez;
s.style.setProperty('--ms', d[r].ms);
s.querySelector('[data-pfad]').setAttribute('d', `M0 1C${x1} ${1 - y1} ${x2} ${1 - y2} 1 0`);
s.querySelector('[data-text]').innerHTML = `<b>${d[r].ms} ms</b><span class="name"> · ${d[r].name}</span>`;
}
werteEl.innerHTML = d.werte.map(([a, b]) => `<div><dt>${a}</dt><dd>${b}</dd></div>`).join('');
}
/* Messleiste: Balken und Punkt laufen in Echtzeit mit */
function messen(richtung, ms) {
cancelAnimationFrame(lauf);
for (const r of ['auf', 'zu']) { spuren[r].classList.toggle('laeuft', r === richtung); spuren[r].style.setProperty('--p', 0); spuren[r].querySelector('[data-punkt]').setAttribute('r', 0); }
const s = spuren[richtung], punkt = s.querySelector('[data-punkt]'), f = bezier(v()[richtung].bez), t0 = performance.now();
const schritt = now => {
const p = Math.min(1, (now - t0) / ms);
s.style.setProperty('--p', p);
punkt.setAttribute('cx', p); punkt.setAttribute('cy', 1 - f(p)); punkt.setAttribute('r', .07);
if (p < 1) lauf = requestAnimationFrame(schritt);
else setTimeout(() => s.classList.remove('laeuft'), 500);
};
lauf = requestAnimationFrame(schritt);
}
/* GSAP-Timeline: Öffnen mit Ausklang, Schließen über easeReverse (oder gespiegelt) */
function baueTimeline() {
if (tl) tl.kill();
if (hatGsap) gsap.set([panel, ...innen, ...karten, panelFuss], { clearProps: "all" });
tl = null;
if (!hatGsap || variante === 'css' || reduziert()) return;
const asym = variante === 'gsap';
tl = gsap.timeline({ paused: true, defaults: { duration: 0.8, ease: 'back.out(1.7)', ...(asym && { easeReverse: 'expo.in' }) } });
tl.fromTo(panel, { clipPath: 'inset(0% 0% 100% 0% round 0px 0px 24px 24px)' },
{ clipPath: 'inset(0% 0% 0% 0% round 0px 0px 24px 24px)', ease: 'expo.out' }, 0)
.fromTo(innen, { y: 0, yPercent: 115 }, { y: 0, yPercent: 0, stagger: 0.05 }, 0.12)
.fromTo(karten, { scale: 0.9, opacity: 0 }, { scale: 1, opacity: 1, stagger: 0.06 }, 0.18)
.fromTo(panelFuss, { opacity: 0 }, { opacity: 1, duration: 0.6, ease: 'none' }, 0.3);
}
/* Kopffarbe folgt der Kante des Panels (Clip bzw. Deckkraft), nicht dem Klick */
let waechter = null;
function kopfWaechter() {
cancelAnimationFrame(waechter);
const kr = document.querySelector('.kopf').getBoundingClientRect(), kopfMitte = (kr.top + kr.bottom) / 2;
const tick = () => {
const cs = getComputedStyle(panel);
let bedeckt;
if (reduziert()) bedeckt = parseFloat(cs.opacity) > .5;
else {
const clip = cs.clipPath || '', z = clip.startsWith('inset(') ? clip.slice(6).split('round')[0].trim().split(/\s+/).map(parseFloat) : null;
const unten = !z ? (clip === 'none' ? 0 : 100) : z.length > 2 ? z[2] : z[0];
bedeckt = panel.offsetHeight * (1 - unten / 100) > kopfMitte;
}
html.classList.toggle('kopf-hell', bedeckt);
waechter = requestAnimationFrame(tick);
};
tick();
clearTimeout(kopfWaechter.t);
kopfWaechter.t = setTimeout(() => cancelAnimationFrame(waechter), 2400);
}
function panelHoehe() {
const fuss = document.querySelector('.fuss').getBoundingClientRect().top;
const h = fuss > innerHeight * .62 && fuss <= innerHeight ? fuss - 10 : innerHeight;
html.style.setProperty('--panel-h', Math.round(h) + 'px');
}
function setze(auf, { fokus = false } = {}) {
if (auf === offen) return;
offen = auf;
/* Zustand sofort synchron, unabhängig vom Animationsende */
html.classList.toggle('offen', auf);
knopf.setAttribute('aria-expanded', String(auf));
knopf.querySelector('.sr').textContent = auf ? 'Menü schließen' : 'Menü';
panel.inert = !auf;
if (auf) panelHoehe();
const d = v();
if (tl) {
if (auf) { tl.timeScale(1).play(); }
else {
const unterbrochen = tl.progress() < 1 && tl.isActive();
tl.timeScale(variante === 'spiegel' ? 1 : unterbrochen ? 2 : 1.5).reverse();
}
}
messen(auf ? 'auf' : 'zu', d[auf ? 'auf' : 'zu'].ms);
kopfWaechter();
if (fokus) (auf ? panel.querySelector('a') : knopf).focus({ preventScroll: true });
}
knopf.addEventListener('click', () => { stoppeVorfuehrung(); setze(!offen, { fokus: true }); });
addEventListener('keydown', e => { if (e.key === 'Escape' && offen) { stoppeVorfuehrung(); setze(false, { fokus: true }); } });
panel.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });
addEventListener('resize', () => { if (offen) panelHoehe(); });
document.querySelector('[data-varianten]').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
stoppeVorfuehrung();
wechsle(b.dataset.v);
vorfuehren();
});
function wechsle(name) {
variante = name;
body.dataset.variante = name;
document.querySelectorAll('[data-v]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.v === name)));
sofortZu();
zeigeWerte();
}
/* Sofort zu, ohne Übergang */
function sofortZu() {
offen = false;
body.style.setProperty('transition', 'none');
panel.querySelectorAll('*').forEach(n => n.style.transition = 'none'); panel.style.transition = 'none';
html.classList.remove('offen', 'kopf-hell'); cancelAnimationFrame(waechter);
knopf.setAttribute('aria-expanded', 'false'); panel.inert = true;
baueTimeline();
panel.offsetWidth;
panel.querySelectorAll('*').forEach(n => n.style.transition = ''); panel.style.transition = '';
body.style.removeProperty('transition');
cancelAnimationFrame(lauf);
for (const r of ['auf', 'zu']) { spuren[r].classList.remove('laeuft'); spuren[r].style.setProperty('--p', 0); spuren[r].querySelector('[data-punkt]').setAttribute('r', 0); }
}
/* Vorführung: gezeichneter Zeiger öffnet und schließt das Menü */
const warte = (fn, ms) => timer.push(setTimeout(fn, ms));
function stoppeVorfuehrung() { timer.forEach(clearTimeout); timer = []; html.classList.remove('vorfuehrung'); }
function zeigerAuf(x, y) { zeiger.style.transform = `translate(${x}px, ${y}px)`; }
function drueck(dann) {
zeiger.classList.add('drueckt'); knopf.classList.add('gedrueckt');
warte(() => { zeiger.classList.remove('drueckt'); knopf.classList.remove('gedrueckt'); dann(); }, 140);
}
function knopfMitte() { const r = knopf.getBoundingClientRect(); return [r.left + r.width * .42, r.top + r.height * .55]; }
function neustart() {
stoppeVorfuehrung();
sofortZu();
warte(() => setze(true), 250);
warte(() => setze(false), 250 + v().auf.ms + 1400);
}
function vorfuehren() {
stoppeVorfuehrung();
sofortZu();
html.classList.add('vorfuehrung');
const halten = 1500, d = v();
zeiger.style.transition = 'none';
zeigerAuf(innerWidth * .52, innerHeight * .58);
zeiger.offsetWidth; zeiger.style.transition = '';
warte(() => zeigerAuf(...knopfMitte()), 60);
warte(() => drueck(() => setze(true)), 760);
const zuZeit = 900 + d.auf.ms + halten;
warte(() => drueck(() => setze(false)), zuZeit);
warte(() => zeigerAuf(innerWidth * .52, innerHeight * .58), zuZeit + d.zu.ms + 300);
warte(vorfuehren, zuZeit + d.zu.ms + 1400);
}
function pause() { timer.forEach(clearTimeout); timer = []; if (tl) tl.pause(); }
function weiter() { vorfuehren(); }
zeigeWerte();
baueTimeline();
new MutationObserver(() => { const r = reduziert(); if (r !== zeigeWerte.r) { zeigeWerte.r = r; stoppeVorfuehrung(); sofortZu(); zeigeWerte(); } })
.observe(html, { attributes: true, attributeFilter: ['class'] });
zeigeWerte.r = reduziert();
})();
</script>
</body>
</html>