Vollbild-Slideshow mit Masken-Schub und innerer Gegenbewegung
Der Rahmen schiebt sich aus dem Bild, sein Inhalt läuft um 30 % dagegen: Fünf Ansichten eines Hotels am See wechseln wie Filmschnitte.
Präsentations- und Kampagnenseiten, bei denen die Slideshow die ganze Seite ist: Rad, Wischen und Pfeile blättern, Richtung vertikal, horizontal oder Wipe.
Jeder Slide besteht aus einem Rahmen mit overflow hidden und einem inneren Bild. Beim Wechsel schiebt sich der aktuelle Rahmen um −100 % hinaus, sein Bild bewegt sich gleichzeitig um +30 % in die Gegenrichtung. Der neue Rahmen kommt von +100 % herein, sein Bild von −30 % auf 0. Alle vier Tweens starten zusammen mit derselben Kurve (power4.inOut, 1,5 s). Rad, Wischen und Pfeile werden gebündelt: Eine Geste bewegt höchstens einen Slide.
Die Gegenbewegung erzeugt Tiefe ohne 3D: Rahmen und Bild scheinen auf zwei Ebenen zu liegen. Die symmetrische inOut-Kurve gibt dem Wechsel Gewicht wie ein Filmschnitt.
Wann einsetzen
Hero-Slideshows, Projekt- und Fallstudien im Vollbild, Kampagnen, Präsentationsmodus. Gut bei wenigen, starken Bildern.
Wann nicht
Für automatische Werbe-Rotatoren mit Text, der gelesen werden muss. Bei kurzer Aufmerksamkeit: 1,5 s pro Wechsel ist zu langsam zum schnellen Durchklicken.
Varianten
- Vertikal (yPercent) oder horizontal (xPercent)
- Clip-Path-Wipe statt Schub: inset(0 0 100% 0) → inset(0), beide Bilder laufen in dieselbe Richtung
- Squeeze-Zoom: neuer Slide startet bei scale 0,1 und wächst in zwei Phasen, das innere Bild von scaleX 4 auf 1
- Textzeilen im Versatz zum Bild
- Reveal mit Unschärfe statt Deckkraft
- Foto-Hero auf einer normal scrollenden Seite: erstes Foto steht im HTML (LCP), Wechsel nur auf Klick, Wischen oder Pfeiltaste, kein Autoplay
- Ruhige Überblendung ohne Schub: 1000 ms, nur auf Klick, nativ per View Transition (nur das Bild trägt den Namen) oder gestapelt mit opacity
- Tageslauf: vier bis fünf Bilder nach Uhrzeit beschriftet, Fortschritt je Bild, Intervall 6 s, Pause-Knopf zuerst, auf dem Telefon kein Autostart
Umsetzung
Struktur: .slide (position absolute, overflow hidden) > .slide__bild > img (object-fit cover). Eine Timeline mit Label 'start', alle Tweens an dieser Position. Ein Flag verhindert überlappende Wechsel, der Index läuft modulo Anzahl. GSAP Observer für Rad, Touch und Zeiger (wheelSpeed −1, tolerance 10). Eine neue Geste beginnt erst nach 200 ms Ruhe. Fallstrick: Beim Clip-Path-Wipe beide Bilder in dieselbe Richtung bewegen, sonst springt das Bild. Eingebettet in eine längere Seite das Rad erst nach einem Klick abfangen und auf Touch nur waagerecht wischen lassen, sonst bleibt die Seite hängen. Den ersten Slide (Bild mit alt, Titel, Text) fest ins HTML schreiben, damit er ohne JavaScript steht und als LCP-Bild sofort lädt; JavaScript ergänzt die übrigen Slides erst nach dem Laden der Seite.
Robust umsetzen
Telefon. Wischen per Observer (type touch, pointer). Auf Hochkant-Geräten die vertikale Variante nehmen und die Dauer auf 1,0 bis 1,2 s kürzen.
Ohne Bewegung. Crossfade 300 bis 500 ms ohne Verschiebung.
Barrierefrei. Sichtbare Knöpfe für Vor und Zurück, Pfeiltasten, aria-live polite für die Bildunterschrift. Keine automatische Rotation ohne Pause. Das Rad nur innerhalb einer klar abgegrenzten Vollbild-Sektion abfangen.
Code
<!DOCTYPE html>
<!--
Vollbild-Slideshow mit Masken-Schub und innerer Gegenbewegung
Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/slideshow-innere-gegenparallaxe/
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>Vollbild-Slideshow mit innerer Gegenbewegung · 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: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
/* Variante „ueberblenden“ (Rezept weingut-hofladen-herkunft) */
@font-face { font-family: 'EB Garamond'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/eb-garamond:vf@latest/latin-wght-normal.woff') format('woff'); font-weight: 400 800; font-display: swap; }
@font-face { font-family: 'EB Garamond'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/eb-garamond:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 400 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
:root {
/* Rezept „Boutique-Hotel“: Sand und Creme, warmes Dunkel statt Schwarz, ein Hauston aus Holz */
--sand: #f3eee7; --creme: #fef2e3; --dunkel: #3e3a37; --linie: #d5d1c8; --meta: #646661; --haus: #6e5a47;
--ui: cubic-bezier(0.4, 0, 0.2, 1); /* Rezept: Buchungsleiste, Knöpfe 150–250 ms */
--rand: clamp(18px, 3.4vw, 48px);
--leiste: 84px;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--dunkel); color: var(--dunkel); font-family: 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow: hidden; }
button { font: inherit; color: inherit; }
.label { font-size: .75rem; font-weight: 500; font-stretch: 120%; letter-spacing: .06em; text-transform: uppercase; }
.mono { font: 500 10.5px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* Bühne: Vollbild über der Buchungsleiste */
.buehne { position: relative; height: calc(100svh - var(--leiste)); overflow: hidden; background: #2a2724; color: #fff; outline: none; touch-action: none; }
.buehne.eingebettet { touch-action: pan-y; }
.slide { position: absolute; inset: 0; overflow: hidden; will-change: transform; }
.slide__bild { position: absolute; inset: 0; will-change: transform; }
.slide__bild img { width: 100%; height: 100%; object-fit: cover; display: block; user-select: none; -webkit-user-drag: none; }
/* Lesbarkeit: warmer Verlauf oben und unten, steht still */
.buehne::after { content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none; background: linear-gradient(180deg, rgba(42,36,31,.5) 0, rgba(42,36,31,0) 22%), linear-gradient(0deg, rgba(42,36,31,.62) 0, rgba(42,36,31,0) 46%); }
/* Kopf */
.kopf { position: absolute; z-index: 4; inset: 0 0 auto; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; padding: 22px var(--rand); }
.nav { display: flex; gap: clamp(16px, 2vw, 30px); list-style: none; padding: 0; }
.nav a, .sprache a { color: rgba(255,255,255,.86); text-decoration: none; background: linear-gradient(currentColor 0 0) 0 100% / 0 1px no-repeat; padding-bottom: 3px; transition: background-size .25s var(--ui), color .25s var(--ui); }
.nav a:hover, .sprache a:hover { color: #fff; background-size: 100% 1px; }
.marke { text-align: center; color: #fff; text-decoration: none; display: grid; gap: 2px; }
.marke b { font: 400 30px/1 'Newsreader', Georgia, serif; font-style: italic; letter-spacing: -.01em; }
.marke span { font-size: 9.5px; font-weight: 500; font-stretch: 125%; letter-spacing: .14em; text-transform: uppercase; color: rgba(255,255,255,.75); }
.rechts { justify-self: end; display: flex; align-items: center; gap: 22px; }
.sprache { display: flex; gap: 8px; color: rgba(255,255,255,.5); }
.sprache a[aria-current] { color: #fff; }
.buchen { height: 40px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--creme); color: var(--dunkel); cursor: pointer; transition: background-color .2s var(--ui); }
.buchen:hover { background: #fff; }
.menue { display: none; width: 40px; height: 40px; border: 1px solid rgba(255,255,255,.4); border-radius: 50%; background: none; }
.menue i { display: block; width: 14px; height: 1px; margin: 4px auto; background: #fff; }
/* Bildunterschrift: Zeilen im Versatz */
.text { position: absolute; z-index: 3; left: var(--rand); right: var(--rand); bottom: clamp(26px, 5vh, 56px); display: grid; pointer-events: none; }
.zeilen { grid-area: 1 / 1; align-self: end; display: grid; gap: 14px; }
.zeilen .ort { overflow: hidden; }
.zeilen .ort span, .zeile span { display: block; will-change: transform; }
.zeilen .ort { color: rgba(255,255,255,.82); }
h2 { font: 300 clamp(2.5rem, 1.5rem + 4vw, 5.5rem)/1.05 'Newsreader', Georgia, serif; font-variation-settings: 'opsz' 72; letter-spacing: -.015em; }
h2 em { font-style: italic; font-weight: 300; }
.zeile { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
/* Zähler, Pfeile, Fortschritt */
.steuer { position: absolute; z-index: 4; right: var(--rand); bottom: clamp(30px, 5.4vh, 60px); display: grid; justify-items: end; gap: 16px; }
.reihe { display: flex; align-items: center; gap: 18px; }
.zaehler { font-variant-numeric: tabular-nums; display: flex; gap: 6px; align-items: baseline; color: rgba(255,255,255,.66); }
.zaehler .maske { display: inline-block; height: 1.2em; overflow: hidden; color: #fff; }
.zaehler .maske span { display: block; }
.pfeil { width: 48px; height: 48px; border-radius: 50%; border: 1px solid rgba(255,255,255,.45); background: rgba(62,58,55,.12); display: grid; place-items: center; cursor: pointer; transition: background-color .2s var(--ui), border-color .2s var(--ui), color .2s var(--ui); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
.pfeil:hover { background: var(--creme); color: var(--dunkel); border-color: var(--creme); }
.pfeil svg { width: 16px; height: 16px; }
.punkte { display: flex; gap: 6px; }
.punkte i { width: 26px; height: 2px; background: rgba(255,255,255,.3); position: relative; overflow: hidden; }
.punkte i::after { content: ''; position: absolute; inset: 0; background: #fff; scale: 0 1; transform-origin: 0 50%; transition: scale .25s var(--ui); }
.punkte i.an::after { scale: 1 1; }
.pfeil:focus-visible, .buchen:focus-visible, .pult button:focus-visible, .knopf:focus-visible { outline: 2px solid var(--creme); outline-offset: 3px; }
.buehne:focus-visible::before { content: ''; position: absolute; inset: 6px; z-index: 5; border: 1px solid rgba(255,255,255,.6); pointer-events: none; }
/* Pult: Varianten und Werte, liegt über der Bühne */
.pult { position: absolute; z-index: 4; top: 86px; right: var(--rand); display: grid; justify-items: end; gap: 8px; }
.modi { display: flex; gap: 4px; padding: 4px; border-radius: 999px; background: rgba(42,36,31,.42); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
.modi button { letter-spacing: inherit; text-transform: inherit; color: rgba(255,255,255,.72); background: none; border: 0; border-radius: 999px; height: 26px; padding: 0 11px; cursor: pointer; transition: background-color .2s var(--ui), color .2s var(--ui); }
.modi button:hover { color: #fff; }
.modi button[aria-pressed='true'] { background: var(--creme); color: var(--dunkel); }
.werte { color: rgba(255,255,255,.78); background: rgba(42,36,31,.42); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); padding: 6px 11px; border-radius: 999px; }
.werte b { color: #fff; font-weight: 500; }
/* Buchungsleiste direkt unter dem Bild */
.leiste { height: var(--leiste); background: var(--creme); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: stretch; padding: 0 var(--rand); }
.feld { display: grid; align-content: center; gap: 4px; padding: 0 22px; border-left: 1px solid var(--linie); text-align: left; background: none; border-top: 0; border-bottom: 0; border-right: 0; cursor: pointer; transition: background-color .2s var(--ui); }
.feld:first-child { border-left: 0; padding-left: 0; }
.feld:hover { background: rgba(213,209,200,.28); }
.feld .label { color: var(--meta); font-size: .6875rem; }
.feld b { font: 400 1.25rem/1.1 'Newsreader', Georgia, serif; font-variation-settings: 'opsz' 24; white-space: nowrap; }
.knopf { align-self: center; height: 52px; padding: 0 26px; border: 0; border-radius: 999px; background: var(--haus); color: var(--creme); cursor: pointer; display: flex; align-items: center; gap: 14px; transition: background-color .2s var(--ui); }
.knopf:hover { background: #5b4a3a; }
.knopf small { font-size: .6875rem; font-stretch: 100%; letter-spacing: 0; text-transform: none; opacity: .88; font-weight: 400; }
/* Gezeichneter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 22px; height: 22px; pointer-events: none; display: none; }
/* Ohne JavaScript: erster Slide steht als Bild mit Text im HTML, Blättern gibt es nicht */
html:not(.js) .steuer, html:not(.js) .pult { display: none; }
/* ---------- Variante „hero“ (Rezept boutique-hotel-gastgeber): Foto zuerst, Wechsel nur auf Klick, Wischen oder Pfeiltaste ----------
Die Seite scrollt normal weiter: kein Rad-Fang, Touch nur waagerecht, kein Autoplay (deshalb kein Pause-Knopf). */
html:not([data-v="hero"]) [data-welt="hero"], html[data-v="hero"] [data-welt="basis"] { display: none !important; }
html[data-v="hero"] { background: var(--sand); }
html[data-v="hero"] body { overflow: visible; }
html[data-v="hero"] .buehne { height: min(88svh, 900px); min-height: 520px; touch-action: pan-y; }
html[data-v="hero"] .buehne::after { background: linear-gradient(180deg, rgba(42,36,31,.46) 0, rgba(42,36,31,0) 24%), linear-gradient(0deg, rgba(42,36,31,.7) 0, rgba(42,36,31,.2) 42%, rgba(42,36,31,0) 64%); }
.slide picture { display: contents; }
.held { position: absolute; z-index: 3; left: var(--rand); right: var(--rand); bottom: calc(clamp(26px, 5vh, 56px) + 46px); pointer-events: none; color: #fff; }
.held .label { color: rgba(255,255,255,.86); margin-bottom: 16px; }
.held h1 { max-width: 13ch; font: 300 clamp(2.5rem, 1.5rem + 4vw, 5.5rem)/1.05 'Newsreader', Georgia, serif; font-variation-settings: 'opsz' 72; letter-spacing: -.015em; }
.held h1 em { font-style: italic; }
html[data-v="hero"] .text { right: calc(var(--rand) + 240px); }
html[data-v="hero"] .zeilen .ort { color: #fff; }
.seite { background: var(--sand); color: var(--dunkel); padding: clamp(56px, 9vw, 120px) var(--rand); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: clamp(24px, 5vw, 80px); }
.seite .label { color: var(--meta); }
.seite h2 { font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.75rem); font-variation-settings: 'opsz' 48; font-weight: 300; }
.seite p { font-size: 1.0625rem; line-height: 1.6; max-width: 58ch; }
.seite p + p { margin-top: 1em; }
.fakten { display: flex; flex-wrap: wrap; gap: 10px 32px; margin: 28px 0 0; padding: 0; list-style: none; }
.fakten b { display: block; font: 400 1.5rem/1.1 'Newsreader', Georgia, serif; }
.fakten span { font-size: .8125rem; color: var(--meta); }
html.vorschau[data-v="hero"] .buehne { height: 100svh; }
.vorschau .seite { display: none !important; }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau .leiste { display: none; }
.vorschau .buehne { height: 100svh; }
.vorschau .nav, .vorschau .rechts { visibility: hidden; }
@media (max-width: 900px) {
.nav, .sprache { display: none; }
.kopf { grid-template-columns: auto 1fr auto; }
.marke { text-align: left; order: -1; }
.rechts { gap: 10px; } .menue { display: block; }
.leiste { grid-template-columns: 1fr 1fr auto; }
.feld:nth-child(3) { display: none; }
}
@media (max-width: 640px) {
:root { --leiste: 76px; }
.buchen { display: none; }
.marke b { font-size: 26px; }
.pult { top: 78px; }
.text { bottom: 132px; }
h2 { font-size: clamp(2.2rem, 10vw, 2.9rem); }
.steuer { left: var(--rand); right: var(--rand); bottom: 30px; justify-items: stretch; }
.reihe { justify-content: flex-end; gap: 10px; }
.reihe .zaehler { margin-right: auto; }
.punkte { order: 2; }
.leiste { grid-template-columns: 1fr auto; gap: 12px; }
.feld:nth-child(2) { display: none; }
.feld b { font-size: 1.0625rem; }
.knopf { height: 46px; padding: 0 18px; }
.knopf small { display: none; }
.held { bottom: 150px; }
.held h1 { font-size: clamp(2.2rem, 10vw, 2.9rem); }
html[data-v="hero"] .text { right: var(--rand); bottom: 96px; }
.seite { grid-template-columns: minmax(0, 1fr); }
}
/* Touch: Wischen statt Rad, Pfeile bleiben */
@media (hover: none) {
.pfeil { width: 44px; height: 44px; }
}
/* ---------- Varianten „ueberblenden“ und „tageslauf“: eigene Bühnen außerhalb des Vollbilds ---------- */
html:not([data-v="ueberblenden"]) [data-welt="ueberblenden"], html:not([data-v="tageslauf"]) [data-welt="tageslauf"] { display: none !important; }
html[data-v="ueberblenden"] :is([data-welt="basis"], [data-welt="hero"], .buehne, .leiste),
html[data-v="tageslauf"] :is([data-welt="basis"], [data-welt="hero"], .buehne, .leiste) { display: none !important; }
html[data-v="ueberblenden"] body, html[data-v="tageslauf"] body { overflow: visible; }
.vorschau[data-v="ueberblenden"] body, .vorschau[data-v="tageslauf"] body { overflow: hidden; }
:is(.ub, .tl) .pult { top: 84px; }
:is(.ub, .tl) .modi, :is(.ub, .tl) .werte { background: rgba(42,36,31,.86); }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ueberblenden · Rezept weingut-hofladen-herkunft: Kalk, Löss, Schiefer; EB Garamond + DM Mono; Bilder token-luxus-weich */
html[data-v="ueberblenden"] { background: #F1ECE2; }
.ub {
--ub-grund: #F1ECE2; --ub-text: #22211F; --ub-flaeche: #E6DCCB; --ub-schiefer: #2F3236; --ub-leise: #5A564F; --ub-linie: #D8CFC0;
--ub-ease: cubic-bezier(0.37, 0, 0.63, 1); --ub-blende: 1000ms; --ub-ui: cubic-bezier(0.16, 1, 0.3, 1);
position: relative; min-height: 100svh; background: var(--ub-grund); color: var(--ub-text);
font-family: 'EB Garamond', Georgia, serif; padding: 0 var(--rand) clamp(40px, 6vw, 88px);
}
.ub-mono { font: 500 .75rem/1.45 'DM Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.ub-kopf { display: flex; align-items: center; justify-content: space-between; gap: 12px 28px; flex-wrap: wrap; padding: 20px 0 18px; border-bottom: 1px solid var(--ub-linie); }
.ub-marke { color: inherit; text-decoration: none; display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.ub-marke b { font: 400 1.75rem/1 'EB Garamond', Georgia, serif; letter-spacing: -.01em; }
.ub-marke b i { font-style: italic; }
.ub-marke span, .ub-status { color: var(--ub-leise); }
.ub-nav { display: flex; gap: 26px; list-style: none; padding: 0; }
.ub-nav a { color: inherit; text-decoration: none; background: linear-gradient(currentColor 0 0) 0 100% / 0 1px no-repeat; padding-bottom: 2px; transition: background-size 150ms var(--ub-ui); }
.ub-nav a:hover, .ub-nav a[aria-current] { background-size: 100% 1px; }
.ub-raster { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: clamp(16px, 2.2vw, 36px); padding-top: clamp(28px, 4vw, 52px); }
.ub-text { grid-column: 1 / span 4; display: flex; flex-direction: column; }
.ub-text h2 { font: 400 clamp(2rem, 1.5rem + 2vw, 3.25rem)/1.02 'EB Garamond', Georgia, serif; letter-spacing: -.015em; text-wrap: balance; }
.ub-text h2 em { font-style: italic; }
.ub-liste { list-style: none; padding: 0; margin: clamp(22px, 3vw, 40px) 0 0; border-top: 1px solid var(--ub-linie); counter-reset: lage; }
.ub-liste button {
width: 100%; display: grid; grid-template-columns: 2.4em minmax(0, 1fr); align-items: baseline; gap: 2px 0;
padding: 11px 0 12px; border: 0; border-bottom: 1px solid var(--ub-linie); background: none; color: var(--ub-leise);
text-align: left; cursor: pointer; transition: color 150ms var(--ub-ui);
}
.ub-liste button .ub-mono { grid-row: span 2; color: inherit; }
.ub-liste button b { font: italic 400 1.5rem/1.1 'EB Garamond', Georgia, serif; }
.ub-liste button small { grid-column: 2; font: 500 .6875rem/1.4 'DM Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.ub-liste button:hover { color: var(--ub-text); }
.ub-liste button[aria-current="true"] { color: var(--ub-text); box-shadow: inset 0 -2px 0 var(--ub-text); }
.ub-bild { grid-column: 5 / -1; }
.ub-region { position: relative; outline: none; }
.ub-region:focus-visible { outline: 2px solid var(--ub-text); outline-offset: 4px; }
.ub-rahmen { position: relative; aspect-ratio: 3 / 2; overflow: hidden; background: var(--ub-flaeche); view-transition-name: ub-bild; touch-action: pan-y; }
.ub-rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity var(--ub-blende) var(--ub-ease); user-select: none; -webkit-user-drag: none; }
.ub-rahmen img.an { opacity: 1; }
.ub-rahmen.vt img, .reduziert .ub-rahmen img { transition: none; }
.ub-unter { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 24px; align-items: center; padding-top: 14px; }
.ub-zeile { color: var(--ub-text); }
.ub-steuer { display: flex; align-items: center; gap: 8px; }
.ub-zahl { color: var(--ub-leise); margin-right: 10px; white-space: nowrap; }
.ub-zahl b { color: var(--ub-text); font-weight: 500; }
.ub-pfeil { width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid var(--ub-text); background: none; color: var(--ub-text); cursor: pointer; transition: background-color 150ms var(--ub-ui), color 150ms var(--ub-ui); }
.ub-pfeil:hover { background: var(--ub-text); color: var(--ub-grund); }
.ub-pfeil svg { width: 16px; height: 16px; }
.ub-absatz { grid-column: 1 / -1; font-size: 1.25rem; line-height: 1.55; max-width: 52ch; font-variant-numeric: oldstyle-nums; margin-top: 6px; }
.ub :is(button, a):focus-visible { outline: 2px solid var(--ub-text); outline-offset: 3px; }
/* Natives Überblenden per View Transition: nur das Bild blendet, die Seite wechselt sofort (Text steht) */
html[data-v="ueberblenden"]::view-transition-old(root), html[data-v="ueberblenden"]::view-transition-new(root) { animation: none; }
html[data-v="ueberblenden"]::view-transition-group(ub-bild) { animation-duration: 1000ms; animation-timing-function: cubic-bezier(0.37, 0, 0.63, 1); }
html[data-v="ueberblenden"]::view-transition-old(ub-bild), html[data-v="ueberblenden"]::view-transition-new(ub-bild) { animation-duration: 1000ms; animation-timing-function: cubic-bezier(0.37, 0, 0.63, 1); height: 100%; object-fit: cover; }
.vorschau .ub-nav, .vorschau .ub-status, .vorschau .ub-absatz { display: none; }
@media (max-width: 900px) {
.ub-nav { display: none; }
.ub-text { grid-column: 1 / span 5; }
.ub-bild { grid-column: 6 / -1; }
}
@media (max-width: 700px) {
.ub-raster { grid-template-columns: minmax(0, 1fr); row-gap: 22px; }
.ub-text, .ub-bild { grid-column: 1; }
.ub-text { display: contents; }
.ub-text h2 { order: 1; }
.ub-bild { order: 2; }
.ub-liste { order: 3; margin-top: 0; }
.ub-rahmen { aspect-ratio: 4 / 3; }
.ub-unter { grid-template-columns: minmax(0, 1fr); }
.ub-steuer { order: -1; }
.ub-zahl { margin-right: auto; }
.ub-absatz { font-size: 1.125rem; }
.ub-liste button b { font-size: 1.3rem; }
.ub .pult { top: 70px; }
}
/* tageslauf · Rezept boutique-hotel-gastgeber: Foto mit Rand auf Sand (inset-rahmen), Uhrzeiten als Fortschritt */
html[data-v="tageslauf"] { background: var(--sand); }
.tl { --tl-ease: cubic-bezier(0.37, 0, 0.63, 1); --tl-blende: 1000ms; position: relative; min-height: 100svh; background: var(--sand); color: var(--dunkel); padding: 0 var(--rand) clamp(36px, 5vw, 72px); }
.tl-kopf { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; padding: 18px 0; }
.tl-kopf .nav a { color: var(--dunkel); }
.tl-marke { text-align: center; color: var(--dunkel); text-decoration: none; display: grid; gap: 2px; }
.tl-marke b { font: 400 28px/1 'Newsreader', Georgia, serif; font-style: italic; letter-spacing: -.01em; }
.tl-marke span { font-size: 9.5px; font-weight: 500; font-stretch: 125%; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.tl-anfrage { justify-self: end; height: 40px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--haus); color: var(--creme); cursor: pointer; transition: background-color .2s var(--ui); }
.tl-anfrage:hover { background: #5b4a3a; }
.tl-anfrage { white-space: nowrap; }
.tl-buehne { position: relative; height: min(70svh, 760px); min-height: 380px; overflow: hidden; background: #2a2724; touch-action: pan-y; }
.tl-bild { position: absolute; inset: 0; margin: 0; opacity: 0; transition: opacity var(--tl-blende) var(--tl-ease); }
.tl-bild.an { opacity: 1; z-index: 1; }
.reduziert .tl-bild { transition: none; }
.tl-bild img { width: 100%; height: 100%; object-fit: cover; display: block; user-select: none; -webkit-user-drag: none; }
.tl-bild::after { content: ''; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(20deg, rgba(42,36,31,.72) 0, rgba(42,36,31,.28) 34%, rgba(42,36,31,0) 58%); }
.tl-bild figcaption { position: absolute; z-index: 1; left: clamp(20px, 3vw, 40px); bottom: clamp(18px, 3vw, 36px); right: clamp(20px, 3vw, 40px); color: #fff; display: grid; gap: 6px; }
.tl-bild time { font: 300 clamp(3.5rem, 2rem + 6vw, 7rem)/.9 'Newsreader', Georgia, serif; font-variation-settings: 'opsz' 72; font-variant-numeric: tabular-nums lining-nums; letter-spacing: -.02em; }
.tl-bild figcaption p { font: 300 clamp(1.25rem, 1rem + .8vw, 1.75rem)/1.2 'Newsreader', Georgia, serif; font-style: italic; max-width: 26ch; }
.tl-leiste { display: grid; grid-template-columns: auto repeat(5, minmax(0, 1fr)); gap: 0 clamp(10px, 1.8vw, 26px); align-items: start; padding-top: 16px; }
.tl-pause { width: 44px; height: 44px; margin-top: -2px; border-radius: 50%; border: 1px solid rgba(62,58,55,.5); background: none; display: grid; place-items: center; cursor: pointer; color: var(--dunkel); transition: background-color .2s var(--ui), color .2s var(--ui); }
.tl-pause:hover { background: var(--dunkel); color: var(--creme); }
.tl-pause svg { width: 14px; height: 14px; }
.tl-pause .ab, .tl-pause[aria-pressed="true"] .an { display: none; }
.tl-pause[aria-pressed="true"] .ab { display: block; }
.tl-seg { display: grid; gap: 7px; width: 100%; padding: 0 0 6px; border: 0; background: none; color: var(--meta); text-align: left; cursor: pointer; transition: color .2s var(--ui); }
.tl-seg:hover, .tl-seg[aria-current="true"] { color: var(--dunkel); }
.tl-spur { position: relative; height: 2px; background: rgba(62,58,55,.2); overflow: hidden; }
.tl-fuell { position: absolute; inset: 0; background: var(--dunkel); transform-origin: 0 50%; scale: 0 1; }
.tl-seg.vorbei .tl-fuell { scale: 1 1; }
.tl-uhr { font: 500 13px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .04em; font-variant-numeric: tabular-nums; }
.tl-was { font-size: .8125rem; line-height: 1.35; }
.tl :is(button, a):focus-visible { outline: 2px solid var(--dunkel); outline-offset: 3px; }
.vorschau .tl-kopf .nav, .vorschau .tl-anfrage { visibility: hidden; }
@media (max-width: 900px) {
.tl-kopf { grid-template-columns: auto 1fr; }
.tl-kopf .nav { display: none; }
.tl-marke { text-align: left; }
.tl-was { display: none; }
}
@media (max-width: 640px) {
.tl { padding-inline: 14px; }
.tl-buehne { height: 64svh; min-height: 360px; }
.tl-anfrage { height: 40px; padding: 0 16px; }
.tl-lang { display: none; }
.tl-leiste { gap: 0 8px; }
.tl-uhr { font-size: 11.5px; letter-spacing: 0; }
.tl .pult { top: 74px; }
}
@media (max-width: 360px) { .tl-leiste { gap: 0 5px; } .tl-uhr { font-size: 10.5px; } .tl-marke b { font-size: 24px; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } .tl-fuell { background: Highlight; } }
</style>
</head>
<body>
<section class="buehne" data-buehne tabindex="0" aria-roledescription="Slideshow" aria-label="Haus Uferwind in fünf Bildern, mit Pfeiltasten blättern">
<!-- Erster Slide steht fest im HTML (Bild, Titel, alt): ohne JavaScript sichtbar und das LCP-Bild. JS ergänzt die übrigen. -->
<div data-slides="basis" data-welt="basis">
<div class="slide" role="group" aria-roledescription="Slide" aria-label="1 von 5: Hafen am Morgen">
<div class="slide__bild"><img src="bilder/slideshow-innere-gegenparallaxe-hafen.svg" alt="Gezeichnete Hafeneinfahrt von Lindau im Morgenlicht mit Leuchtturm, Löwe und Alpen" width="1600" height="1000" fetchpriority="high" draggable="false"></div>
</div>
</div>
<div data-slides="hero" data-welt="hero">
<div class="slide" role="group" aria-roledescription="Slide" aria-label="1 von 5: Das Haus am Abend">
<!-- Auf der echten Seite ohne loading="lazy" und mit fetchpriority="high" (LCP). Hier lazy, weil die Demo zwei Welten enthält. -->
<div class="slide__bild"><picture><source media="(orientation: portrait)" srcset="bilder/hotel-haus-am-see-hoch.webp" width="900" height="1200"><img src="bilder/hotel-haus-am-see.webp" alt="Backsteinhaus mit erleuchteten Fenstern am Seeufer in der Abenddämmerung, daneben Bäume und ein kleines Bootshaus, alles spiegelt sich im stillen Wasser" width="1920" height="1200" loading="lazy" draggable="false"></picture></div>
</div>
</div>
<header class="kopf">
<ul class="nav label">
<li><a href="#zimmer">Zimmer</a></li>
<li><a href="#fruehstueck">Frühstück</a></li>
<li><a href="#see">Am See</a></li>
<li><a href="#anreise">Anreise</a></li>
</ul>
<a class="marke" href="#start" data-welt="basis"><b>Uferwind</b><span>Hotel am Seehafen · Lindau</span></a>
<a class="marke" href="#start" data-welt="hero"><b>Reetwiese</b><span>Gasthaus am Kellersee</span></a>
<div class="rechts">
<p class="sprache label"><a href="#de" aria-current="true">DE</a><a href="#en">EN</a></p>
<button class="buchen label" type="button">Buchen</button>
<button class="menue" type="button" aria-label="Menü öffnen"><i></i><i></i></button>
</div>
</header>
<div class="held" data-welt="hero">
<p class="label">Gasthaus und zwölf Zimmer seit 1912</p>
<h1>Ein Haus am See, <em>sonst nichts.</em></h1>
</div>
<div class="text" data-text="basis" data-welt="basis" aria-live="polite">
<div class="zeilen"><p class="ort label"><span>Hafeneinfahrt · 6:40 Uhr</span></p><h2><span class="zeile"><span>Morgens gehört der</span></span><span class="zeile"><span>Hafen <em>den Möwen.</em></span></span></h2></div>
</div>
<div class="text" data-text="hero" data-welt="hero" aria-live="polite">
<div class="zeilen"><p class="ort label"><span>Das Haus am Abend · Ostufer</span></p></div>
</div>
<div class="steuer">
<div class="reihe">
<p class="zaehler label" aria-hidden="true"><span class="maske"><span data-nr>01</span></span><span>/ 05</span></p>
<button class="pfeil" type="button" data-schritt="-1" aria-label="Vorheriges Bild"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
<button class="pfeil" type="button" data-schritt="1" aria-label="Nächstes Bild"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
</div>
<div class="punkte" aria-hidden="true" data-punkte></div>
</div>
<div class="pult">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-v-wahl="basis" aria-pressed="true">Vollbild</button>
<button type="button" data-v-wahl="hero" aria-pressed="false">Foto-Hero</button>
<button type="button" data-v-wahl="ueberblenden" aria-pressed="false">Überblenden</button>
<button type="button" data-v-wahl="tageslauf" aria-pressed="false">Tageslauf</button>
</div>
<div class="modi mono" role="group" aria-label="Richtung" data-welt="basis">
<button type="button" data-modus="vertikal" aria-pressed="true">Vertikal</button>
<button type="button" data-modus="horizontal" aria-pressed="false">Horizontal</button>
<button type="button" data-modus="wipe" aria-pressed="false">Wipe</button>
</div>
<p class="werte mono" data-werte></p>
</div>
</section>
<form class="leiste" onsubmit="return false" aria-label="Zimmer finden">
<button class="feld" type="button"><span class="label">Anreise</span><b>Fr, 14. Nov.</b></button>
<button class="feld" type="button"><span class="label">Abreise</span><b>So, 16. Nov.</b></button>
<button class="feld" type="button"><span class="label">Gäste</span><b>2 Erwachsene</b></button>
<button class="knopf label" type="submit">Verfügbarkeit prüfen <small><span data-welt="basis">ab 139 €</span><span data-welt="hero">ab 168 €</span> pro Nacht</small></button>
</form>
<main class="seite" data-welt="hero" aria-labelledby="haus-titel">
<div>
<p class="label">Das Haus</p>
<h2 id="haus-titel">Gasthaus seit 1912, <em>Hotel</em> seit 2019.</h2>
</div>
<div>
<p>Die Reetwiese liegt am Ostufer des Kellersees, zwischen Schilf und Buchenwald. Unten ist die Gaststube mit Kamin, oben sind zwölf Zimmer, die meisten mit Blick aufs Wasser.</p>
<p>Am Steg steht eine kleine Sauna, das Ruderboot dürfen Gäste kostenlos nehmen. Frühstück gibt es bis elf, Abendessen ab sechs.</p>
<ul class="fakten">
<li><b>12</b><span>Zimmer und Suiten</span></li>
<li><b>40 m</b><span>bis zum Steg</span></li>
<li><b>ab 168 €</b><span>pro Nacht mit Frühstück</span></li>
</ul>
</div>
</main>
<!-- Variante „ueberblenden“: Weingut, fünf Lagen, ruhige Überblendung nur auf Klick (View Transition, sonst gestapelte Bilder mit opacity) -->
<section class="ub" data-ub data-welt="ueberblenden" aria-labelledby="ub-titel">
<header class="ub-kopf">
<a class="ub-marke" href="#start"><b>Weingut <i>Rothlauf</i></b><span class="ub-mono">Winterhausen am Main</span></a>
<ul class="ub-nav ub-mono">
<li><a href="#weine">Weine</a></li>
<li><a href="#lagen" aria-current="page">Lagen</a></li>
<li><a href="#hofladen">Hofladen</a></li>
<li><a href="#weinprobe">Weinprobe</a></li>
</ul>
<p class="ub-status ub-mono">Hofladen Fr 14–18 · Sa 9–13 Uhr</p>
</header>
<div class="ub-raster">
<div class="ub-text">
<h2 id="ub-titel">Fünf Lagen, <em>ein</em> Hang über dem Main.</h2>
<ol class="ub-liste" aria-label="Lagen">
<li><button type="button" data-ub-lage="0" aria-current="true"><span class="ub-mono">01</span><b>Steinkammer</b><small>Muschelkalk · 41 % · Süd</small></button></li>
<li><button type="button" data-ub-lage="1" aria-current="false"><span class="ub-mono">02</span><b>Sommerleite</b><small>Kalk über Lehm · 24 % · Südwest</small></button></li>
<li><button type="button" data-ub-lage="2" aria-current="false"><span class="ub-mono">03</span><b>Hoher Rain</b><small>Löss · 6 % · West</small></button></li>
<li><button type="button" data-ub-lage="3" aria-current="false"><span class="ub-mono">04</span><b>Kalte Mulde</b><small>Keuper · 18 % · Nordost</small></button></li>
<li><button type="button" data-ub-lage="4" aria-current="false"><span class="ub-mono">05</span><b>Mainleite</b><small>Sand und Kies · 9 % · Süd</small></button></li>
</ol>
</div>
<div class="ub-bild">
<div class="ub-region" data-ub-region role="region" aria-roledescription="Bildwechsel" aria-label="Unsere Lagen, mit Pfeilknöpfen oder Pfeiltasten blättern" tabindex="0">
<div class="ub-rahmen" data-ub-rahmen>
<img src="bilder/lagen-steckbrief-karte-kreuzleite.webp" alt="Schrägluftbild eines Geländemodells: Steiler Rebhang mit Parzellen in verschiedenen Zeilenrichtungen über einem Hof mit roten Dächern" width="900" height="600" decoding="async" draggable="false" class="an">
<img src="bilder/lagen-steckbrief-karte-sonnwendberg.webp" alt="Schrägluftbild eines Geländemodells: Breiter Hang mit Rebparzellen in Gelb und Grün bis zum Horizont" width="900" height="600" decoding="async" draggable="false" aria-hidden="true" loading="lazy">
<img src="bilder/lagen-steckbrief-karte-grauer-buehl.webp" alt="Schrägluftbild eines Geländemodells: Weite, flache Kuppe mit Rebparzellen in Gelb- und Grüntönen unter hellem Himmel" width="900" height="600" decoding="async" draggable="false" aria-hidden="true" loading="lazy">
<img src="bilder/lagen-steckbrief-karte-zwetschgengrund.webp" alt="Schrägluftbild eines Geländemodells: Enger Taleinschnitt mit steilen grünen Hängen, Rebzeilen und einzelnen Obstbäumen" width="900" height="600" decoding="async" draggable="false" aria-hidden="true" loading="lazy">
<img src="bilder/lagen-steckbrief-karte-muehlwinkel.webp" alt="Schrägluftbild eines Geländemodells: Rebhang an einem Fluss mit einer Wiese und einem kleinen weißen Gebäude am Ufer" width="900" height="600" decoding="async" draggable="false" aria-hidden="true" loading="lazy">
</div>
<div class="ub-unter">
<p class="ub-zeile ub-mono" data-ub-zeile aria-live="polite">Abb. 1 · Geländemodell Steinkammer · Silvaner, gepflanzt 1987</p>
<div class="ub-steuer">
<p class="ub-zahl ub-mono" aria-hidden="true"><b data-ub-nr>01</b> / 05</p>
<button class="ub-pfeil" type="button" data-ub-schritt="-1" aria-label="Vorherige Lage"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
<button class="ub-pfeil" type="button" data-ub-schritt="1" aria-label="Nächste Lage"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg></button>
</div>
<p class="ub-absatz" data-ub-absatz>Der steilste Hang über dem Hof. Zwischen den Trockenmauern ist es abends noch warm. Hier steht unser ältester Silvaner.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Variante „tageslauf“: Gästehaus, fünf Bilder nach Uhrzeit, Fortschritt je Bild, Pause-Knopf zuerst, auf dem Telefon kein Autostart -->
<section class="tl" data-tl data-welt="tageslauf" aria-labelledby="tl-titel">
<header class="tl-kopf">
<ul class="nav label">
<li><a href="#zimmer">Zimmer</a></li>
<li><a href="#essen">Essen</a></li>
<li><a href="#see">Am See</a></li>
</ul>
<a class="tl-marke" href="#start"><b>Kranichwiese</b><span>Gästehaus am Fleesensee</span></a>
<button class="tl-anfrage label" type="button"><span class="tl-lang">Zimmer </span>anfragen</button>
</header>
<div class="tl-karussell" data-tl-karussell role="region" aria-roledescription="Karussell" aria-labelledby="tl-titel">
<h2 id="tl-titel" class="unsichtbar">Ein Tag in der Kranichwiese, fünf Bilder</h2>
<div class="tl-buehne" data-tl-buehne>
<figure class="tl-bild an" data-tl-bild role="group" aria-roledescription="Bild" aria-label="1 von 5: 08:30 Uhr, Frühstück">
<img src="bilder/hotel-zimmer-fenster.webp" alt="Breite Fensterbank mit gelbem Polster und einer Tasse, daneben eine Vase mit Zweigen, draußen der See im Morgenlicht" width="1200" height="800" decoding="async" draggable="false">
<figcaption><time datetime="08:30">08:30</time><p>Frühstück an der Fensterbank, der See noch ohne Wind.</p></figcaption>
</figure>
<figure class="tl-bild" data-tl-bild role="group" aria-roledescription="Bild" aria-label="2 von 5: 13:00 Uhr, Mittagsruhe" aria-hidden="true" inert>
<img src="bilder/hotel-zimmer.webp" alt="Helles Zimmer mit Doppelbett, Sessel und Fensterbank mit gelbem Polster, Sonnenlicht fällt auf den Holzboden" width="1200" height="800" decoding="async" draggable="false" loading="lazy">
<figcaption><time datetime="13:00">13:00</time><p>Mittagsruhe im Seezimmer. Die Sonne wandert über den Boden.</p></figcaption>
</figure>
<figure class="tl-bild" data-tl-bild role="group" aria-roledescription="Bild" aria-label="3 von 5: 17:45 Uhr, Blaue Stunde" aria-hidden="true" inert>
<picture><source media="(orientation: portrait)" srcset="bilder/atmosphaere-partikel-see-hoch.jpg" width="800" height="1000"><img src="bilder/atmosphaere-partikel-see.jpg" alt="Unscharfe Lichter einer Uferpromenade mit Turmsilhouette über dunklem, stillem Wasser in der blauen Stunde" width="1440" height="900" decoding="async" draggable="false" loading="lazy"></picture>
<figcaption><time datetime="17:45">17:45</time><p>Vom Steg aus gehen drüben in Malchow die Lichter an.</p></figcaption>
</figure>
<figure class="tl-bild" data-tl-bild role="group" aria-roledescription="Bild" aria-label="4 von 5: 19:30 Uhr, Abendessen" aria-hidden="true" inert>
<picture><source media="(orientation: portrait)" srcset="bilder/hotel-haus-am-see-hoch.webp" width="900" height="1200"><img src="bilder/hotel-haus-am-see.webp" alt="Backsteinhaus mit erleuchteten Fenstern am Seeufer in der Abenddämmerung, daneben Bäume und ein kleines Bootshaus, alles spiegelt sich im stillen Wasser" width="1920" height="1200" decoding="async" draggable="false" loading="lazy"></picture>
<figcaption><time datetime="19:30">19:30</time><p>Abendessen im Haus, die Fenster spiegeln sich im See.</p></figcaption>
</figure>
<figure class="tl-bild" data-tl-bild role="group" aria-roledescription="Bild" aria-label="5 von 5: 22:30 Uhr, Sauna" aria-hidden="true" inert>
<picture><source media="(orientation: portrait)" srcset="bilder/hotel-sauna-hoch.webp" width="800" height="1000"><img src="bilder/hotel-sauna.webp" alt="Kleine Holzsauna auf einem Steg am See in der Dämmerung, zwei warm erleuchtete Fenster spiegeln sich im Wasser" width="1200" height="800" decoding="async" draggable="false" loading="lazy"></picture>
<figcaption><time datetime="22:30">22:30</time><p>Die Sauna am Steg heizt bis Mitternacht.</p></figcaption>
</figure>
</div>
<div class="tl-leiste">
<button class="tl-pause" type="button" data-tl-pause aria-pressed="false" aria-label="Bewegung anhalten"><svg class="an" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 2v10M10 2v10" stroke="currentColor" stroke-width="1.6"/></svg><svg class="ab" viewBox="0 0 14 14" aria-hidden="true"><path d="M3.5 1.8v10.4L12 7z" fill="currentColor"/></svg></button>
<button class="tl-seg" type="button" data-tl-seg="0" aria-current="true" aria-label="08:30 Uhr, Frühstück"><span class="tl-spur"><span class="tl-fuell"></span></span><span class="tl-uhr">08:30</span><span class="tl-was">Frühstück</span></button>
<button class="tl-seg" type="button" data-tl-seg="1" aria-current="false" aria-label="13:00 Uhr, Mittagsruhe"><span class="tl-spur"><span class="tl-fuell"></span></span><span class="tl-uhr">13:00</span><span class="tl-was">Mittagsruhe</span></button>
<button class="tl-seg" type="button" data-tl-seg="2" aria-current="false" aria-label="17:45 Uhr, Blaue Stunde"><span class="tl-spur"><span class="tl-fuell"></span></span><span class="tl-uhr">17:45</span><span class="tl-was">Blaue Stunde</span></button>
<button class="tl-seg" type="button" data-tl-seg="3" aria-current="false" aria-label="19:30 Uhr, Abendessen"><span class="tl-spur"><span class="tl-fuell"></span></span><span class="tl-uhr">19:30</span><span class="tl-was">Abendessen</span></button>
<button class="tl-seg" type="button" data-tl-seg="4" aria-current="false" aria-label="22:30 Uhr, Sauna"><span class="tl-spur"><span class="tl-fuell"></span></span><span class="tl-uhr">22:30</span><span class="tl-was">Sauna</span></button>
</div>
<p class="unsichtbar" data-tl-live aria-live="off" aria-atomic="true"></p>
</div>
</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/Observer.min.js"></script>
<script>
(() => {
gsap.registerPlugin(Observer);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const buehne = $('[data-buehne]'), werteEl = $('[data-werte]');
const nrEl = $('[data-nr]'), punkteEl = $('[data-punkte]');
const schmal = matchMedia('(max-width: 640px), (hover: none)');
// Werte (Codrops Slideshow Animations, Demo 1; Wipe nach Green Stack; hero nach Rezept boutique-hotel-gastgeber, token-luxus-weich)
const W = {
schub: { dauer: 1.5, dauerMobil: 1.1, ease: 'power4.inOut', rahmen: 100, bild: 30 },
wipe: { dauer: 0.8, ease: 'power2.out', bild: 12 },
text: { versatz: 0.35, stagger: 0.08 },
hero: { dauer: 1.4, mobil: 0.8, ease: 'sine.inOut', rahmen: 100, bild: 30, textEin: 1.2, textAus: 0.5, textEase: 'sine.out', stagger: 0.12, wisch: 50 },
geste: 200, toleranz: 10, blende: 0.4
};
// Bilder: eigene Dateien in medien/ (basis: gezeichnete Ansichten von Lindau; hero: Renderings eines Gasthauses am See)
const M = 'bilder/';
const DATEN = {
basis: [
{ titel: 'Hafen am Morgen', ort: 'Hafeneinfahrt · 6:40 Uhr', zeilen: ['Morgens gehört der', 'Hafen <em>den Möwen.</em>'], bild: 'slideshow-innere-gegenparallaxe-hafen.svg', b: [1600, 1000], alt: 'Gezeichnete Hafeneinfahrt von Lindau im Morgenlicht mit Leuchtturm, Löwe und Alpen' },
{ titel: 'Seezimmer', ort: 'Seezimmer · 24 m² · ab 189 € pro Nacht', zeilen: ['Zwölf Zimmer,', '<em>alle</em> mit Blick zum See.'], bild: 'slideshow-innere-gegenparallaxe-seezimmer.svg', b: [1600, 1000], alt: 'Gezeichnetes Zimmer mit großem Sprossenfenster, Leinenvorhang und Bett' },
{ titel: 'Frühstück', ort: 'Frühstück auf der Terrasse · bis 11 Uhr', zeilen: ['Frühstück, solange', 'der Kaffee <em>warm</em> ist.'], bild: 'slideshow-innere-gegenparallaxe-fruehstueck.svg', b: [1600, 1000], alt: 'Gezeichneter Frühstückstisch mit Kaffee, Croissant und Brotkorb vor dem See' },
{ titel: 'Altstadt am Abend', ort: 'Insel-Altstadt · 18:30 Uhr', zeilen: ['Drei Minuten bis', 'zur <em>Maximilianstraße.</em>'], bild: 'slideshow-innere-gegenparallaxe-altstadt.svg', b: [1600, 1000], alt: 'Gezeichnete Giebelhäuser der Altstadt in der Abenddämmerung mit erleuchteten Fenstern' },
{ titel: 'Steg im Nebel', ort: 'November · Nebensaison ab 139 €', zeilen: ['Im November wird', 'es still. <em>Wir mögen das.</em>'], bild: 'slideshow-innere-gegenparallaxe-steg.svg', b: [1600, 1000], alt: 'Gezeichneter Holzsteg, der im Nebel auf dem See verschwindet' }
],
hero: [
{ titel: 'Das Haus am Abend', ort: 'Das Haus am Abend · Ostufer', bild: 'hotel-haus-am-see.webp', b: [1920, 1200], hoch: 'hotel-haus-am-see-hoch.webp', bh: [900, 1200], alt: 'Backsteinhaus mit erleuchteten Fenstern am Seeufer in der Abenddämmerung, daneben Bäume und ein kleines Bootshaus, alles spiegelt sich im stillen Wasser' },
{ titel: 'Seezimmer', ort: 'Seezimmer · 26 m² · ab 168 € mit Frühstück', bild: 'hotel-zimmer.webp', b: [1200, 800], alt: 'Helles Zimmer mit Doppelbett, Sessel und Fensterbank mit gelbem Polster, Sonnenlicht fällt auf den Holzboden' },
{ titel: 'Sauna am Steg', ort: 'Sauna am Steg · bis 22 Uhr', bild: 'hotel-sauna.webp', b: [1200, 800], hoch: 'hotel-sauna-hoch.webp', bh: [800, 1000], alt: 'Kleine Holzsauna auf einem Steg am See in der Dämmerung, zwei warm erleuchtete Fenster spiegeln sich im Wasser' },
{ titel: 'Kaminstube', ort: 'Kaminstube · Abendessen ab 18 Uhr', bild: 'atmosphaere-partikel-kamin.jpg', b: [1440, 900], hoch: 'atmosphaere-partikel-kamin-hoch.jpg', bh: [800, 1000], alt: 'Breites offenes Kaminfeuer in einer dunklen Wand, das Licht fällt warm auf die Steinbank davor' },
{ titel: 'Fensterbank', ort: 'Fensterbank im Seezimmer · Tee um vier', bild: 'hotel-zimmer-fenster.webp', b: [1200, 800], alt: 'Breite Fensterbank mit gelbem Polster und einer Tasse, daneben eine Vase mit Zweigen, draußen der See im Morgenlicht' }
]
};
const NAME = { basis: 'Haus Uferwind in fünf Bildern, mit Pfeiltasten blättern', hero: 'Gasthaus Reetwiese in fünf Bildern, mit Pfeilknöpfen, Pfeiltasten oder Wischen blättern' };
// Slides aufbauen: der erste steht im HTML, JS ergänzt die übrigen
const welten = {};
function bildHtml(s, eager) {
const img = `<img src="${M + s.bild}" alt="${s.alt}" width="${s.b[0]}" height="${s.b[1]}" decoding="async" draggable="false">`;
return `<div class="slide__bild">${s.hoch ? `<picture><source media="(orientation: portrait)" srcset="${M + s.hoch}" width="${s.bh[0]}" height="${s.bh[1]}">${img}</picture>` : img}</div>`;
}
function baue(w) {
if (welten[w]) return welten[w];
const daten = DATEN[w], N = daten.length, box = $(`[data-slides="${w}"]`), textBox = $(`[data-text="${w}"]`);
const st = { N, slides: [], innen: [], texte: [] };
const ergaenzen = () => daten.forEach((s, i) => {
let el = box.children[i];
if (!el) {
el = document.createElement('div'); el.className = 'slide';
el.setAttribute('role', 'group'); el.setAttribute('aria-roledescription', 'Slide'); el.setAttribute('aria-label', `${i + 1} von ${N}: ${s.titel}`);
el.innerHTML = bildHtml(s); box.append(el);
gsap.set(el, { autoAlpha: 0, zIndex: 0 });
}
let t = textBox.children[i];
if (!t) {
t = document.createElement('div'); t.className = 'zeilen';
t.innerHTML = `<p class="ort label"><span>${s.ort}</span></p>` + (s.zeilen ? `<h2>${s.zeilen.map(z => `<span class="zeile"><span>${z}</span></span>`).join('')}</h2>` : '');
textBox.append(t); gsap.set(t.querySelectorAll('.ort span, .zeile > span'), { yPercent: 105 }); t.style.visibility = 'hidden';
}
st.slides[i] = el; st.innen[i] = el.firstElementChild; st.texte[i] = [...t.querySelectorAll('.ort span, .zeile > span')];
});
// Erster Slide ist sofort da; die übrigen Bilder erst nach dem Laden der Seite, damit das erste (LCP) allein lädt
st.slides[0] = box.children[0]; st.innen[0] = box.children[0].firstElementChild; st.texte[0] = [...textBox.children[0].querySelectorAll('.ort span, .zeile > span')];
st.bereit = document.readyState === 'complete' ? Promise.resolve().then(ergaenzen) : new Promise(ok => addEventListener('load', () => { ergaenzen(); ok(); }, { once: true }));
welten[w] = st;
return st;
}
let welt = 'basis', S = null, aktiv = 0, animiert = false, modus = 'vertikal', tl = null;
function punkteBauen() {
punkteEl.replaceChildren(...Array.from({ length: S.N }, (_, i) => { const p = document.createElement('i'); if (i === aktiv) p.className = 'an'; return p; }));
}
function grundzustand() {
S.slides.forEach((s, i) => gsap.set(s, { xPercent: 0, yPercent: 0, clipPath: 'none', autoAlpha: i === aktiv ? 1 : 0, zIndex: i === aktiv ? 1 : 0 }));
S.innen.forEach(b => gsap.set(b, { xPercent: 0, yPercent: 0 }));
S.texte.forEach((z, i) => { gsap.set(z, { yPercent: i === aktiv ? 0 : 105, autoAlpha: 1 }); z[0].closest('.zeilen').style.visibility = i === aktiv ? 'visible' : 'hidden'; });
nrEl.textContent = String(aktiv + 1).padStart(2, '0');
$$('.punkte i').forEach((p, i) => p.classList.toggle('an', i === aktiv));
}
function zaehler(neu, dir, t) {
const w = String(neu + 1).padStart(2, '0');
if (reduziert()) { nrEl.textContent = w; return; }
t.to(nrEl, { yPercent: -110 * dir, duration: 0.35, ease: 'power2.in' }, 'start')
.add(() => { nrEl.textContent = w; }, 'start+=0.35')
.fromTo(nrEl, { yPercent: 110 * dir }, { yPercent: 0, duration: 0.45, ease: 'power2.out', immediateRender: false }, 'start+=0.35');
}
// Der Wechsel: vier Tweens gleichzeitig, eine Kurve
function gehe(dir, ziel) {
if (animiert || !S.slides[S.N - 1]) return false; // übrige Slides noch nicht ergänzt
const N = S.N, neu = ziel != null ? ziel : (aktiv + dir + N) % N;
if (neu === aktiv) return false;
animiert = true;
const alt = aktiv, a = S.slides[alt], b = S.slides[neu], ai = S.innen[alt], bi = S.innen[neu];
aktiv = neu;
$$('.punkte i').forEach((p, i) => p.classList.toggle('an', i === neu));
const tAlt = S.texte[alt], tNeu = S.texte[neu];
tNeu[0].closest('.zeilen').style.visibility = 'visible';
const fertig = () => { gsap.set(a, { autoAlpha: 0, zIndex: 0, xPercent: 0, yPercent: 0, clipPath: 'none' }); gsap.set(ai, { xPercent: 0, yPercent: 0 }); tAlt[0].closest('.zeilen').style.visibility = 'hidden'; animiert = false; };
gsap.set(b, { autoAlpha: 1, zIndex: 2 }); gsap.set(a, { zIndex: 1 });
if (reduziert()) { // Crossfade 400 ms ohne Weg
tl = gsap.timeline({ onComplete: fertig });
tl.addLabel('start').fromTo(b, { autoAlpha: 0 }, { autoAlpha: 1, duration: W.blende, ease: 'none' }, 'start')
.set(tNeu, { yPercent: 0 }, 'start').fromTo(tNeu, { autoAlpha: 0 }, { autoAlpha: 1, duration: W.blende, ease: 'none' }, 'start')
.to(tAlt, { autoAlpha: 0, duration: W.blende / 2, ease: 'none' }, 'start').set(tAlt, { autoAlpha: 1, yPercent: 105 });
zaehler(neu, dir, tl);
return true;
}
if (welt === 'hero') { // waagerecht, Kurve und Dauer aus token-luxus-weich
const f = schmal.matches ? W.hero.mobil : 1, d = W.hero.dauer * f;
tl = gsap.timeline({ defaults: { duration: d, ease: W.hero.ease }, onComplete: fertig });
tl.addLabel('start')
.to(a, { xPercent: -W.hero.rahmen * dir }, 'start')
.to(ai, { xPercent: W.hero.bild * dir }, 'start')
.fromTo(b, { xPercent: W.hero.rahmen * dir }, { xPercent: 0 }, 'start')
.fromTo(bi, { xPercent: -W.hero.bild * dir }, { xPercent: 0 }, 'start')
.to(tAlt, { yPercent: -105, duration: W.hero.textAus * f }, 'start')
.fromTo(tNeu, { yPercent: 105 }, { yPercent: 0, duration: W.hero.textEin * f, ease: W.hero.textEase, stagger: W.hero.stagger }, `start+=${W.text.versatz}`);
zaehler(neu, dir, tl);
return true;
}
if (modus === 'wipe') {
const d = W.wipe.dauer;
tl = gsap.timeline({ defaults: { duration: d, ease: W.wipe.ease }, onComplete: fertig });
tl.addLabel('start')
.fromTo(b, { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)' }, 'start')
.fromTo(bi, { yPercent: -W.wipe.bild }, { yPercent: 0 }, 'start')
.to(ai, { yPercent: W.wipe.bild / 2 }, 'start');
} else {
const achse = modus === 'horizontal' ? 'xPercent' : 'yPercent', d = schmal.matches ? W.schub.dauerMobil : W.schub.dauer;
tl = gsap.timeline({ defaults: { duration: d, ease: W.schub.ease }, onComplete: fertig });
tl.addLabel('start')
.to(a, { [achse]: -W.schub.rahmen * dir }, 'start')
.to(ai, { [achse]: W.schub.bild * dir }, 'start')
.fromTo(b, { [achse]: W.schub.rahmen * dir }, { [achse]: 0 }, 'start')
.fromTo(bi, { [achse]: -W.schub.bild * dir }, { [achse]: 0 }, 'start');
}
// Textzeilen im Versatz, gleiche Kurve
const td = modus === 'wipe' ? W.wipe.dauer : (schmal.matches ? W.schub.dauerMobil : W.schub.dauer) * 0.8, te = modus === 'wipe' ? W.wipe.ease : W.schub.ease;
tl.to(tAlt, { yPercent: -105 * dir, duration: td * 0.7, ease: te, stagger: W.text.stagger }, 'start')
.fromTo(tNeu, { yPercent: 105 * dir }, { yPercent: 0, duration: td, ease: te, stagger: W.text.stagger }, `start+=${(modus === 'wipe' ? 0.15 : W.text.versatz)}`);
zaehler(neu, dir, tl);
return true;
}
// Eingaben bündeln: eine Geste, höchstens ein Slide; neue Geste erst nach 200 ms Ruhe
let letzte = 0;
function geste(dir) {
const jetzt = performance.now(), neu = jetzt - letzte > W.geste;
letzte = jetzt;
if (!neu || animiert) return;
stoppVorfuehrung(); gehe(dir);
}
// basis (Vollbild): Rad und Wischen in alle Richtungen. Eingebettet (iframe auf einer längeren Seite): Rad erst nach Klick
// in die Bühne, Touch nur waagerecht, damit die umgebende Seite weiter scrollt
const eingebettet = (() => { try { return window.self !== window.top; } catch (e) { return true; } })();
if (eingebettet) buehne.classList.add('eingebettet');
const ignorieren = '.pfeil, .pult, .kopf a, .kopf button';
const zug = Observer.create({
target: buehne, type: 'touch,pointer', tolerance: W.toleranz, preventDefault: !eingebettet, ignore: ignorieren,
onUp: eingebettet ? undefined : () => geste(1), onDown: eingebettet ? undefined : () => geste(-1),
onLeft: () => geste(1), onRight: () => geste(-1)
});
const rad = Observer.create({
target: buehne, type: 'wheel', wheelSpeed: -1, tolerance: W.toleranz, preventDefault: true, ignore: ignorieren,
onUp: () => geste(1), onDown: () => geste(-1)
});
let radFrei = !eingebettet;
buehne.addEventListener('pointerdown', () => { if (eingebettet && welt === 'basis') rad.enable(); });
buehne.addEventListener('pointerleave', () => { if (eingebettet) rad.disable(); });
// hero: kein Rad, nur waagerechtes Wischen (Touch und Stift, ab 50 px und deutlich waagerecht); die Seite scrollt normal
let wisch = null;
buehne.addEventListener('pointerdown', e => { if (welt === 'hero' && e.pointerType !== 'mouse' && !e.target.closest(ignorieren)) wisch = { x: e.clientX, y: e.clientY }; });
buehne.addEventListener('pointerup', e => {
if (!wisch) return; const dx = e.clientX - wisch.x, dy = e.clientY - wisch.y; wisch = null;
if (Math.abs(dx) > W.hero.wisch && Math.abs(dx) > Math.abs(dy) * 1.5) geste(dx < 0 ? 1 : -1);
});
buehne.addEventListener('pointercancel', () => { wisch = null; });
$$('[data-schritt]').forEach(b => b.addEventListener('click', () => { stoppVorfuehrung(); gehe(+b.dataset.schritt); }));
addEventListener('keydown', e => {
const d = { ArrowDown: 1, ArrowRight: 1, PageDown: 1, ArrowUp: -1, ArrowLeft: -1, PageUp: -1 }[e.key];
if (!d || e.target.closest('.leiste, .pult') || NEU[welt]) return; // ueberblenden und tageslauf haben eigene Tasten
if (welt === 'hero' && (!buehne.contains(e.target) || e.key.startsWith('Page') || e.key === 'ArrowUp' || e.key === 'ArrowDown')) return; // hero: nur ← → mit Fokus in der Bühne
e.preventDefault(); stoppVorfuehrung(); gehe(d);
});
// Richtung (nur Vollbild)
function setzeModus(m) {
modus = m;
$$('[data-modus]').forEach(b => b.setAttribute('aria-pressed', b.dataset.modus === m));
zeigeWerte();
}
$$('[data-modus]').forEach(b => b.addEventListener('click', () => { stoppVorfuehrung(); setzeModus(b.dataset.modus); }));
function zeigeWerte() {
if (NEU[welt]) { werteEl.innerHTML = NEU[welt].werte(); return; }
if (reduziert()) { werteEl.innerHTML = `Crossfade <b>400 ms</b> · ohne Weg`; return; }
if (welt === 'hero') { werteEl.innerHTML = `Rahmen <b>100 %</b> · Bild <b>30 %</b> gegen · <b>sine.inOut ${Math.round(W.hero.dauer * (schmal.matches ? W.hero.mobil : 1) * 1000)} ms</b> · kein Autoplay`; return; }
const d = schmal.matches ? W.schub.dauerMobil : W.schub.dauer;
werteEl.innerHTML = modus === 'wipe'
? `clip-path inset <b>${W.wipe.dauer * 1000} ms</b> · <b>power2.out</b> · Bild ${W.wipe.bild} %`
: `Rahmen <b>${W.schub.rahmen} %</b> · Bild <b>${W.schub.bild} %</b> gegen · <b>power4.inOut ${d * 1000} ms</b>`;
}
schmal.addEventListener && schmal.addEventListener('change', zeigeWerte);
/* ---------- Variante „ueberblenden“: ruhige Überblendung 1000 ms nur auf Klick (Terreno: Swiper effect fade, speed 1000) ----------
Nativ per View Transition (nur das Bild trägt view-transition-name, die Seite wechselt sofort);
ohne View Transitions gestapelte Bilder mit opacity 1000 ms; bei reduzierter Bewegung ohne Übergang. Kein Autoplay. */
const UB = (() => {
const sek = $('[data-ub]'), region = $('[data-ub-region]'), rahmen = $('[data-ub-rahmen]'), bilder = [...rahmen.querySelectorAll('img')];
const lagen = $$('[data-ub-lage]'), nr = $('[data-ub-nr]'), zeile = $('[data-ub-zeile]'), absatz = $('[data-ub-absatz]');
const BLENDE = 1000, RUHE = 1800, N = bilder.length;
const TEXT = [
['Steinkammer', 'Silvaner, gepflanzt 1987', 'Der steilste Hang über dem Hof. Zwischen den Trockenmauern ist es abends noch warm. Hier steht unser ältester Silvaner.'],
['Sommerleite', 'Riesling, gepflanzt 2004', 'Ein breiter, gleichmäßiger Hang mit Sonne bis zum Abend. Der Riesling braucht hier Geduld, gelesen wird meist Mitte Oktober.'],
['Hoher Rain', 'Müller-Thurgau, gepflanzt 1996', 'Flach, offen, viel Wind. Der Löss hält Wasser, auch in trockenen Sommern. Daraus wird unser Literwein.'],
['Kalte Mulde', 'Spätburgunder, gepflanzt 2012', 'Ein enger Taleinschnitt, morgens lange im Schatten. Der Spätburgunder reift hier langsam und bleibt frisch.'],
['Mainleite', 'Bacchus, gepflanzt 2009', 'Direkt über dem Fluss. Der Main mildert den Frost im Frühjahr, der Sand macht leichte Weine.']
];
let i = 0, laeuft = false, vorf = false, uhr = null, lauf = 0;
const vt = () => typeof document.startViewTransition === 'function';
function setze(n) {
i = n;
bilder.forEach((b, k) => { b.classList.toggle('an', k === n); k === n ? b.removeAttribute('aria-hidden') : b.setAttribute('aria-hidden', 'true'); });
lagen.forEach((b, k) => b.setAttribute('aria-current', String(k === n)));
nr.textContent = String(n + 1).padStart(2, '0');
zeile.textContent = `Abb. ${n + 1} · Geländemodell ${TEXT[n][0]} · ${TEXT[n][1]}`;
absatz.textContent = TEXT[n][2];
}
async function gehe(ziel) {
const n = (ziel + N) % N;
if (n === i || laeuft) return;
laeuft = true;
const b = bilder[n]; b.loading = 'eager';
try { await b.decode(); } catch (e) { /* Bild noch nicht da: trotzdem wechseln */ }
if (reduziert()) { setze(n); laeuft = false; return; }
if (vt()) {
rahmen.classList.add('vt');
const t = document.startViewTransition(() => setze(n));
t.finished.finally(() => { rahmen.classList.remove('vt'); laeuft = false; });
} else { setze(n); setTimeout(() => { laeuft = false; }, BLENDE); }
}
const nutzer = f => (...a) => { stopp(); f(...a); };
$$('[data-ub-schritt]').forEach(b => b.addEventListener('click', nutzer(() => gehe(i + +b.dataset.ubSchritt))));
lagen.forEach((b, k) => b.addEventListener('click', nutzer(() => gehe(k))));
region.addEventListener('keydown', e => {
const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (!d || e.target.closest('button')) return;
e.preventDefault(); stopp(); gehe(i + d);
});
// Wischen nur Touch und Stift, deutlich waagerecht; die Seite scrollt senkrecht weiter
let w = null;
rahmen.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') w = { x: e.clientX, y: e.clientY }; });
rahmen.addEventListener('pointerup', e => { if (!w) return; const dx = e.clientX - w.x, dy = e.clientY - w.y; w = null; if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) { stopp(); gehe(i + (dx < 0 ? 1 : -1)); } });
rahmen.addEventListener('pointercancel', () => { w = null; });
function takt(id) { if (id !== lauf || !vorf) return; gehe(i + 1); uhr = setTimeout(() => takt(id), (reduziert() ? 0 : BLENDE) + RUHE); }
function stopp() { if (!vorf) return; vorf = false; vorfuehrt = false; lauf++; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
return {
sektion: sek,
an() { setze(0); },
aus() { stopp(); },
neustart() { stopp(); setze(0); },
vorfuehren() { vorf = true; const id = ++lauf; uhr = setTimeout(() => takt(id), 900); },
pause() { vorf = false; lauf++; clearTimeout(uhr); },
weiter() { if (html.classList.contains('vorfuehrung') && !vorf) { vorf = true; const id = ++lauf; uhr = setTimeout(() => takt(id), 600); } },
werte: () => reduziert() ? 'Wechsel <b>ohne Übergang</b> · nur auf Klick' : `${vt() ? 'View Transition' : 'opacity'} <b>1000 ms</b> · <b>cubic-bezier(0.37, 0, 0.63, 1)</b> · nur auf Klick`
};
})();
/* ---------- Variante „tageslauf“: fünf Bilder nach Uhrzeit, Fortschritt je Bild, Intervall 6 s ----------
Pause-Knopf zuerst, Stopp bei Fokus im Karussell, Halt bei Zeiger über dem Bild; Telefon und reduzierte Bewegung ohne Autostart. */
const TL = (() => {
const sek = $('[data-tl]'), kar = $('[data-tl-karussell]'), buehneT = $('[data-tl-buehne]'), bilder = $$('[data-tl-bild]'), segs = $$('[data-tl-seg]');
const fuell = segs.map(s => s.querySelector('.tl-fuell')), knopf = $('[data-tl-pause]'), live = $('[data-tl-live]');
const INTERVALL = 6000, INTERVALL_VORF = 4000, N = bilder.length;
const telefon = matchMedia('(hover: none), (max-width: 640px)');
let i = 0, anim = null, angehalten = true, zeiger = false, intervall = INTERVALL;
function halten() { return angehalten || zeiger; }
function setzeAngehalten(a) {
angehalten = a; knopf.setAttribute('aria-pressed', String(a));
live.setAttribute('aria-live', a ? 'polite' : 'off'); // während der Rotation keine Ansagen (APG)
if (!anim) { if (!a) fortschritt(); return; }
halten() ? anim.pause() : anim.play();
}
function zeige(n, ansage = true) {
i = (n + N) % N;
bilder.forEach((b, k) => { const an = k === i; b.classList.toggle('an', an); b.toggleAttribute('inert', !an); b.setAttribute('aria-hidden', String(!an)); });
segs.forEach((s, k) => { s.setAttribute('aria-current', String(k === i)); s.classList.toggle('vorbei', k < i); });
if (ansage) live.textContent = bilder[i].getAttribute('aria-label');
fortschritt();
}
function fortschritt() {
if (anim) { anim.onfinish = null; anim.cancel(); anim = null; }
fuell.forEach((f, k) => { f.style.scale = ''; });
if (angehalten) { fuell[i].style.scale = '1 1'; return; } // angehalten: aktives Segment steht voll
anim = fuell[i].animate([{ scale: '0 1' }, { scale: '1 1' }], { duration: intervall, easing: 'linear', fill: 'forwards' });
anim.onfinish = () => { const naechst = bilder[(i + 1) % N].querySelector('img'); naechst.loading = 'eager'; zeige(i + 1); };
if (halten()) anim.pause();
}
knopf.addEventListener('click', () => { if (angehalten && intervall === INTERVALL_VORF) intervall = INTERVALL; stoppVorf(); setzeAngehalten(!angehalten); });
segs.forEach((s, k) => s.addEventListener('click', () => { stoppVorf(); angehalten = true; setzeAngehalten(true); zeige(k); }));
// Fokus im Karussell (außer auf dem Pause-Knopf) stoppt die Rotation, ohne Neustart nach dem Verlassen (APG)
kar.addEventListener('focusin', e => { if (e.target !== knopf && !angehalten && !html.classList.contains('vorfuehrung')) setzeAngehalten(true); });
// Zeiger über dem Bild hält nur an, solange er dort ist
buehneT.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) { zeiger = true; if (anim) anim.pause(); } });
buehneT.addEventListener('pointerleave', e => { if (zeiger) { zeiger = false; if (anim && !angehalten) anim.play(); } });
// Wischen auf Touch: blättert und hält an
let w = null;
buehneT.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') w = { x: e.clientX, y: e.clientY }; });
buehneT.addEventListener('pointerup', e => { if (!w) return; const dx = e.clientX - w.x, dy = e.clientY - w.y; w = null; if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) { stoppVorf(); angehalten = true; setzeAngehalten(true); zeige(i + (dx < 0 ? 1 : -1)); } });
buehneT.addEventListener('pointercancel', () => { w = null; });
kar.addEventListener('keydown', e => {
const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (!d || !e.target.closest('[data-tl-seg]')) return;
e.preventDefault(); const z = (i + d + N) % N; stoppVorf(); angehalten = true; setzeAngehalten(true); zeige(z); segs[z].focus();
});
function stoppVorf() { if (html.classList.contains('vorfuehrung')) { html.classList.remove('vorfuehrung'); vorfuehrt = false; lauf++; } intervall = INTERVALL; }
function start() { intervall = INTERVALL; zeiger = false; angehalten = telefon.matches || reduziert(); knopf.setAttribute('aria-pressed', String(angehalten)); live.setAttribute('aria-live', angehalten ? 'polite' : 'off'); zeige(0, false); }
return {
sektion: sek,
an: start,
aus() { if (anim) { anim.onfinish = null; anim.cancel(); anim = null; } angehalten = true; },
neustart: start,
vorfuehren() { intervall = INTERVALL_VORF; zeiger = false; angehalten = false; knopf.setAttribute('aria-pressed', 'false'); live.setAttribute('aria-live', 'off'); zeige(0, false); },
pause() { setzeAngehalten(true); },
weiter() { if (html.classList.contains('vorfuehrung')) setzeAngehalten(false); },
werte: () => reduziert() ? 'Kein Autostart · Wechsel <b>ohne Übergang</b> · Intervall <b>6 s</b> nach Abspielen' : `Überblendung <b>1000 ms</b> · Intervall <b>6 s</b> · ${telefon.matches ? 'Telefon: <b>kein Autostart</b>' : 'Halt bei Zeiger und Fokus'}`
};
})();
const NEU = { ueberblenden: UB, tageslauf: TL };
// Varianten nach Regel 13: ?v=basis (Vollbild mit Rad) | hero (Foto-Hero, Wechsel nur auf Klick oder Wischen)
// | ueberblenden (Weingut, Überblendung nur auf Klick) | tageslauf (Gästehaus, Uhrzeiten mit Fortschritt und Pause)
const WELTEN = ['basis', 'hero', 'ueberblenden', 'tageslauf'];
const pult = $('.pult');
function setzeWelt(v, url = true) {
stoppVorfuehrung(); if (tl) tl.progress(1); animiert = false;
Object.values(NEU).forEach(c => c.aus());
welt = v; html.dataset.v = v;
$$('[data-v-wahl]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.vWahl === v)));
if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
if (NEU[v]) {
zug.disable(); rad.disable();
NEU[v].sektion.append(pult); NEU[v].an(); zeigeWerte();
if (url) scrollTo(0, 0);
return;
}
buehne.append(pult);
S = baue(v); aktiv = 0;
buehne.setAttribute('aria-label', NAME[v]);
if (v === 'hero') { zug.disable(); rad.disable(); } else { zug.enable(); radFrei ? rad.enable() : rad.disable(); }
punkteBauen(); grundzustand(); zeigeWerte();
S.bereit.then(() => { if (welt === v) grundzustand(); });
if (url) scrollTo(0, 0);
}
$$('[data-v-wahl]').forEach(b => b.addEventListener('click', () => setzeWelt(b.dataset.vWahl)));
// Vorführung: blättert selbst (Vollbild wechselt dabei die Richtung)
let lauf = 0, vorfuehrt = false, uhr = null;
const FOLGE = ['vertikal', 'vertikal', 'horizontal', 'horizontal', 'wipe', 'wipe'];
function takt(id, n) {
if (id !== lauf || !vorfuehrt) return;
if (!reduziert() && welt === 'basis') setzeModus(FOLGE[n % FOLGE.length]);
gehe(1);
const d = reduziert() ? W.blende : welt === 'hero' ? W.hero.dauer * (schmal.matches ? W.hero.mobil : 1) : (modus === 'wipe' ? W.wipe.dauer : (schmal.matches ? W.schub.dauerMobil : W.schub.dauer));
uhr = setTimeout(() => takt(id, n + 1), d * 1000 + 1500);
}
function stoppVorfuehrung() { if (!vorfuehrt) return; vorfuehrt = false; lauf++; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
function neustart() {
if (NEU[welt]) { stoppVorfuehrung(); NEU[welt].neustart(); return; }
stoppVorfuehrung(); if (tl) tl.progress(1); animiert = false;
aktiv = 0; setzeModus(schmal.matches && innerHeight > innerWidth ? 'vertikal' : modus); grundzustand();
}
function vorfuehren() {
if (NEU[welt]) { neustart(); vorfuehrt = true; html.classList.add('vorfuehrung'); NEU[welt].vorfuehren(); return; }
neustart(); setzeModus('vertikal'); vorfuehrt = true; html.classList.add('vorfuehrung');
const id = ++lauf; uhr = setTimeout(() => takt(id, 0), 900);
}
function pause() { if (NEU[welt]) { NEU[welt].pause(); return; } if (vorfuehrt) { vorfuehrt = false; lauf++; clearTimeout(uhr); } }
function weiter() { if (NEU[welt]) { NEU[welt].weiter(); return; } if (html.classList.contains('vorfuehrung') && !vorfuehrt) { vorfuehrt = true; const id = ++lauf; uhr = setTimeout(() => takt(id, 0), 600); } }
const ausUrl = new URLSearchParams(location.search).get('v');
setzeWelt(WELTEN.includes(ausUrl) ? ausUrl : WELTEN[0], false);
})();
</script>
</body>
</html>