Menü-Choreografie: Panel-Wipe bzw. Clip-Reveal mit asymmetrischem Schließen
Drei Farbpanels wischen gestaffelt herein, dann steigen die Links gedreht aus ihren Masken; geschlossen wird rückwärts, 1,5-mal schneller und mit eigener Kurve.
- Klick auf den Menüknopf: zwei bis drei farbige Panels fahren nacheinander von der Seite herein (xPercent 101 → 0). Alternativ öffnet sich ein Clip-Path vom Knopf aus (circle, inset oder polygon).
- Kurz bevor das letzte Panel steht, steigen die Menülinks aus ihren Masken nach oben, leicht gedreht (10°) und gestaffelt.
- Danach blenden Nebeninhalte wie Adresse, Social und Kleingedrucktes mit kleinem Versatz ein.
- Ein halbtransparenter Schleier dunkelt die Seite ab, ein Klick darauf schließt.
- Zum Schließen läuft dieselbe Timeline rückwärts, aber 1,5-mal schneller und mit eigener Kurve (GSAP easeReverse). Wird mitten in der Bewegung umgeschaltet, rechnet GSAP die Kurve ab dem Umkehrpunkt neu.
Die Schichten erzeugen Tiefe und Rhythmus, die Staffelung führt den Blick vom Hintergrund zu den Links. Das asymmetrische Timing trifft die Erwartung: Öffnen darf inszeniert sein, Schließen muss sofort reagieren. Weil die Bewegung jederzeit umkehrbar ist, hängt das Menü nie.
Wann einsetzen
Overlay- oder Seitenleisten-Navigation mit Markencharakter, vor allem bei Studios, Agenturen und Kampagnenseiten.
Wann nicht
Wenn das Menü sehr oft geöffnet wird, etwa in Web-Apps oder Dokumentationen. Dort höchstens 200 bis 300 ms und keine mehrstufige Choreografie.
Varianten
- Seitenleiste mit drei gestaffelten Farbpanels, die von rechts hereinwischen (Osmo)
- Clip-Path-Kreis wächst aus dem Menüknopf (Codrops, TRIONN)
- Vorhang: clip-path polygon fährt von unten auf volle Fläche (Stewart & Partners)
- Vorhang von oben mit konkaver SVG-Kante (Shape-Overlay)
- Radialmenü (GSAP-3.15-Demo)
Umsetzung
Beim Init eine pausierte GSAP-Timeline bauen: Panels (xPercent 101 → 0, stagger), Links (yPercent 140, rotate 10 → 0) über den Positionsparameter verzahnt, danach die Fades. Umschalten: offen ? tl.timeScale(1).play() : tl.timeScale(1.5).reverse(). Ab GSAP 3.15 easeReverse in den Timeline-defaults setzen (true für die gespiegelte Kurve oder eine eigene, z. B. "power3.out"); das ersetzt das veraltete yoyoEase. Für die Kreis-Variante clip-path: circle(0px at X Y) → circle(R at X Y), X und Y aus getBoundingClientRect des Knopfs, R als Abstand zur fernsten Ecke. Fallstricke: display:none am Container verhindert das Messen, besser visibility bzw. autoAlpha. Panels mit will-change: transform. Klicks nur auf echten <button>-Elementen.
Robust umsetzen
Telefon. Gleiche Timeline, Panels vollflächig statt 36em breit. Dauer und Stagger um 20 bis 30 % kürzen.
Ohne Bewegung. Die Timeline durch einen einfachen Opacity-Fade (150 ms) ersetzen, zentral über gsap.matchMedia('(prefers-reduced-motion: reduce)').
Barrierefrei. Echter <button> mit aria-expanded und aria-controls. Escape schließt. Der Klick auf den Schleier schließt zusätzlich, nicht ausschließlich. Nach dem Öffnen per Tastatur Fokus auf den ersten Link, Hintergrund inert. Beim Schließen Fokus zurück auf den Knopf.
Code
<!DOCTYPE html>
<!--
Menü-Choreografie: Panel-Wipe bzw. Clip-Reveal mit asymmetrischem Schließen
Fundus · Navigation · https://cecodesign.de/lab/fundus/navigation/menue-choreografie-wipe-clip/
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>Menü-Choreografie: Panel-Wipe und Clip-Reveal · 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 {
--papier: #eeebe4; --tinte: #141413; --leise: rgba(20, 20, 19, .66); --linie: rgba(20, 20, 19, .16);
--hell: #f3f1ea; --hell-leise: rgba(243, 241, 234, .55); --hell-linie: rgba(243, 241, 234, .16);
--schicht-1: #d3cbbd; --schicht-2: #e2552b; --schicht-3: #141413;
--akzent: #ff7a4d;
--ui: cubic-bezier(0.65, 0.05, 0, 1); /* CSS-Kurve aus den Messwerten, für Hover 150 ms */
--rand: clamp(16px, 3.4vw, 44px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
:focus-visible { outline: 2px solid var(--akzent); outline-offset: 4px; border-radius: 2px; }
/* ---------- Seite unter dem Menü ---------- */
.kopf { position: fixed; inset: 0 0 auto; height: 84px; padding: 0 var(--rand); display: flex; align-items: center; z-index: 10; pointer-events: none; }
.kopf > * { pointer-events: auto; }
.marke { font-size: 19px; font-weight: 600; letter-spacing: -.03em; display: flex; align-items: baseline; gap: .28em; }
.marke em { font-size: 1.15em; color: var(--schicht-2); }
.marke small { font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); margin-left: .9em; }
.seite { min-height: 100svh; display: grid; grid-template-rows: 1fr auto; gap: clamp(28px, 4vh, 48px); padding: 120px var(--rand) var(--rand); }
.oben { align-self: end; display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 40px; row-gap: 22px; align-items: end; }
.oben > .mono { grid-column: 1 / -1; }
.oben .mono { color: var(--leise); display: flex; gap: 18px; flex-wrap: wrap; }
h1 { font-size: clamp(40px, 7.4vw, 118px); font-weight: 500; line-height: .94; letter-spacing: -.05em; max-width: 13ch; }
h1 em { color: var(--schicht-2); }
.unten { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 3fr); gap: clamp(16px, 2vw, 28px); align-items: end; }
.intro { font-size: clamp(15px, 1.2vw, 17px); line-height: 1.5; color: var(--leise); max-width: 30ch; padding-bottom: .5em; }
.arbeiten { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(10px, 1.4vw, 20px); list-style: none; }
.arbeiten figure { display: grid; gap: 10px; }
.arbeiten img { display: block; width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 4px; background: #ddd8cd; }
.arbeiten figcaption { display: flex; justify-content: space-between; gap: 10px; color: var(--leise); }
.arbeiten figcaption b { color: var(--tinte); font-weight: 500; }
/* ---------- Menüknopf ---------- */
.knopf { position: fixed; top: 20px; right: calc(var(--rand) - 6px); z-index: 40; height: 44px; padding: 0 6px 0 16px; display: flex; align-items: center; gap: 12px; border-radius: 999px; color: var(--tinte); }
.knopf-text { position: relative; display: block; height: 1.35em; overflow: hidden; text-align: right; }
.knopf-text span { display: block; }
.knopf-icon { position: relative; width: 32px; height: 32px; border-radius: 50%; background: currentColor; }
.knopf-icon i { position: absolute; left: 10px; right: 10px; height: 1.5px; margin-top: -.75px; background: var(--papier); top: 50%; }
.knopf-icon i:first-child { top: calc(50% - 3px); }
.knopf-icon i:last-child { top: calc(50% + 3px); }
/* ---------- Menü ---------- */
.menue { position: fixed; inset: 0; z-index: 30; visibility: hidden; }
.schleier { position: absolute; inset: 0; background: rgba(20, 20, 19, .52); opacity: 0; }
.flaeche { position: absolute; top: 0; right: 0; bottom: 0; width: min(36em, 100%); overflow: hidden; }
.menue[data-variante='kreis'] .flaeche, .menue[data-variante='vorhang'] .flaeche { width: 100%; }
.schicht { position: absolute; inset: 0; will-change: transform, clip-path; }
.schicht:nth-child(1) { background: var(--schicht-1); }
.schicht:nth-child(2) { background: var(--schicht-2); }
.schicht:nth-child(3) { background: var(--schicht-3); }
.inhalt { position: absolute; inset: 0; color: var(--hell); display: grid; grid-template-rows: 84px 1fr auto; padding: 0 var(--rand) var(--rand); }
.zeile-oben { display: flex; align-items: center; color: var(--hell-leise); }
.links { list-style: none; align-self: center; display: grid; gap: clamp(2px, .6vh, 8px); }
.maske { display: block; overflow: hidden; padding-block: .1em; margin-block: -.1em; }
.links a { display: inline-flex; align-items: flex-start; gap: .35em; font-size: clamp(44px, 5.4vw, 78px); font-weight: 500; line-height: 1; letter-spacing: -.045em; transition: opacity .15s var(--ui), color .15s var(--ui); }
.wort { display: inline-flex; align-items: flex-start; gap: .22em; transform-origin: 0% 50%; will-change: transform; }
.links sup { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; color: var(--hell-leise); margin-top: .55em; }
.links a em { color: var(--akzent); }
.links:hover a, .links.zeigt a { opacity: .38; }
.links a:hover, .links a:focus-visible, .links a.an { opacity: 1; color: var(--akzent); }
.unterteil { display: grid; gap: 22px; }
.trenner { display: block; height: 1px; background: var(--hell-linie); transform-origin: 0 50%; }
.neben { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 20px; }
.neben .mono { color: var(--hell-leise); margin-bottom: 8px; }
.neben p, .neben a { font-size: 14px; line-height: 1.5; }
.neben a { display: block; width: fit-content; transition: color .15s var(--ui); }
.neben a:hover { color: var(--akzent); }
.frei { display: inline-flex; align-items: center; gap: 8px; }
.frei::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #7bd88f; }
/* Vollflächig: Links groß links, Nebeninfos rechts unten */
.menue[data-variante='kreis'] .inhalt, .menue[data-variante='vorhang'] .inhalt { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); grid-template-rows: 84px 1fr; column-gap: 40px; }
.menue[data-variante='kreis'] .zeile-oben, .menue[data-variante='vorhang'] .zeile-oben { grid-column: 1 / -1; }
.menue[data-variante='kreis'] .links, .menue[data-variante='vorhang'] .links { padding-bottom: 150px; }
.menue[data-variante='kreis'] .links a, .menue[data-variante='vorhang'] .links a { font-size: clamp(48px, 6.6vw, 100px); }
.menue[data-variante='kreis'] .unterteil, .menue[data-variante='vorhang'] .unterteil { grid-column: 2; grid-row: 2; align-self: end; }
.menue[data-variante='kreis'] .links, .menue[data-variante='vorhang'] .links { grid-column: 1; grid-row: 2; }
.teaser { display: none; }
.menue[data-variante='kreis'] .teaser, .menue[data-variante='vorhang'] .teaser { display: grid; grid-column: 2; grid-row: 2; align-self: start; gap: 12px; margin-top: clamp(16px, 4vh, 40px); color: var(--hell); }
.teaser img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 4px; }
.teaser p { display: flex; justify-content: space-between; gap: 12px; color: var(--hell-leise); }
.teaser p b { color: var(--hell); font-weight: 500; }
.menue[data-variante='kreis'] .neben, .menue[data-variante='vorhang'] .neben { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 26px; }
/* ---------- Pult: Varianten und Zeitleiste ---------- */
.pult { position: fixed; left: var(--rand); bottom: var(--rand); z-index: 50; width: min(340px, calc(100% - 2 * var(--rand))); padding: 12px 14px 13px; border-radius: 10px; background: rgba(20, 20, 19, .86); color: var(--hell); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); display: grid; gap: 11px; box-shadow: 0 10px 30px -14px rgba(0, 0, 0, .5); }
.varianten { display: flex; gap: 4px; }
.varianten button { color: var(--hell-leise); border: 1px solid var(--hell-linie); border-radius: 999px; height: 26px; padding: 0 10px; transition: color .15s var(--ui), background .15s var(--ui), border-color .15s var(--ui); }
.varianten button:hover { color: var(--hell); border-color: rgba(243, 241, 234, .34); }
.varianten button[aria-pressed='true'] { background: var(--hell); color: var(--tinte); border-color: var(--hell); }
.zeit { display: grid; gap: 7px; }
.zeit-kopf { display: flex; justify-content: space-between; gap: 10px; color: var(--hell-leise); }
.zeit-kopf b { color: var(--hell); font-weight: 500; }
.zeit-kopf [data-richtung].zu { color: var(--akzent); }
.balken { position: relative; height: 3px; border-radius: 3px; background: var(--hell-linie); }
.balken i { position: absolute; left: 0; top: 0; bottom: 0; width: 100%; border-radius: inherit; background: var(--hell); transform-origin: 0 50%; transform: scaleX(0); }
.balken.zu i { background: var(--akzent); }
.balken u { position: absolute; top: -4px; width: 1.5px; height: 11px; background: var(--akzent); opacity: 0; translate: -50% 0; transition: opacity .15s linear; }
.balken u.an { opacity: 1; }
.werte { display: flex; flex-wrap: wrap; gap: 3px 14px; color: var(--hell-leise); font-size: 10px; }
.werte b { color: var(--hell); font-weight: 500; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .9s cubic-bezier(0.65, 0.05, 0, 1); }
.zeiger path { transition: scale .15s var(--ui); transform-origin: 3px 2px; }
.zeiger.klick path { scale: .78; }
.vorfuehrung .zeiger { display: block; }
/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
/* Reduzierte Bewegung: nur Überblenden (läuft über JS, 150 ms) */
:root.reduziert .zeiger { transition: none; }
:root.reduziert .links a, :root.reduziert .varianten button { transition: none; }
@media (max-width: 860px) {
.unten { grid-template-columns: 1fr; }
.oben { grid-template-columns: 1fr; }
.unten > span { display: none; }
.seite { padding-bottom: 160px; }
.menue[data-variante] .inhalt { grid-template-columns: 1fr; grid-template-rows: 84px 1fr auto; }
.menue[data-variante] .links { padding-bottom: 0; }
.menue[data-variante] .teaser { display: none; }
.menue[data-variante] .links, .menue[data-variante] .unterteil { grid-column: 1; grid-row: auto; }
.menue[data-variante] .neben { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.menue[data-variante] .unterteil { padding-bottom: 96px; }
}
@media (max-width: 560px) {
.kopf { height: 72px; }
.knopf { top: 14px; }
.arbeiten figcaption span { display: none; }
.marke small { display: none; }
.seite { padding-top: 104px; }
.arbeiten { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.arbeiten li:nth-child(3) { display: none; }
.knopf-text { display: none; }
.inhalt, .menue[data-variante] .inhalt { grid-template-rows: 72px 1fr auto; }
.links a, .menue[data-variante] .links a { font-size: clamp(40px, 12vw, 56px); }
.neben p, .neben a { font-size: 13px; }
.neben > div:nth-child(3) { display: none; }
.werte { display: none; }
}
@media (hover: none) {
.links:hover a { opacity: 1; }
.links a:hover { color: inherit; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#start" data-marke>Ostwind <em>&</em> Kalk <small>Markengestaltung · Köln</small></a>
</header>
<button class="knopf mono" type="button" aria-expanded="false" aria-controls="menue" data-knopf>
<span class="knopf-text" aria-hidden="true"><span data-label>Menü</span><span data-label>Schließen</span></span>
<span class="knopf-icon" aria-hidden="true"><i data-strich></i><i data-strich></i></span>
<span class="nur-sr" style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)" data-sr>Menü öffnen</span>
</button>
<main class="seite" id="start" data-seite>
<div class="oben">
<p class="mono"><span>Studio für Marke & Editorial</span><span>Seit 2014</span><span>Ehrenfeld, Köln</span></p>
<h1>Marken, die man <em>wieder­erkennt</em>, bevor man sie liest.</h1>
<p class="intro">Wir entwickeln Erscheinungsbilder für Cafés, Handwerk und Kultur. Klein im Team, gründlich in der Sache und bis zum letzten Etikett dabei.</p>
</div>
<div class="unten">
<span aria-hidden="true"></span>
<ul class="arbeiten">
<li><figure><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin mit Keramik-Stillleben auf Terrakotta und dem Titel Die Ruhe der Dinge"><figcaption class="mono"><b>Die Ruhe der Dinge</b><span>Editorial</span></figcaption></figure></li>
<li><figure><img src="bilder/marke-krume.jpg" alt="Wortmarke krume in fetter dunkelbrauner Schrift auf Sonnengelb, darüber ein Brotlaib"><figcaption class="mono"><b>Krume</b><span>Marke</span></figcaption></figure></li>
<li><figure><img src="bilder/marke-roestkontor.jpg" alt="Wortmarke Röstkontor in breiten weißen Versalien auf Ziegelrot"><figcaption class="mono"><b>Röstkontor</b><span>Verpackung</span></figcaption></figure></li>
</ul>
</div>
</main>
<nav class="menue" id="menue" aria-label="Hauptmenü" data-menue data-variante="wipe">
<div class="schleier" data-schleier></div>
<div class="flaeche" data-flaeche>
<div class="schicht" data-schicht></div>
<div class="schicht" data-schicht></div>
<div class="schicht" data-schicht></div>
<div class="inhalt">
<p class="zeile-oben mono" data-fade>Navigation (05)</p>
<ul class="links" data-links>
<li><span class="maske"><a href="#arbeiten"><span class="wort" data-wort>Arbeiten<sup>01</sup></span></a></span></li>
<li><span class="maske"><a href="#studio"><span class="wort" data-wort>Studio<sup>02</sup></span></a></span></li>
<li><span class="maske"><a href="#leistungen"><span class="wort" data-wort>Leistungen<sup>03</sup></span></a></span></li>
<li><span class="maske"><a href="#journal"><span class="wort" data-wort><span><em>Journal</em></span><sup>04</sup></span></a></span></li>
<li><span class="maske"><a href="#kontakt"><span class="wort" data-wort>Kontakt<sup>05</sup></span></a></span></li>
</ul>
<a class="teaser" href="#journal" data-fade><img src="bilder/typo-plakat.jpg" alt=""><p class="mono"><b>Neu im Journal</b><span>Ein Plakat, drei Formate</span></p></a>
<div class="unterteil">
<i class="trenner" data-trenner></i>
<div class="neben">
<div data-fade><p class="mono">Atelier</p><p>Venloer Straße 241<br>50823 Köln</p></div>
<div data-fade><p class="mono">Anfragen</p><a href="#kontakt">hallo@ostwind-kalk.de</a><p class="frei">Frei ab März 2027</p></div>
<div data-fade><p class="mono">Folgen</p><a href="#instagram">Instagram</a><a href="#newsletter">Newsletter</a></div>
</div>
</div>
</div>
</div>
</nav>
<aside class="pult mono" aria-label="Demo-Steuerung">
<div class="varianten" role="group" aria-label="Variante">
<button type="button" data-variante="wipe" aria-pressed="true">Wipe</button>
<button type="button" data-variante="kreis" aria-pressed="false">Kreis</button>
<button type="button" data-variante="vorhang" aria-pressed="false">Vorhang</button>
</div>
<div class="zeit">
<div class="zeit-kopf"><span data-richtung>Geschlossen</span><span data-dauern></span></div>
<div class="balken" data-balken><i data-fortschritt></i><u data-umkehr></u></div>
</div>
<p class="werte" data-werte aria-live="polite"></p>
</aside>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 17 10.2l-6.4 1.4-3 6Z" fill="#141413" stroke="#f3f1ea" stroke-width="1.3" 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/CustomEase.min.js"></script>
<script>
(() => {
gsap.registerPlugin(CustomEase);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
// Die gemessene Kurve (Osmo „main“) für alles, was sich öffnet.
CustomEase.create('main', 'M0,0 C0.65,0.01 0.05,0.99 1,1');
const OEFFNEN = {
panel: 0.575, panelVersatz: 0.12, // Panels 575 ms, Stagger 120 ms
links: 0.7, linkVersatz: 0.05, linkStart: 0.35, // Links 350 ms nach den Panels
fade: 0.7, fadeVersatz: 0.04, fadeStart: 0.2, // Fades 200 ms nach den Links
icon: 0.4, // Knopf-Icon und Label 400 ms
};
const SCHLIESSEN = { zeitfaktor: 1.5, kurve: 'power3.out' }; // rückwärts, schneller, eigene Kurve
const TELEFON = 0.75; // unter 600 px: Dauer und Stagger −25 %
const menue = $('[data-menue]'), flaeche = $('[data-flaeche]'), schleier = $('[data-schleier]');
const schichten = $$('[data-schicht]'), woerter = $$('[data-wort]'), fades = $$('[data-fade]'), trenner = $('[data-trenner]');
const knopf = $('[data-knopf]'), labels = $$('[data-label]'), striche = $$('[data-strich]'), sr = $('[data-sr]');
const seite = $('[data-seite]'), marke = $('[data-marke]'), links = $('[data-links]');
const balken = $('[data-balken]'), fortschritt = $('[data-fortschritt]'), umkehr = $('[data-umkehr]');
const richtung = $('[data-richtung]'), dauern = $('[data-dauern]'), werte = $('[data-werte]');
const zeiger = $('[data-zeiger]');
const knoepfe = $$('.varianten [data-variante]');
let variante = 'wipe', tl = null, offen = false, faktor = 1;
const komma = s => s.toFixed(2).replace('.', ',');
// Startwerte der Schichten je Variante
function schichtWege() {
if (variante === 'wipe') return { von: { xPercent: 101 }, nach: { xPercent: 0 } };
if (variante === 'vorhang') return {
von: { clipPath: 'polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)' },
nach: { clipPath: 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)' },
};
// Kreis: Mittelpunkt im Knopf-Icon, Radius bis zur fernsten Ecke
const r = knopf.querySelector('.knopf-icon').getBoundingClientRect();
const x = Math.round(r.left + r.width / 2), y = Math.round(r.top + r.height / 2);
const rad = Math.ceil(Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)));
return { von: { clipPath: `circle(0px at ${x}px ${y}px)` }, nach: { clipPath: `circle(${rad}px at ${x}px ${y}px)` } };
}
function bauen() {
const war = offen;
if (tl) { tl.kill(); tl = null; }
gsap.set([menue, schleier, ...schichten, ...woerter, ...fades, trenner, ...labels, ...striche, knopf], { clearProps: 'all' });
umkehr.classList.remove('an');
faktor = innerWidth < 600 ? TELEFON : 1;
const O = OEFFNEN, f = faktor;
if (reduziert()) {
// Ein Opacity-Fade, 150 ms, alles andere steht schon
tl = gsap.timeline({ paused: true, onUpdate: anzeigen, onComplete: anzeigen, onReverseComplete: fertigZu })
.set(menue, { visibility: 'visible' })
.set(schleier, { opacity: 1 })
.set(labels, { yPercent: -100 })
.set(striche[0], { rotate: 45, y: 3, backgroundColor: '#141413' }).set(striche[1], { rotate: -45, y: -3, backgroundColor: '#141413' })
.set(knopf, { color: '#f3f1ea' })
.fromTo(menue, { opacity: 0 }, { opacity: 1, duration: 0.15, ease: 'none' });
} else {
const w = schichtWege();
tl = gsap.timeline({
paused: true,
defaults: { ease: 'main', easeReverse: SCHLIESSEN.kurve },
onUpdate: anzeigen, onComplete: anzeigen, onReverseComplete: fertigZu,
});
tl.set(menue, { visibility: 'visible' }, 0)
.fromTo(schleier, { opacity: 0 }, { opacity: 1, duration: O.panel * f }, 0)
.fromTo(schichten, w.von, { ...w.nach, duration: O.panel * f, stagger: O.panelVersatz * f }, 0)
.to(labels, { yPercent: -100, duration: O.icon * f }, 0.05 * f)
.to(striche[0], { rotate: 45, y: 3, duration: O.icon * f }, 0.05 * f)
.to(striche[1], { rotate: -45, y: -3, duration: O.icon * f }, 0.05 * f)
.to(knopf, { color: '#f3f1ea', duration: 0.3 * f }, 0.15 * f)
.to(striche, { backgroundColor: '#141413', duration: 0.3 * f }, 0.15 * f)
.fromTo(woerter, { yPercent: 140, rotate: 10 }, { yPercent: 0, rotate: 0, duration: O.links * f, stagger: O.linkVersatz * f }, O.linkStart * f)
.fromTo(trenner, { scaleX: 0 }, { scaleX: 1, duration: O.fade * f }, (O.linkStart + O.fadeStart) * f)
.fromTo(fades, { autoAlpha: 0, yPercent: 50 }, { autoAlpha: 1, yPercent: 0, duration: O.fade * f, stagger: O.fadeVersatz * f }, (O.linkStart + O.fadeStart) * f);
}
if (war) tl.progress(1);
zeigeWerte();
anzeigen();
}
function zeigeWerte() {
const zu = reduziert() ? tl.duration() : tl.duration() / SCHLIESSEN.zeitfaktor;
dauern.innerHTML = `<b>${komma(tl.duration())} s</b> auf · <b>${komma(zu)} s</b> zu`;
if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>Überblenden 150 ms</b></span>'; return; }
const f = faktor, ms = v => Math.round(v * f * 1000);
const weg = { wipe: 'xPercent 101 → 0', kreis: 'circle() aus dem Knopf', vorhang: 'polygon() von unten' }[variante];
werte.innerHTML = `<span>Auf <b>main .65 .01 .05 .99</b></span><span>Panels <b>${ms(OEFFNEN.panel)} / ${ms(OEFFNEN.panelVersatz)} ms</b></span><span><b>${weg}</b></span>`
+ `<span>Links <b>140 %, 10° / ${ms(OEFFNEN.linkVersatz)} ms</b></span><span>Zu <b>×1.5 · easeReverse ${SCHLIESSEN.kurve}</b></span>`;
}
// Zeitleiste im Pult: Fortschritt, Richtung, Umkehrpunkt
function anzeigen() {
if (!tl) return;
const p = tl.progress(), zu = tl.reversed();
fortschritt.style.transform = `scaleX(${p})`;
balken.classList.toggle('zu', zu);
richtung.classList.toggle('zu', zu && p > 0);
richtung.textContent = p === 0 ? 'Geschlossen' : p === 1 && !zu ? 'Offen' : zu ? 'Schließt ×1,5' : 'Öffnet';
}
function fertigZu() {
anzeigen();
gsap.set(menue, { visibility: 'hidden' });
}
function setzeOffen(auf, { fokus = false } = {}) {
if (auf === offen) return;
offen = auf;
const p = tl.progress();
if (!auf && p > 0 && p < 1) { umkehr.style.left = `${p * 100}%`; umkehr.classList.add('an'); }
else if (auf) umkehr.classList.remove('an');
if (auf) tl.timeScale(1).play();
else tl.timeScale(reduziert() ? 1 : SCHLIESSEN.zeitfaktor).reverse();
knopf.setAttribute('aria-expanded', String(auf));
sr.textContent = auf ? 'Menü schließen' : 'Menü öffnen';
seite.inert = auf; marke.inert = auf;
if (auf && fokus) setTimeout(() => offen && links.querySelector('a').focus({ preventScroll: true }), reduziert() ? 60 : 450);
if (!auf && menue.contains(document.activeElement)) knopf.focus({ preventScroll: true });
}
// Bedienung
knopf.addEventListener('click', e => { if (e.isTrusted) stopVorfuehrung(); setzeOffen(!offen, { fokus: e.detail === 0 }); });
schleier.addEventListener('click', () => { stopVorfuehrung(); setzeOffen(false); });
addEventListener('keydown', e => { if (e.key === 'Escape' && offen) { stopVorfuehrung(); setzeOffen(false); } });
menue.addEventListener('click', e => { const a = e.target.closest('a'); if (a) { e.preventDefault(); setzeOffen(false); } });
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.variante); }));
function setzeVariante(v) {
variante = v;
menue.dataset.variante = v;
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
bauen();
}
let breite = innerWidth;
addEventListener('resize', () => {
if (innerWidth === breite && variante !== 'kreis') return;
breite = innerWidth;
clearTimeout(bauen.t); bauen.t = setTimeout(bauen, 120);
});
// ---------- Vorführung: gedachter Zeiger klickt den Knopf ----------
let uhren = [], laeuft = false, runde = 0;
const spaeter = (ms, fn) => uhren.push(setTimeout(fn, ms));
function zeigerZu(el, dx = 0.5, dy = 0.5) {
const r = el.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + r.width * dx - 3}px, ${r.top + r.height * dy - 2}px)`;
}
function klick() {
zeiger.classList.add('klick'); setTimeout(() => zeiger.classList.remove('klick'), 160);
setzeOffen(!offen);
}
function hover(i) {
const a = links.querySelectorAll('a');
a.forEach((el, j) => el.classList.toggle('an', j === i));
links.classList.toggle('zeigt', i >= 0);
}
function stopVorfuehrung() {
if (!laeuft) return;
pause(); laeuft = false; hover(-1);
html.classList.remove('vorfuehrung');
}
function eineRunde() {
const icon = knopf.querySelector('.knopf-icon');
const ruhe = { x: innerWidth * 0.62, y: innerHeight * 0.55 };
zeiger.style.transform = `translate(${ruhe.x}px, ${ruhe.y}px)`;
const zu = (tl.duration() / SCHLIESSEN.zeitfaktor) * 1000, auf = tl.duration() * 1000;
let t = 300;
spaeter(t, () => zeigerZu(icon)); t += 1000;
spaeter(t, klick); t += auf + 250; // öffnen
spaeter(t, () => { zeigerZu(links.querySelectorAll('a')[1], 0.6, 0.55); }); t += 800;
spaeter(t, () => hover(1)); t += 500;
spaeter(t, () => { zeigerZu(links.querySelectorAll('a')[3], 0.5, 0.55); }); t += 450;
spaeter(t, () => hover(3)); t += 900;
spaeter(t, () => { hover(-1); zeigerZu(icon); }); t += 950;
spaeter(t, klick); t += zu + 650; // schließen, schneller
spaeter(t, klick); t += auf * 0.42; // öffnen und mittendrin umkehren
spaeter(t, klick); t += zu + 1400;
spaeter(t, () => {
runde++;
setzeVariante(['wipe', 'kreis', 'vorhang'][runde % 3]);
eineRunde();
});
}
function neustart() {
pause(); laeuft = false; hover(-1);
html.classList.remove('vorfuehrung');
offen = false; tl.pause(0); fertigZu();
knopf.setAttribute('aria-expanded', 'false'); sr.textContent = 'Menü öffnen';
seite.inert = false; marke.inert = false;
umkehr.classList.remove('an');
anzeigen();
}
function vorfuehren() {
neustart();
laeuft = true;
html.classList.add('vorfuehrung');
runde = ['wipe', 'kreis', 'vorhang'].indexOf(variante);
eineRunde();
}
function pause() { uhren.forEach(clearTimeout); uhren = []; if (tl) tl.pause(); }
function weiter() { if (html.classList.contains('vorfuehrung')) vorfuehren(); else if (tl && tl.progress() > 0 && tl.progress() < 1) tl.resume(); }
bauen();
// Schalter der Detailseite setzt .reduziert nachträglich: neu bauen
new MutationObserver(() => { const r = reduziert(); if (r !== bauen.r) { bauen.r = r; bauen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
bauen.r = reduziert();
})();
</script>
</body>
</html>