Richtungsabhängiger Marquee-Hover im Menü
Der Menüpunkt deckt beim Hover ein Laufband mit Gerichten auf, und zwar von genau der Kante, über die die Maus hereinkam.
- Jeder Menüeintrag trägt eine verborgene Ebene mit einem Laufband aus Text und kleinen Bildern.
- Beim Betreten wird ermittelt, ob die Maus über die obere oder die untere Kante kam.
- Die äußere Ebene fährt von dieser Kante herein, die innere gleichzeitig von der Gegenseite. So wirkt es wie ein Ausschnitt, der den Inhalt aufdeckt, statt ihn hereinzuschieben.
- Beim Verlassen fährt die Ebene zur Austrittskante hinaus.
- Das Laufband selbst läuft linear und ohne Pause.
Die Navigation antwortet auf die Bewegung der Hand und fühlt sich dadurch physisch an. Das Laufband bringt Energie und Bildwelt in eine reine Textliste. Der Menüpunkt wird für einen Moment zur Bühne.
Wann einsetzen
Vollbild-Menüs, Projektindexe und Kampagnenseiten mit drei bis sechs Einträgen. Passt zu Agenturen, Mode, Musik und zu Gastronomie mit Charakter, wo das Laufband Gerichte oder Getränke zeigen kann.
Wann nicht
Ruhige, seriöse Marken. Lange Listen und Shops, in denen schnelle Orientierung wichtiger ist als Wirkung. Seiten, die fast nur auf dem Telefon besucht werden.
Varianten
- Text und kleine Bilder im Laufband (Demo: „Text + Bild“)
- Nur Text in Umkehrfarbe, Trenner statt Bilder (Demo: „Nur Text“)
- Nur Farbfläche ohne Laufband, der Eintrag erscheint in Umkehrfarbe (Demo: „Nur Fläche“)
- Richtung aus der Eintrittshälfte statt aus der nächstgelegenen Kante
- Reine CSS-Fassung ohne Richtungserkennung (Text-Marquee)
- Bildvorschau am Zeiger statt Laufband (siehe listen-hover-bildvorschau)
Umsetzung
Aufbau pro Eintrag: .marquee (absolut über dem Eintrag, overflow: hidden) > .marquee__wrap > .marquee__spur. Die Spur enthält den Inhalt doppelt und läuft per CSS-Keyframes von translate3d(0) nach translate3d(−50 %), 15 s linear unendlich. Inhalt so oft wiederholen, dass eine Hälfte breiter ist als das Fenster. Kante bestimmen: (e.clientY − rect.top) < (rect.bottom − e.clientY) ergibt oben, sonst unten. Hereinfahren: gsap.timeline mit set (außen yPercent ±101, innen ∓101), dann beide auf 0, 600 ms expo.out. Hinausfahren: außen zur Austrittskante, innen gegenläufig. Laufende Tweens mit overwrite: true ersetzen, sonst flackert es bei schnellem Wechsel. Fallstrick: pageY und offsetTop brechen bei transformierten Eltern, deshalb getBoundingClientRect und clientY. Startlage per gsap.set mit y: 0 setzen, damit GSAP eine CSS-Startlage nicht doppelt einrechnet. Das Laufband nur im aktiven Eintrag laufen lassen (animation-play-state).
Robust umsetzen
Telefon. Ohne Hover bleiben die Einträge groß und statisch. Ein festes Bild je Eintrag ersetzt das Laufband. Optional zeigt ein Tipp die Ebene kurz von unten; für die echte Navigation den Tipp nicht verzögern.
Ohne Bewegung. Kein Laufband, kein Hereinfahren. Der Eintrag wechselt beim Hover in Umkehrfarbe und blendet kurz ein.
Barrierefrei. Die Laufband-Ebene ist ein Duplikat und bekommt aria-hidden. :focus-visible zeigt die Ebene genauso wie der Hover, zum Beispiel von unten. Das Laufband läuft nur, solange der Eintrag aktiv ist, und stoppt beim Verlassen (WCAG 2.2.2). Die Link-Texte bleiben echte, lesbare Menüeinträge.
Code
<!DOCTYPE html>
<!--
Richtungsabhängiger Marquee-Hover im Menü
Fundus · Navigation · https://cecodesign.de/lab/fundus/navigation/richtungsabhaengiger-marquee-hover/
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>Richtungsabhängiger Marquee-Hover im Menü · 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: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
/* Möwenkrog, Küche & Bar in Lübeck: Rezept „gastro-warm-appetitlich“
Papier-Weiß, warme Tinte, Hausfarbe Dunkelgrün für Flächen, Senf als Akzent */
:root {
--papier: #f0ece0; --tinte: #353839; --leise: rgba(53, 56, 57, .72); --linie: rgba(53, 56, 57, .16);
--gruen: #293e3a; --senf: #eac16e; --rose: #e1a894; --salbei: #7f937a;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, CSS-Gegenstück zur GSAP-Kurve */
--rand: clamp(16px, 3.4vw, 48px);
--titel: min(clamp(2.5rem, 1rem + 5.8vw, 7.5rem), 11.6svh);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 0; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.rubrik { font-size: 12px; line-height: 1.2; font-weight: 560; letter-spacing: .15em; text-transform: uppercase; font-variation-settings: 'FLAR' 80; }
.buehne { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto auto; }
/* Kopf */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; padding: clamp(14px, 2.4vh, 26px) var(--rand); }
.marke { display: flex; align-items: baseline; gap: 10px; color: inherit; text-decoration: none; }
.marke b { font: 600 27px/1 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 1; letter-spacing: -.02em; }
.marke svg { width: 26px; height: 14px; align-self: center; color: var(--gruen); }
.marke small { color: var(--leise); white-space: nowrap; }
.status { display: flex; align-items: center; gap: 10px; color: var(--tinte); }
.status i { width: 8px; height: 8px; border-radius: 50%; background: var(--salbei); box-shadow: 0 0 0 4px rgba(127, 147, 122, .22); }
.status.zu i { background: var(--rose); box-shadow: 0 0 0 4px rgba(225, 168, 148, .3); }
.schliessen { justify-self: end; display: flex; align-items: center; gap: 12px; }
.schliessen span { position: relative; width: 22px; height: 22px; }
.schliessen span::before, .schliessen span::after { content: ''; position: absolute; left: 1px; right: 1px; top: 10px; height: 1.5px; background: currentColor; rotate: 45deg; }
.schliessen span::after { rotate: -45deg; }
/* Menü */
nav { display: grid; }
.menue { list-style: none; display: grid; grid-auto-rows: minmax(clamp(68px, 11.2svh, 132px), 1fr); border-bottom: 1px solid var(--linie); }
.punkt { position: relative; overflow: hidden; border-top: 1px solid var(--linie); }
.punkt > a {
height: 100%; display: grid; grid-template-columns: clamp(44px, 6vw, 92px) 1fr auto; align-items: center; gap: 16px;
padding: 0 var(--rand); color: inherit; text-decoration: none; outline: none;
}
.nr { color: var(--leise); }
.titel { font: 470 var(--titel)/1 'Fraunces', serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; letter-spacing: -.02em; padding-bottom: .06em; }
.info { font-size: 15px; color: var(--leise); text-align: right; }
.daumen { display: none; width: 46px; height: 46px; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--linie); }
/* Die verborgene Ebene: außen fährt sie von der Eintrittskante herein, innen gegenläufig */
.marquee { position: absolute; inset: -1px 0 0; z-index: 2; overflow: hidden; pointer-events: none; background: var(--gruen); color: var(--papier); transform: translateY(101%); }
.marquee__wrap { height: 100%; transform: translateY(-101%); }
.marquee__spur { height: 100%; display: flex; align-items: center; width: max-content; animation: lauf 15s linear infinite; animation-play-state: paused; }
.punkt.an .marquee__spur { animation-play-state: running; }
@keyframes lauf { to { transform: translate3d(-50%, 0, 0); } }
.marquee__teil { display: flex; align-items: center; flex: none; font: italic 380 calc(var(--titel) * .74)/1 'Fraunces', serif; letter-spacing: -.01em; white-space: nowrap; }
.marquee__teil > span { padding: 0 .42em; }
.teller { flex: none; width: 1.08em; height: 1.08em; display: block; }
.raute { display: none; flex: none; width: .22em; height: .22em; background: var(--senf); rotate: 45deg; margin: 0 .2em; }
/* Ruhige Fassung (Variante „Nur Fläche“ und reduzierte Bewegung): derselbe Eintrag in Umkehrfarbe */
.marquee__still { display: none; height: 100%; grid-template-columns: clamp(44px, 6vw, 92px) 1fr auto; align-items: center; gap: 16px; padding: 0 var(--rand); }
.marquee__still .nr, .marquee__still .info { color: inherit; opacity: .72; }
.marquee__still .pfeil { width: .5em; height: .5em; margin-left: .25em; vertical-align: .02em; }
.buehne[data-variante="text"] .teller { display: none; }
.buehne[data-variante="text"] .raute { display: block; }
.buehne[data-variante="flaeche"] .marquee { background: var(--senf); color: var(--tinte); }
.buehne[data-variante="flaeche"] .marquee__spur { display: none; }
.buehne[data-variante="flaeche"] .marquee__still { display: grid; }
/* Fuß: Öffnungszeiten, Adresse, Reservieren bleiben im Menü sichtbar */
.fuss { display: grid; grid-template-columns: 1.2fr 1fr auto; gap: 16px 32px; align-items: end; padding: clamp(14px, 2.4vh, 26px) var(--rand); font-size: 15px; line-height: 1.45; }
.fuss .rubrik { color: var(--leise); margin-bottom: 4px; }
.fuss a { color: inherit; }
.reservieren { display: inline-flex; align-items: center; gap: 10px; padding: 13px 20px 13px 22px; border-radius: 999px; background: var(--gruen); color: var(--papier) !important; text-decoration: none; font-weight: 560; transition: background-color .2s var(--kurve); }
.reservieren:hover { background: #1f302d; }
.reservieren svg { width: 14px; height: 14px; }
/* Werte und Varianten der Demo: dezent, Geist Mono */
.regie { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px 24px; padding: 12px var(--rand) 16px; border-top: 1px solid var(--linie); }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.varianten { display: flex; gap: 4px; }
.varianten button { padding: 7px 10px; border-radius: 999px; border: 1px solid transparent; }
.varianten button[aria-pressed="true"] { border-color: var(--linie); color: var(--tinte); background: rgba(255, 255, 255, .45); }
.varianten button:focus-visible, .schliessen:focus-visible, .marke:focus-visible, .reservieren:focus-visible { outline: 2px solid var(--gruen); outline-offset: 3px; }
.werte b { font-weight: 500; color: var(--tinte); }
.werte [data-richtung] { display: inline-block; min-width: 9ch; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 22px; height: 22px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.vorschau body { overflow: hidden; }
.vorschau .regie { display: none; }
.vorschau .buehne { height: 100svh; grid-template-rows: auto 1fr auto; }
.vorschau .menue { grid-auto-rows: 1fr; }
/* Reduzierte Bewegung: kein Laufband, kein Hereinfahren; die Farbumkehr blendet kurz ein */
.reduziert .marquee, .reduziert .marquee__wrap { transform: none !important; }
.reduziert .marquee { opacity: 0; transition: opacity .15s linear; }
.reduziert .punkt.an .marquee { opacity: 1; }
.reduziert .marquee__spur { display: none; }
.reduziert .marquee__still { display: grid; }
/* Touch: kein Hover; Tippen zeigt die Ebene kurz von unten */
@media (hover: none) {
.punkt > a { grid-template-columns: 40px 1fr auto auto; }
.daumen { display: block; }
}
@media (max-width: 760px) {
.kopf { grid-template-columns: 1fr auto; }
.kopf .status { display: none; }
.marke small { display: none; }
.punkt > a, .marquee__still { grid-template-columns: 34px 1fr auto; gap: 10px; }
.info { font-size: 13px; max-width: 9em; }
.fuss { grid-template-columns: 1.25fr 1fr; font-size: 14px; gap: 14px 16px; }
.fuss .aktion { grid-column: 1 / -1; }
.reservieren { width: 100%; justify-content: center; }
.fuss .status-mobil { display: flex; }
}
.status-mobil { display: none; grid-column: 1 / -1; }
@media (max-width: 420px) { .info { display: none; } .punkt > a { grid-template-columns: 34px 1fr auto; } .daumen { display: block; } .marquee__still { grid-template-columns: 34px 1fr; } }
/* Zugang: 320 px ohne Querscrollen */
@media (max-width: 360px) { :root { --titel: 2.05rem; } .marke b { font-size: 22px; } .kopf { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; } .punkt > a { grid-template-columns: 34px minmax(0, 1fr) auto; } .werte { overflow-wrap: anywhere; } .fuss { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-fisch" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#f0ece0"/><circle cx="50" cy="50" r="39" fill="none" stroke="#353839" stroke-opacity=".12"/><path d="M22 52c10-14 30-19 45-9l13-10-4 17 4 17-13-10c-15 10-35 5-45-5z" fill="#e1a894"/><path d="M38 44c3 5 3 11 0 16M47 42c3 6 3 14 0 20" stroke="#353839" stroke-opacity=".28" stroke-width="2" fill="none" stroke-linecap="round"/><circle cx="29" cy="49" r="2.6" fill="#353839"/><path d="M60 70a12 12 0 0 1 22 2z" fill="#eac16e"/></symbol>
<symbol id="i-krabben" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#eac16e"/><path d="M20 58c0-13 9-21 30-21s30 8 30 21v6H20z" fill="#c98b4a"/><path d="M24 58c0-10 8-17 26-17s26 7 26 17v4H24z" fill="#f0ece0"/><path d="M30 55a7 7 0 1 1 9 5M45 53a7 7 0 1 1 9 5M60 55a7 7 0 1 1 9 5" stroke="#e07c66" stroke-width="5" fill="none" stroke-linecap="round"/><path d="M36 44l3 4M58 44l-3 4" stroke="#7f937a" stroke-width="3" stroke-linecap="round"/></symbol>
<symbol id="i-ei" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#f0ece0"/><circle cx="50" cy="50" r="39" fill="none" stroke="#353839" stroke-opacity=".12"/><path d="M27 60c0-12 9-20 23-20s23 8 23 20c0 6-10 9-23 9s-23-3-23-9z" fill="#b8615a"/><path d="M33 47c5-12 25-14 32-4 6 8-4 13-14 12-9 0-21-1-18-8z" fill="#fff"/><circle cx="50" cy="47" r="6.5" fill="#eac16e"/><circle cx="70" cy="34" r="6" fill="#8e2f4a"/></symbol>
<symbol id="i-schale" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#7f937a"/><path d="M22 50h56c0 16-12 26-28 26S22 66 22 50z" fill="#f0ece0"/><path d="M26 50h48" stroke="#353839" stroke-opacity=".2" stroke-width="2"/><path d="M40 40c-4-5 4-8 0-14M52 40c-4-5 4-8 0-14M64 40c-4-5 4-8 0-14" stroke="#f0ece0" stroke-width="3" fill="none" stroke-linecap="round"/></symbol>
<symbol id="i-brot" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#e1a894"/><path d="M22 60c0-16 12-28 28-28s28 12 28 28c0 6-12 10-28 10S22 66 22 60z" fill="#c98b4a"/><path d="M36 44l8 10M48 40l8 10M60 44l6 8" stroke="#f0ece0" stroke-width="3" stroke-linecap="round"/></symbol>
<symbol id="i-glas" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#f0ece0"/><path d="M35 24h30c1 18-4 30-15 30S34 42 35 24z" fill="#e8913c"/><path d="M35 24h30" stroke="#353839" stroke-width="2.5"/><path d="M36 30h28" stroke="#fff" stroke-opacity=".5" stroke-width="2"/><path d="M50 54v18M40 76h20" stroke="#353839" stroke-width="3" stroke-linecap="round"/><circle cx="62" cy="22" r="7" fill="#eac16e"/></symbol>
<symbol id="i-flasche" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#eac16e"/><path d="M45 18h10v14c7 4 9 9 9 16v30a4 4 0 0 1-4 4H40a4 4 0 0 1-4-4V48c0-7 2-12 9-16z" fill="#293e3a"/><rect x="38" y="54" width="24" height="16" rx="2" fill="#f0ece0"/><path d="M43 62h14" stroke="#e1a894" stroke-width="3"/></symbol>
<symbol id="i-zitrone" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#f0ece0"/><circle cx="50" cy="50" r="27" fill="#eac16e"/><circle cx="50" cy="50" r="21" fill="#f6dc95"/><path d="M50 29v42M29 50h42M35 35l30 30M65 35 35 65" stroke="#eac16e" stroke-width="2.5"/></symbol>
<symbol id="i-kerze" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#e1a894"/><rect x="43" y="42" width="14" height="32" rx="2" fill="#f0ece0"/><path d="M50 22c6 7 7 12 0 17-7-5-6-10 0-17z" fill="#eac16e"/><path d="M50 38v4" stroke="#353839" stroke-width="2"/><path d="M30 76h40" stroke="#293e3a" stroke-width="4" stroke-linecap="round"/></symbol>
<symbol id="i-moewe" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#7f937a"/><path d="M18 48c10-10 20-10 32 2 12-12 22-12 32-2-10-3-20 0-32 12-12-12-22-15-32-12z" fill="#f0ece0"/><path d="M48 56l2 4 2-4" fill="#eac16e"/><path d="M14 72c12-5 24 5 36 0s24-5 36 0" stroke="#f0ece0" stroke-opacity=".5" stroke-width="3" fill="none"/></symbol>
<symbol id="i-gutschein" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="#f0ece0"/><path d="M20 36h60v8a6 6 0 0 0 0 12v8H20v-8a6 6 0 0 0 0-12z" fill="#e1a894"/><path d="M60 38v24" stroke="#f0ece0" stroke-width="2" stroke-dasharray="3 3"/><path d="M30 46h20M30 54h14" stroke="#293e3a" stroke-width="3" stroke-linecap="round"/></symbol>
</defs>
</svg>
<div class="buehne" data-buehne data-variante="bild">
<header class="kopf">
<a class="marke" href="#start"><svg viewBox="0 0 26 14" aria-hidden="true"><path d="M1 9c4-6 8-6 12 0 4-6 8-6 12 0" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg><b>Möwenkrog</b><small class="rubrik">Küche & Bar</small></a>
<p class="status rubrik" data-status-box><i></i><span data-status>Heute geöffnet</span></p>
<button class="schliessen rubrik" type="button" aria-label="Menü schließen">Schließen<span></span></button>
</header>
<nav aria-label="Hauptmenü">
<ul class="menue" data-menue>
<li class="punkt">
<a href="#karte"><span class="nr rubrik">01</span><span class="titel">Speisekarte</span><span class="info">Mittag & Abend</span><svg class="daumen" aria-hidden="true"><use href="#i-fisch"/></svg></a>
<div class="marquee" aria-hidden="true"><div class="marquee__wrap">
<div class="marquee__spur"><div class="marquee__teil"><span>Scholle mit Speckstippe</span><svg class="teller"><use href="#i-fisch"/></svg><i class="raute"></i><span>Krabbenbrot vom Holzofen</span><svg class="teller"><use href="#i-krabben"/></svg><i class="raute"></i><span>Labskaus mit Spiegelei</span><svg class="teller"><use href="#i-ei"/></svg><i class="raute"></i></div></div>
<div class="marquee__still"><span class="nr rubrik">01</span><span class="titel">Speisekarte<svg class="pfeil" viewBox="0 0 10 10"><path d="M1 9 9 1M3 1h6v6" fill="none" stroke="currentColor" stroke-width="1.2"/></svg></span><span class="info">Mittag & Abend</span></div>
</div></div>
</li>
<li class="punkt">
<a href="#mittag"><span class="nr rubrik">02</span><span class="titel">Mittagstisch</span><span class="info">Di bis Fr, 12 bis 15 Uhr</span><svg class="daumen" aria-hidden="true"><use href="#i-schale"/></svg></a>
<div class="marquee" aria-hidden="true"><div class="marquee__wrap">
<div class="marquee__spur"><div class="marquee__teil"><span>Tagesgericht 13,50 €</span><svg class="teller"><use href="#i-schale"/></svg><i class="raute"></i><span>Suppe mit Brot vom Bäcker nebenan</span><svg class="teller"><use href="#i-brot"/></svg><i class="raute"></i><span>In zwanzig Minuten am Tisch</span><svg class="teller"><use href="#i-zitrone"/></svg><i class="raute"></i></div></div>
<div class="marquee__still"><span class="nr rubrik">02</span><span class="titel">Mittagstisch<svg class="pfeil" viewBox="0 0 10 10"><path d="M1 9 9 1M3 1h6v6" fill="none" stroke="currentColor" stroke-width="1.2"/></svg></span><span class="info">Di bis Fr, 12 bis 15 Uhr</span></div>
</div></div>
</li>
<li class="punkt">
<a href="#bar"><span class="nr rubrik">03</span><span class="titel">Bar & Weine</span><span class="info">ab 18 Uhr</span><svg class="daumen" aria-hidden="true"><use href="#i-glas"/></svg></a>
<div class="marquee" aria-hidden="true"><div class="marquee__wrap">
<div class="marquee__spur"><div class="marquee__teil"><span>Sanddorn-Spritz</span><svg class="teller"><use href="#i-glas"/></svg><i class="raute"></i><span>Naturweine von Mosel und Saale</span><svg class="teller"><use href="#i-flasche"/></svg><i class="raute"></i><span>Limonade aus eigener Herstellung</span><svg class="teller"><use href="#i-zitrone"/></svg><i class="raute"></i></div></div>
<div class="marquee__still"><span class="nr rubrik">03</span><span class="titel">Bar & Weine<svg class="pfeil" viewBox="0 0 10 10"><path d="M1 9 9 1M3 1h6v6" fill="none" stroke="currentColor" stroke-width="1.2"/></svg></span><span class="info">ab 18 Uhr</span></div>
</div></div>
</li>
<li class="punkt">
<a href="#reservieren"><span class="nr rubrik">04</span><span class="titel">Reservieren</span><span class="info">Tische & Gruppen</span><svg class="daumen" aria-hidden="true"><use href="#i-kerze"/></svg></a>
<div class="marquee" aria-hidden="true"><div class="marquee__wrap">
<div class="marquee__spur"><div class="marquee__teil"><span>Tische für zwei bis zwölf</span><svg class="teller"><use href="#i-kerze"/></svg><i class="raute"></i><span>Fensterplätze zur Engelsgrube</span><svg class="teller"><use href="#i-moewe"/></svg><i class="raute"></i><span>Gruppen ab acht mit eigenem Menü</span><svg class="teller"><use href="#i-glas"/></svg><i class="raute"></i></div></div>
<div class="marquee__still"><span class="nr rubrik">04</span><span class="titel">Reservieren<svg class="pfeil" viewBox="0 0 10 10"><path d="M1 9 9 1M3 1h6v6" fill="none" stroke="currentColor" stroke-width="1.2"/></svg></span><span class="info">Tische & Gruppen</span></div>
</div></div>
</li>
<li class="punkt">
<a href="#gutscheine"><span class="nr rubrik">05</span><span class="titel">Gutscheine</span><span class="info">ab 25 €</span><svg class="daumen" aria-hidden="true"><use href="#i-gutschein"/></svg></a>
<div class="marquee" aria-hidden="true"><div class="marquee__wrap">
<div class="marquee__spur"><div class="marquee__teil"><span>Ein Abend in der Altstadt zum Verschenken</span><svg class="teller"><use href="#i-gutschein"/></svg><i class="raute"></i><span>Als Karte oder per E-Mail</span><svg class="teller"><use href="#i-moewe"/></svg><i class="raute"></i></div></div>
<div class="marquee__still"><span class="nr rubrik">05</span><span class="titel">Gutscheine<svg class="pfeil" viewBox="0 0 10 10"><path d="M1 9 9 1M3 1h6v6" fill="none" stroke="currentColor" stroke-width="1.2"/></svg></span><span class="info">ab 25 €</span></div>
</div></div>
</li>
</ul>
</nav>
<footer class="fuss">
<p class="status-mobil status rubrik" data-status-box><i></i><span data-status>Heute geöffnet</span></p>
<div><p class="rubrik">Öffnungszeiten</p><p>Di–Sa 12–15 · 18–23 Uhr<br>So 12–21 Uhr · Mo Ruhetag</p></div>
<div><p class="rubrik">Adresse</p><p>Engelsgrube 31<br>23552 Lübeck</p></div>
<div class="aktion"><a class="reservieren" href="#reservieren">Tisch reservieren<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7h11M7.5 2.5 12 7l-4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></a></div>
</footer>
<div class="regie">
<div class="varianten" role="group" aria-label="Variante">
<button class="mono" type="button" data-variante="bild" aria-pressed="true">Text + Bild</button>
<button class="mono" type="button" data-variante="text" aria-pressed="false">Nur Text</button>
<button class="mono" type="button" data-variante="flaeche" aria-pressed="false">Nur Fläche</button>
</div>
<p class="mono werte">expo.out · <b>600 ms</b> · Ebenen <b>±101 % / ∓101 %</b> · <span data-laufband>Laufband <b>15 s</b> linear</span> · Eintritt <b data-richtung>–</b></p>
</div>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#353839" stroke="#f0ece0" stroke-width="1.2" 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;
const reduziert = () => html.classList.contains('reduziert');
const buehne = document.querySelector('[data-buehne]');
const punkte = [...document.querySelectorAll('.punkt')];
const zeiger = document.querySelector('[data-zeiger]');
const anzeigeRichtung = document.querySelector('[data-richtung]');
const DAUER = 0.6, EASE = 'expo.out', VERSATZ = 101; // Messwerte: 600 ms, expo, ±101 %
/* Öffnungsstatus aus den echten Zeiten (0 = Sonntag) */
const ZEITEN = { 0: [[12, 21]], 1: [], 2: [[12, 15], [18, 23]], 3: [[12, 15], [18, 23]], 4: [[12, 15], [18, 23]], 5: [[12, 15], [18, 23]], 6: [[12, 15], [18, 23]] };
const TAGE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
function status() {
const j = new Date(), t = j.getDay(), h = j.getHours() + j.getMinutes() / 60;
const offen = ZEITEN[t].find(([a, b]) => h >= a && h < b);
let text;
if (offen) text = `Jetzt geöffnet bis ${offen[1]} Uhr`;
else {
const heute = ZEITEN[t].find(([a]) => a > h);
if (heute) text = `Geschlossen · öffnet um ${heute[0]} Uhr`;
else { let n = 1; while (!ZEITEN[(t + n) % 7].length) n++; text = `Geschlossen · öffnet ${n === 1 ? 'morgen' : TAGE[(t + n) % 7]} um ${ZEITEN[(t + n) % 7][0][0]} Uhr`; }
}
document.querySelectorAll('[data-status]').forEach(s => s.textContent = text);
document.querySelectorAll('[data-status-box]').forEach(s => s.classList.toggle('zu', !offen));
}
status(); setInterval(status, 60000);
/* Laufband: Inhalt so oft wiederholen, dass eine Hälfte breiter ist als das Fenster; dann verdoppeln (Schleife -50 %) */
const vorlagen = punkte.map(p => p.querySelector('.marquee__teil').cloneNode(true));
function bauen() {
punkte.forEach((p, i) => {
const spur = p.querySelector('.marquee__spur');
spur.replaceChildren(vorlagen[i].cloneNode(true));
const w = spur.firstElementChild.getBoundingClientRect().width || 1;
const n = Math.max(1, Math.ceil(innerWidth / w));
for (let k = 1; k < n * 2; k++) spur.append(vorlagen[i].cloneNode(true));
});
}
/* Kante: welche Kante liegt dem Zeiger näher? getBoundingClientRect + clientY, nicht offsetTop */
function kante(el, y) {
const r = el.getBoundingClientRect();
return (y - r.top) < (r.bottom - y) ? 'oben' : 'unten';
}
function ebenen(p) { return [p.querySelector('.marquee'), p.querySelector('.marquee__wrap')]; }
function rein(p, k) {
const [aussen, innen] = ebenen(p);
p.classList.add('an');
anzeigeRichtung.textContent = k === 'oben' ? '↓ oben' : '↑ unten';
if (reduziert()) return;
const s = k === 'oben' ? -1 : 1;
gsap.timeline({ defaults: { duration: DAUER, ease: EASE, overwrite: true } })
.set(aussen, { yPercent: s * VERSATZ }, 0).set(innen, { yPercent: -s * VERSATZ }, 0)
.to([aussen, innen], { yPercent: 0 }, 0);
}
function raus(p, k) {
const [aussen, innen] = ebenen(p);
if (reduziert()) { p.classList.remove('an'); return; }
const s = k === 'oben' ? -1 : 1;
gsap.timeline({ defaults: { duration: DAUER, ease: EASE, overwrite: true }, onComplete: () => p.classList.remove('an') })
.to(aussen, { yPercent: s * VERSATZ }, 0).to(innen, { yPercent: -s * VERSATZ }, 0);
}
/* Echte Maus und Tastatur */
let laeuft = null; // Vorführung aktiv?
punkte.forEach(p => {
const a = p.querySelector('a');
p.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !laeuft) rein(p, kante(p, e.clientY)); });
p.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !laeuft) raus(p, kante(p, e.clientY)); });
a.addEventListener('focus', () => { if (a.matches(':focus-visible')) rein(p, 'unten'); });
a.addEventListener('blur', () => { if (p.classList.contains('an') && !p.matches(':hover')) raus(p, 'unten'); });
// Touch: kein Hover. Tippen zeigt die Ebene kurz von unten, dann fährt sie nach oben ab.
a.addEventListener('click', e => {
e.preventDefault();
if (!matchMedia('(hover: none)').matches) return;
punkte.forEach(q => { if (q !== p && q.classList.contains('an')) raus(q, 'oben'); });
rein(p, 'unten');
clearTimeout(p._zu); p._zu = setTimeout(() => raus(p, 'oben'), 1400);
});
});
document.querySelectorAll('a[href^="#"]:not(.punkt a)').forEach(a => a.addEventListener('click', e => e.preventDefault()));
/* Varianten */
document.querySelectorAll('.varianten button').forEach(b => b.addEventListener('click', () => {
document.querySelectorAll('.varianten button').forEach(x => x.setAttribute('aria-pressed', x === b));
buehne.dataset.variante = b.dataset.variante;
document.querySelector('[data-laufband]').style.display = b.dataset.variante === 'flaeche' ? 'none' : '';
bauen();
}));
/* Vorführung: ein gedachter Zeiger fährt das Menü ab, erst nach unten, dann nach oben */
const WEG = [ // [Ziel (Index, -1 = über, 5 = unter der Liste), Fahrt ms, Verweilen ms, x-Anteil]
[-1, 0, 300, .46], [1, 650, 1100, .36], [3, 750, 1200, .58], [5, 600, 600, .4], [4, 600, 1100, .62],
[2, 700, 1300, .5], [0, 700, 1000, .34], [-1, 600, 600, .46]
];
let aktiv = -1, t0 = 0, gesamt = WEG.reduce((s, w) => s + w[1] + w[2], 0);
function ort(ziel, xa) {
const a = punkte[0].getBoundingClientRect(), b = punkte[punkte.length - 1].getBoundingClientRect();
const x = a.left + a.width * xa;
if (ziel < 0) return [x, a.top - Math.min(40, a.height * .45)];
if (ziel >= punkte.length) return [x, b.bottom + Math.min(40, b.height * .45)];
const r = punkte[ziel].getBoundingClientRect();
return [x, r.top + r.height * .52];
}
const weich = q => 0.5 - 0.5 * Math.cos(q * Math.PI);
function schritt(t) {
let z = (t - t0) % gesamt, x, y;
for (let i = 0; i < WEG.length; i++) {
const [ziel, fahrt, bleib, xa] = WEG[i];
if (z < fahrt) {
const prev = WEG[(i - 1 + WEG.length) % WEG.length];
const [x0, y0] = ort(prev[0], prev[3]), [x1, y1] = ort(ziel, xa), q = weich(z / fahrt);
x = x0 + (x1 - x0) * q; y = y0 + (y1 - y0) * q; break;
}
z -= fahrt;
if (z < bleib) { [x, y] = ort(ziel, xa); x += Math.sin(z / 420) * 14; break; }
z -= bleib;
}
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
const i = punkte.findIndex(p => { const r = p.getBoundingClientRect(); return y >= r.top && y < r.bottom; });
if (i !== aktiv) {
if (aktiv >= 0) raus(punkte[aktiv], kante(punkte[aktiv], y));
if (i >= 0) rein(punkte[i], kante(punkte[i], y));
aktiv = i;
}
laeuft = requestAnimationFrame(schritt);
}
function zuruecksetzen() {
punkte.forEach(p => {
const [aussen, innen] = ebenen(p);
gsap.killTweensOf([aussen, innen]);
gsap.set(aussen, { y: 0, yPercent: VERSATZ }); gsap.set(innen, { y: 0, yPercent: -VERSATZ }); // y: 0 löst die CSS-Startlage ab
p.classList.remove('an');
});
aktiv = -1; anzeigeRichtung.textContent = '–';
}
function vorfuehren() {
pause(); zuruecksetzen();
html.classList.add('vorfuehrung');
t0 = performance.now();
laeuft = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(laeuft); laeuft = null; }
function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { t0 = performance.now(); laeuft = requestAnimationFrame(schritt); } }
function neustart() { pause(); html.classList.remove('vorfuehrung'); zuruecksetzen(); }
zuruecksetzen(); bauen();
(document.fonts ? document.fonts.ready : Promise.resolve()).then(bauen);
let rt; addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(bauen, 150); });
})();
</script>
</body>
</html>