Zum Inhalt
← Scroll & Section-Animationen

Ein Element wandert über mehrere Sections

Ein Zimmerschlüssel reist beim Scrollen von der Haustür ans Schlüsselbrett, auf den Nachttisch und in den Rückgabekasten. Ein Objekt erzählt die ganze Seite.

  1. Ein zentrales Objekt (Produkt, Logo, Foto) steht groß im Hero.
  2. Beim Scrollen wandert es an einen festen Platz in der nächsten Section, etwa in eine Rasterzelle oder neben einen Text. Dabei ändern sich Größe, Drehung und Radius.
  3. In weiteren Sections springt es zu neuen Wegpunkten.
  4. Die Wegpunkte sind leere Platzhalter im Layout. Flip berechnet die Bewegung dazwischen, ScrollTrigger koppelt sie an den Scroll.

Das Objekt ist ein roter Faden. Die Seite liest sich wie ein durchgehender Film statt wie eine Folge von Blöcken.

Wann einsetzen

Produkt-Landingpages, Portfolio-Intros und Geschichten mit einem Objekt als Hauptfigur.

Wann nicht

Wenn das Objekt in den Ziel-Layouts keinen klaren Platz hat. Bei responsiven Layouts mit stark wechselnden Positionen nur mit Testaufwand.

Varianten

  • Ein Objekt springt über Platzhalter von Wegpunkt zu Wegpunkt (Flip.fit)
  • Mehrere Objekte verteilen sich aus einem Stapel in ein Raster
  • Das Objekt verwandelt sich unterwegs, etwa vom Bild zur Karte
  • CSS-Annäherung mit Anchor Positioning und scroll-driven Animations (experimentell)

Umsetzung

Platzhalter .wp-1 bis .wp-n ins Layout setzen, leer und im selben Seitenverhältnis wie das Objekt. Das Objekt liegt absolut im gemeinsamen Container, damit es den Fluss nicht stört. Wegpunkte nacheinander vermessen: vars = Flip.fit(el, wp, { getVars: true }), dann gsap.set(el, vars) und den nächsten messen. Daraus eine Timeline bauen: gsap.timeline({ scrollTrigger: { trigger: '.reise', start: 'top top', end: 'bottom bottom', scrub: 1 } }) mit tl.to(el, { ...vars, ease: 'none' }) je Strecke. Drehungen der Platzhalter übernimmt Flip.fit mit. Bei Resize alles neu vermessen und die Timeline neu bauen (oder invalidateOnRefresh). Fantasy nutzt dasselbe Prinzip mit Flip.from(state, { absolute: true, simple: true, ease: 'none' }) in einer scrub-Timeline.

Robust umsetzen

Telefon. Weniger Wegpunkte oder kleinere Sprünge. Bei gestapelten Sections das Objekt notfalls pro Section neu zeigen.

Ohne Bewegung. Keine Wanderung. Das Objekt steht an jedem Wegpunkt als statisches Duplikat.

Barrierefrei. Das wandernde Objekt ist dekorativ und bekommt aria-hidden. Die Inhalte daneben bleiben normaler Text.

Code

<!DOCTYPE html>
<!--
  Ein Element wandert über mehrere Sections
  Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/flip-element-ueber-sections/
  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>Ein Element wandert über mehrere Sections · 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; }

/* Rezept „Boutique-Hotel: Gastgeber mit Haltung“: Sand, Creme, warmes Dunkel, Holz als Hauston */
:root {
  --sand: #f3eee7; --sand-2: #e9e3d9; --creme: #fef2e3; --dunkel: #3e3a37; --linie: #d5d1c8;
  --leise: #62645f; --holz: #6e5a47; --moos: #5f6652; --messing: #a3875a;
  --ui: cubic-bezier(0.4, 0, 0.2, 1);   /* Buchungsleiste, Knöpfe: 150–250 ms */
  --rand: clamp(16px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--sand); color: var(--dunkel); font-family: 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; font-size: 1.0625rem; line-height: 1.6; }
button { font: inherit; color: inherit; }
.label { font-size: .75rem; font-weight: 500; font-stretch: 125%; letter-spacing: .06em; text-transform: uppercase; line-height: 1.3; }
.serif { font-family: 'Newsreader', Georgia, serif; font-optical-sizing: auto; font-weight: 300; letter-spacing: -.015em; line-height: 1.05; }
.serif em { font-style: italic; font-weight: 300; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }

/* ---------- Kopf ---------- */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; height: 76px; padding: 0 var(--rand); position: relative; z-index: 4; }
.marke { display: grid; gap: 1px; text-decoration: none; color: inherit; }
.marke small { font-size: .62rem; font-weight: 500; font-stretch: 125%; letter-spacing: .16em; text-transform: uppercase; color: var(--holz); }
.marke b { font: 400 1.6rem/1 'Newsreader', Georgia, serif; letter-spacing: -.01em; }
.kopf nav { display: flex; gap: clamp(16px, 2.4vw, 34px); }
.kopf nav a, .sprache { color: inherit; text-decoration: none; }
.kopf nav a { position: relative; }
.kopf nav a::after { content: ''; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px; background: currentColor; scale: 0 1; transform-origin: left; transition: scale .25s var(--ui); }
.kopf nav a:hover::after { scale: 1 1; }
.kopf__rechts { display: flex; align-items: center; gap: 18px; }
.sprache { color: var(--leise); }
.sprache b { color: var(--dunkel); font-weight: 500; }
.knopf { display: inline-flex; align-items: center; justify-content: center; height: 44px; padding: 0 20px; border: 0; border-radius: 2px; background: var(--holz); color: var(--creme); cursor: pointer; transition: background-color .2s var(--ui); text-decoration: none; }
.knopf:hover { background: var(--dunkel); }
.knopf:focus-visible, .kopf a:focus-visible, .schalter button:focus-visible { outline: 1.5px solid var(--dunkel); outline-offset: 3px; }

/* ---------- Strecke, in der der Schlüssel reist ---------- */
.reise { position: relative; }
.abschnitt { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: clamp(24px, 5vw, 96px); padding: 12vh var(--rand); min-height: 100svh; }
.text { display: grid; gap: 22px; max-width: 34rem; }
.text > .label { color: var(--holz); display: flex; gap: 14px; align-items: center; }
.text > .label::before { content: ''; width: 28px; height: 1px; background: currentColor; }
h1.serif { font-size: clamp(2.5rem, 1.5rem + 4vw, 5.5rem); }
h2.serif { font-size: clamp(2.1rem, 1.3rem + 2.8vw, 4rem); }
.text p { max-width: 30rem; }
.text .klein { color: var(--leise); font-size: .95rem; }

/* Held */
.held { min-height: calc(100svh - 76px); padding-top: 2vh; padding-bottom: 6vh; grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr); }
.leiste { display: grid; grid-template-columns: 1fr 1fr 1.2fr auto; align-items: stretch; background: var(--creme); border: 1px solid var(--linie); max-width: 40rem; margin-top: 10px; }
.leiste > div { padding: 12px 16px; border-right: 1px solid var(--linie); display: grid; gap: 2px; }
.leiste div .label { font-size: .62rem; color: var(--leise); }
.leiste div span:last-child { font-size: .95rem; white-space: nowrap; }
.leiste .knopf { height: auto; border-radius: 0; padding: 0 22px; }

.buehne { position: relative; justify-self: center; width: 100%; }
.buehne-held { aspect-ratio: 3 / 4.4; max-height: 78svh; width: auto; height: min(78svh, 680px); }
.bogen { position: absolute; inset: 0; width: 100%; height: 100%; }
.wp { position: absolute; aspect-ratio: 100 / 260; }
.hinweis { position: absolute; right: -2%; bottom: 7%; z-index: 1; display: grid; gap: 2px; padding: 10px 14px; background: var(--creme); border: 1px solid var(--linie); color: var(--holz); font-size: .62rem; }
.hinweis span:first-child { font: italic 300 1.15rem/1.1 'Newsreader', serif; letter-spacing: 0; text-transform: none; color: var(--dunkel); }
.wp-held { left: 50%; top: 52%; width: 34%; transform: translate(-50%, -50%) rotate(-12deg); }
.wp-faecher { left: 50%; top: 24%; width: 25%; transform: translate(-50%, 0) rotate(var(--r)); transform-origin: 50% 30%; }

/* Ankommen: Schlüsselbrett */
.ankommen { background: var(--sand-2); }
.brett { position: relative; justify-self: center; width: min(100%, 470px); display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px 10px; padding: 26px 22px 30px; background: var(--holz); border-radius: 4px; box-shadow: 0 30px 60px -30px rgba(62, 58, 55, .55), inset 0 0 0 1px rgba(255, 255, 255, .06); background-image: repeating-linear-gradient(90deg, rgba(0, 0, 0, .05) 0 2px, transparent 2px 23px), repeating-linear-gradient(90deg, rgba(255, 255, 255, .03) 0 1px, transparent 1px 41px); }
.brett__kopf { grid-column: 1 / -1; display: flex; justify-content: space-between; color: rgba(254, 242, 227, .7); padding-bottom: 10px; border-bottom: 1px solid rgba(254, 242, 227, .16); }
.haken { display: grid; justify-items: center; gap: 6px; }
.haken .nr { color: rgba(254, 242, 227, .78); font-size: .62rem; }
.haken .wp { position: relative; width: clamp(46px, 4.4vw, 64px); }
.schatten { position: absolute; inset: 0; width: 100%; height: 100%; color: rgba(30, 20, 12, .32); }
.stift { position: absolute; left: 50%; top: 7.7%; width: 17%; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 50%; background: radial-gradient(circle at 35% 35%, #d8c193, #8c7244 70%); box-shadow: 0 2px 2px rgba(0, 0, 0, .35); z-index: 3; }

/* Zimmer 7: Nachttisch in Draufsicht, dunkle Section */
.zimmer { background: var(--dunkel); color: var(--creme); min-height: 110svh; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.zimmer .text > .label { color: #c9b28c; }
.fakten dd .label { display: block; margin-top: 6px; font-size: .62rem; color: rgba(254, 242, 227, .6); }
.zimmer .text .klein { color: rgba(254, 242, 227, .6); }
.fakten { display: grid; grid-template-columns: auto 1fr; gap: 10px 26px; padding-top: 18px; border-top: 1px solid rgba(254, 242, 227, .16); font-size: .95rem; }
.fakten dt { color: rgba(254, 242, 227, .64); }
.preis { font: 300 1.9rem/1 'Newsreader', Georgia, serif; }
.preis small { font: 400 .8rem 'Mona Sans', sans-serif; color: rgba(254, 242, 227, .6); margin-left: 6px; }
.nachttisch { position: relative; justify-self: center; width: min(100%, 440px); aspect-ratio: 1; }
.tisch { position: absolute; inset: 0; border-radius: 6px; background-color: #b39371; background-image: repeating-linear-gradient(176deg, rgba(90, 60, 30, .10) 0 1px, transparent 1px 9px), repeating-linear-gradient(178deg, rgba(255, 240, 210, .08) 0 2px, transparent 2px 23px); box-shadow: 0 40px 70px -24px rgba(0, 0, 0, .6), inset 0 0 0 1px rgba(255, 255, 255, .08); }
.lampe { position: absolute; left: 7%; top: 7%; width: 40%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle, #fff8ec 0 13%, #e9dcc4 14% 17%, var(--creme) 18% 62%, #ead9bf 63% 100%); box-shadow: 0 18px 30px -8px rgba(40, 25, 10, .45); }
.buch { position: absolute; left: 12%; bottom: 9%; width: 32%; aspect-ratio: 3 / 4.3; background: var(--moos); rotate: -7deg; border-radius: 2px 4px 4px 2px; box-shadow: 0 12px 20px -8px rgba(30, 20, 10, .5), inset 6px 0 0 rgba(0, 0, 0, .12); display: grid; place-content: center; text-align: center; color: var(--creme); padding: 10%; }
.buch i { font: italic 300 clamp(.85rem, 1.5vw, 1.15rem)/1.05 'Newsreader', serif; }
.buch span { font-size: .45rem; font-stretch: 125%; letter-spacing: .14em; text-transform: uppercase; margin-top: 8px; opacity: .86; }
.glas { position: absolute; right: 10%; top: 10%; width: 15%; aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 38% 36%, rgba(255, 255, 255, .85) 0 10%, rgba(230, 240, 238, .55) 11% 58%, rgba(255, 255, 255, .25) 59% 100%); box-shadow: 0 0 0 2px rgba(255, 255, 255, .35), 0 10px 16px -6px rgba(30, 20, 10, .35); }
.wp-zimmer { left: 67%; top: 61%; width: 22%; transform: translate(-50%, -50%) rotate(-68deg); }

/* Abreise: Schlüsselkasten */
.abreise { min-height: 100svh; }
.buehne-kasten { --k: clamp(74px, 7vw, 100px); width: calc(var(--k) * 2); height: calc(var(--k) * 2.6 * 1.6); justify-self: center; }
.wp-kasten { left: 50%; top: 0; width: var(--k); transform: translateX(-50%) rotate(3deg); }
.kasten { position: absolute; left: 0; right: 0; bottom: 0; top: calc(var(--k) * 2.6 * .7); z-index: 3; background: var(--moos); border-radius: 6px 6px 3px 3px; box-shadow: 0 34px 60px -28px rgba(62, 58, 55, .6), inset 0 0 0 1px rgba(255, 255, 255, .06); display: grid; align-content: center; justify-items: center; gap: 10px; color: rgba(254, 242, 227, .82); }
.kasten::before { content: ''; position: absolute; left: 14%; right: 14%; top: 0; height: 9px; translate: 0 -50%; border-radius: 6px; background: #2d2a26; box-shadow: 0 1px 0 rgba(255, 255, 255, .15); }
.kasten .schild { font-size: .6rem; letter-spacing: .2em; padding: 7px 12px; color: #3e3a37; background: linear-gradient(135deg, #cdb381, #a3875a); border-radius: 2px; box-shadow: 0 1px 2px rgba(0, 0, 0, .3); }
.kasten i { font: italic 300 1.15rem/1 'Newsreader', serif; opacity: .8; }
.kasten .schraube { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: rgba(0, 0, 0, .28); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .2); }

/* Fuß: Visitenkarte */
.fuss { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: end; gap: 24px; padding: 56px var(--rand) 120px; border-top: 1px solid var(--linie); }
.fuss p { color: var(--leise); font-size: .95rem; }
.fuss b { font: 400 2rem/1 'Newsreader', serif; color: var(--dunkel); }

/* ---------- Die Reisenden ---------- */
.schluessel { position: absolute; left: 0; top: 0; width: 100px; aspect-ratio: 100 / 260; z-index: 2; pointer-events: none; will-change: transform; filter: drop-shadow(0 12px 12px rgba(40, 28, 16, .22)); }
.schluessel svg, .kopie svg { display: block; width: 100%; height: 100%; overflow: visible; }
.kopie { position: absolute; inset: 0; }

/* ---------- Bedienung der Demo ---------- */
.schalter { position: fixed; right: 16px; bottom: 16px; z-index: 10; display: grid; gap: 8px; padding: 10px 12px 11px; background: rgba(254, 242, 227, .92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid var(--linie); border-radius: 4px; color: var(--leise); max-width: calc(100vw - 32px); }
.schalter__knoepfe { display: flex; gap: 4px; align-items: center; }
.schalter__knoepfe > span { margin-right: 6px; }
.schalter button { letter-spacing: inherit; text-transform: inherit; font: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: background-color .15s var(--ui), color .15s var(--ui); }
.schalter button[aria-pressed='true'] { background: var(--dunkel); border-color: var(--dunkel); color: var(--creme); }
.schalter__werte { display: flex; gap: 14px; flex-wrap: wrap; }
.schalter__werte b { color: var(--dunkel); font-weight: 500; }

/* ---------- Reduzierte Bewegung: Duplikate an jedem Wegpunkt ---------- */
.reduziert .schluessel { display: none; }
.reduziert .abschnitt > * { opacity: 0; transition: opacity .25s linear; }
.reduziert .abschnitt.sichtbar > * { opacity: 1; }
.reduziert * { transition-duration: .01ms; }
.reduziert .abschnitt > * { transition-duration: .25s; }

/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .schalter { display: none; }

@media (max-width: 1100px) { .kopf nav { display: none; } }
@media (max-width: 760px) {
  .kopf { height: 64px; }
  .sprache { display: none; }
  .kopf .knopf { height: 38px; padding: 0 14px; font-size: .68rem; }
  .abschnitt, .held, .zimmer { grid-template-columns: 1fr; padding: 64px var(--rand); gap: 40px; min-height: 0; }
  .held { padding-top: 16px; }
  .buehne-held { height: 60svh; max-height: 470px; }
  .leiste { grid-template-columns: 1fr 1fr; }
  .leiste > div:nth-child(2) { border-right: 0; }
  .leiste > div:nth-child(3) { grid-column: 1 / -1; border-top: 1px solid var(--linie); border-right: 0; }
  .leiste .knopf { grid-column: 1 / -1; height: 48px; }
  .brett { gap: 14px 6px; padding: 20px 14px 24px; }
  .haken .wp { width: 50px; }
  .nachttisch { width: min(100%, 340px); }
  .abreise { padding-bottom: 120px; }
  .schalter { left: 16px; right: 16px; bottom: 12px; padding: 8px 10px; }
  .schalter__knoepfe > span { display: none; }
  .schalter__werte > span:last-child { display: none; }
  .fuss { padding-bottom: 150px; }
}
@media (hover: none) { .kopf nav a::after { display: none; } }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs>
    <linearGradient id="messing" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#c7ab74"/><stop offset=".55" stop-color="#a3875a"/><stop offset="1" stop-color="#7d6541"/>
    </linearGradient>
  </defs>
</svg>

<header class="kopf">
  <a class="marke" href="#" aria-label="Gästehaus Quittenhof, Startseite"><small>Gästehaus · Weimar</small><b>Quittenhof</b></a>
  <nav class="label" aria-label="Hauptnavigation"><a href="#">Zimmer</a><a href="#">Frühstück</a><a href="#">Garten</a><a href="#">Anreise</a></nav>
  <div class="kopf__rechts label"><span class="sprache"><b>DE</b> / EN</span><a class="knopf label" href="#">Direkt buchen</a></div>
</header>

<main class="reise">
  <section class="abschnitt held" aria-labelledby="t-held">
    <div class="text">
      <p class="label">Gästehaus in der Altstadt</p>
      <h1 class="serif" id="t-held">Acht Zimmer hinter <em>einer alten Tür.</em></h1>
      <p>Fünf Minuten zum Frauenplan, zwei zur Ilm. Frühstück gibt es im Garten unter dem Quittenbaum, wenn das Wetter mitspielt.</p>
      <form class="leiste" onsubmit="return false" aria-label="Verfügbarkeit">
        <div><span class="label">Anreise</span><span>Fr, 13. Nov.</span></div>
        <div><span class="label">Abreise</span><span>So, 15. Nov.</span></div>
        <div><span class="label">Gäste</span><span>2 Erwachsene</span></div>
        <button class="knopf label" type="submit">Verfügbarkeit prüfen</button>
      </form>
    </div>
    <div class="buehne buehne-held">
      <svg class="bogen" viewBox="0 0 300 440" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
        <path d="M20 440V150a130 130 0 0 1 260 0v290z" fill="#e6dfd3"/>
        <path d="M44 440V154a106 106 0 0 1 212 0v286z" fill="none" stroke="#d5ccbd" stroke-width="1.5"/>
        <path d="M150 48v392M44 250h212" stroke="#d5ccbd" stroke-width="1.5"/>
        <path d="M60 268h74v150H60zM166 268h74v150h-74z" fill="none" stroke="#d5ccbd" stroke-width="1.2"/>
        <circle cx="168" cy="242" r="5" fill="#c9b28c"/>
      </svg>
      <p class="label hinweis"><span>Zimmer 7</span><span>für Sie reserviert</span></p>
      <div class="wp wp-held" data-wp="held"></div>
      <div class="wp wp-faecher" data-wp="faecher-1" style="--r:34deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-2" style="--r:24deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-3" style="--r:14deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-4" style="--r:4deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-5" style="--r:-6deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-6" style="--r:-16deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-8" style="--r:-36deg"></div>
      <div class="wp wp-faecher" data-wp="faecher-7" style="--r:-26deg"></div>
    </div>
  </section>

  <section class="abschnitt ankommen" aria-labelledby="t-ankommen">
    <div class="text">
      <p class="label">01 · Ankommen</p>
      <h2 class="serif" id="t-ankommen">Kein Schalter, <em>nur ein Brett im Flur.</em></h2>
      <p>Am Anreisetag bekommen Sie einen Code für die Haustür. Ihr Zimmerschlüssel hängt am Brett gleich hinter dem Eingang, mit Ihrem Namen auf einer Karte.</p>
      <p class="klein">Wir sind bis 20 Uhr im Haus, danach erreichen Sie uns telefonisch.</p>
    </div>
    <div class="brett" aria-hidden="true">
      <div class="brett__kopf label"><span>Zimmer</span><span>Erdgeschoss · 1. Stock</span></div>
      <div class="haken"><span class="nr label">01</span><div class="wp" data-wp="haken-1"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">02</span><div class="wp" data-wp="haken-2"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">03</span><div class="wp" data-wp="haken-3"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">04</span><div class="wp" data-wp="haken-4"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">05</span><div class="wp" data-wp="haken-5"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">06</span><div class="wp" data-wp="haken-6"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">07</span><div class="wp" data-wp="haken-7"><i class="stift"></i></div></div>
      <div class="haken"><span class="nr label">08</span><div class="wp" data-wp="haken-8"><i class="stift"></i></div></div>
    </div>
  </section>

  <section class="abschnitt zimmer" aria-labelledby="t-zimmer">
    <div class="nachttisch" aria-hidden="true">
      <div class="tisch"></div>
      <div class="lampe"></div>
      <div class="glas"></div>
      <div class="buch"><i>Weimar zu Fuß</i><span>Zwölf Wege</span></div>
      <div class="wp wp-zimmer" data-wp="zimmer"></div>
    </div>
    <div class="text">
      <p class="label">02 · Zimmer 7, Gartenseite</p>
      <h2 class="serif" id="t-zimmer">Morgens hören Sie <em>nur die Amseln.</em></h2>
      <dl class="fakten">
        <dt>Größe</dt><dd>24 m², Dielenboden, Kachelofen</dd>
        <dt>Bett</dt><dd>180 × 200 cm, Leinenbettwäsche</dd>
        <dt>Ausblick</dt><dd>Garten mit Quittenbaum</dd>
        <dt>Preis</dt><dd><span class="preis">ab 128 €</span><small class="label">pro Nacht, mit Frühstück</small></dd>
      </dl>
    </div>
  </section>

  <section class="abschnitt abreise" aria-labelledby="t-abreise">
    <div class="text">
      <p class="label">03 · Abreise</p>
      <h2 class="serif" id="t-abreise">Schlüssel in den Kasten, <em>Kaffee für unterwegs.</em></h2>
      <p>Check-out bis 11 Uhr. Werfen Sie den Schlüssel einfach in den Kasten neben der Haustür. Einen Becher Kaffee aus der Küche nehmen Sie gern mit.</p>
      <p class="klein">Wer direkt bei uns bucht, zahlt zehn Prozent weniger als über Portale.</p>
    </div>
    <div class="buehne buehne-kasten" aria-hidden="true">
      <div class="wp wp-kasten" data-wp="kasten"></div>
      <div class="kasten">
        <i class="schraube" style="left:12px;top:14px"></i><i class="schraube" style="right:12px;top:14px"></i>
        <i class="schraube" style="left:12px;bottom:14px"></i><i class="schraube" style="right:12px;bottom:14px"></i>
        <span class="schild label">Schlüssel</span><i>Gute Reise</i>
      </div>
    </div>
  </section>
</main>

<footer class="fuss">
  <div><b>Quittenhof</b><p>Gästehaus in der Altstadt von Weimar · acht Zimmer · seit 2014</p></div>
  <p class="label">Anreise ab 15 Uhr · Check-out bis 11 Uhr</p>
</footer>

<div class="schalter mono" aria-label="Demo-Steuerung">
  <div class="schalter__knoepfe" role="group" aria-label="Variante">
    <span>Variante</span>
    <button type="button" data-variante="einer" aria-pressed="true">Ein Schlüssel</button>
    <button type="button" data-variante="bund" aria-pressed="false">Schlüsselbund</button>
  </div>
  <div class="schalter__werte" aria-live="off">
    <span>Wegpunkt <b data-anzeige="wp">1 / 4</b></span>
    <span><b data-anzeige="ort">Empfang</b></span>
    <span>Flip.fit · ease <b>none</b> · scrub <b>1</b></span>
  </div>
</div>

<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/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Flip.min.js"></script>
<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  gsap.registerPlugin(ScrollTrigger, Flip);

  // Werte (auch in bau.werte): lineare Strecken wie gemessen, Scrub glättet 1 s nach
  const SCRUB = 1;              // Sekunden Aufholzeit
  const ANKUNFT = 0.06;         // Ankunft 6 % Viewporthöhe bevor der Wegpunkt mittig steht
  const ANLAUF = 0.7;           // Strecke beginnt höchstens 70 % Viewporthöhe vor der Ankunft
  const STAGGER = 0.025;        // Variante Schlüsselbund: 2,5 % Viewporthöhe Versatz je Schlüssel
  const ORTE = ['Empfang', 'Schlüsselbrett', 'Zimmer 7', 'Schlüsselkasten'];

  const reise = document.querySelector('.reise');
  const wp = n => document.querySelector(`[data-wp="${n}"]`);

  // Ein Zimmerschlüssel: Lederanhänger mit Nummer, Ring, Bartschlüssel aus Messing (viewBox 100 × 260)
  const schluesselSVG = nr => `<svg viewBox="0 0 100 260" aria-hidden="true" focusable="false">
    <path fill-rule="evenodd" d="M12 40a38 38 0 0 1 76 0v36a38 38 0 0 1-76 0zM44 20a6 6 0 1 0 12 0a6 6 0 1 0-12 0z" fill="#fef2e3" stroke="#cdbfa8" stroke-width="1.2"/>
    <rect x="18" y="31" width="64" height="77" rx="32" fill="none" stroke="#6e5a47" stroke-opacity=".35" stroke-width=".8"/>
    <text x="50" y="80" text-anchor="middle" font-family="Newsreader, Georgia, serif" font-size="44" font-weight="400" fill="#3e3a37">${nr}</text>
    <text x="50" y="97" text-anchor="middle" font-family="'Mona Sans', sans-serif" font-size="5.4" font-weight="600" font-stretch="125%" letter-spacing="1.1" fill="#6e5a47">QUITTENHOF</text>
    <circle cx="50" cy="121" r="8" fill="none" stroke="url(#messing)" stroke-width="3.2"/>
    <path fill-rule="evenodd" d="M30 150a20 20 0 1 0 40 0a20 20 0 1 0-40 0zM41 150a9 9 0 1 0 18 0a9 9 0 1 0-18 0z" fill="url(#messing)"/>
    <rect x="42" y="168" width="16" height="8" rx="2" fill="url(#messing)"/>
    <rect x="46.5" y="174" width="7" height="72" rx="2" fill="url(#messing)"/>
    <path d="M53 212h21v9h-6v6h6v13H53z" fill="url(#messing)"/>
    <path d="M48 176v66" stroke="#e3cf9f" stroke-opacity=".55" stroke-width="1"/>
  </svg>`;
  // Gemalter Umriss am Brett: derselbe Schlüssel als Silhouette
  const schattenSVG = `<svg class="schatten" viewBox="0 0 100 260" aria-hidden="true" focusable="false">
    <path fill-rule="evenodd" d="M12 40a38 38 0 0 1 76 0v36a38 38 0 0 1-76 0zM44 20a6 6 0 1 0 12 0a6 6 0 1 0-12 0z" fill="currentColor"/>
    <circle cx="50" cy="121" r="8" fill="none" stroke="currentColor" stroke-width="3.2"/>
    <path fill-rule="evenodd" d="M30 150a20 20 0 1 0 40 0a20 20 0 1 0-40 0zM41 150a9 9 0 1 0 18 0a9 9 0 1 0-18 0z" fill="currentColor"/>
    <path d="M42 168h16v8h-4.5v36H74v9h-6v6h6v13H46.5v-64H42z" fill="currentColor"/>
  </svg>`;
  document.querySelectorAll('[data-wp^="haken-"]').forEach(w => w.insertAdjacentHTML('afterbegin', schattenSVG));

  // Acht Reisende, absolut in .reise, damit sie den Fluss nicht stören
  const schluessel = [1, 2, 3, 4, 5, 6, 7, 8].map(nr => {
    const d = document.createElement('div');
    d.className = 'schluessel'; d.dataset.nr = nr; d.setAttribute('aria-hidden', 'true');
    d.innerHTML = schluesselSVG(nr);
    reise.appendChild(d);
    return d;
  });

  // Wegpunkte je Schlüssel und Variante
  let variante = 'einer';
  function plan() {
    return schluessel.map((k, i) => {
      const nr = i + 1, haken = wp('haken-' + nr);
      if (variante === 'einer') return nr === 7 ? [wp('held'), haken, wp('zimmer'), wp('kasten')] : [haken, haken, haken, haken];
      return [wp('faecher-' + nr), haken, nr === 7 ? wp('zimmer') : haken, nr === 7 ? wp('kasten') : haken];
    });
  }

  // Variante „Ein Schlüssel“: Zimmer 2 und 5 sind belegt, dort zeigt das Brett nur den gemalten Umriss
  const unterwegs = nr => variante === 'bund' || (nr !== 2 && nr !== 5);
  let tl = null, ankunft = [0, 0, 0, 0], abfahrt = [0, 0, 0, 0], gesamt = 1;
  const anzeigeWp = document.querySelector('[data-anzeige="wp"]');
  const anzeigeOrt = document.querySelector('[data-anzeige="ort"]');
  function anzeigen(pos) {
    let j = 0;
    for (let n = 1; n < 4; n++) if (pos >= abfahrt[n]) j = n;
    const unterwegs = j > 0 && pos < ankunft[j];
    anzeigeWp.textContent = unterwegs ? `${j} → ${j + 1}` : `${j + 1} / 4`;
    anzeigeOrt.textContent = unterwegs ? 'unterwegs' : ORTE[j];
  }

  // Reduziert: der Schlüssel liegt an jedem Wegpunkt als Duplikat
  function statisch(p) {
    document.querySelectorAll('.kopie').forEach(k => k.remove());
    const gesetzt = new Set();
    p.forEach((wege, i) => unterwegs(i + 1) && wege.forEach(w => {
      const schl = w.dataset.wp + '|' + (i + 1);
      if (gesetzt.has(schl)) return; gesetzt.add(schl);
      w.insertAdjacentHTML('beforeend', `<div class="kopie" aria-hidden="true">${schluesselSVG(i + 1)}</div>`);
    }));
    anzeigeWp.textContent = '1–4'; anzeigeOrt.textContent = 'statisch';
  }

  function bauen() {
    if (tl) { tl.scrollTrigger && tl.scrollTrigger.kill(); tl.kill(); tl = null; }
    const p = plan();
    if (reduziert()) { statisch(p); return; }
    document.querySelectorAll('.kopie').forEach(k => k.remove());

    // Flip.fit nacheinander: jeder Wegpunkt wird vom vorigen aus vermessen
    schluessel.forEach(k => gsap.set(k, { clearProps: 'all' }));
    schluessel.forEach((k, i) => gsap.set(k, { autoAlpha: unterwegs(i + 1) ? 1 : 0 }));
    const ziele = schluessel.map((k, i) => p[i].map(w => {
      const v = Flip.fit(k, w, { getVars: true });
      gsap.set(k, v);
      return v;
    }));
    schluessel.forEach((k, i) => gsap.set(k, ziele[i][0]));

    // Zeitachse in Scroll-Pixeln: Ankunft, wenn der Wegpunkt etwa mittig im Viewport steht
    const vh = innerHeight, oben = reise.getBoundingClientRect().top + scrollY;
    gesamt = Math.max(1, reise.offsetHeight - vh);
    const begrenzt = v => Math.min(gesamt, Math.max(0, v));
    const mitte = el => { const r = el.getBoundingClientRect(); return r.top + scrollY + r.height / 2 - vh / 2 - oben; };
    const weg7 = p[6];
    ankunft = weg7.map((w, j) => j === 0 ? 0 : begrenzt(mitte(w) - ANKUNFT * vh));
    abfahrt = ankunft.map((a, j) => j === 0 ? 0 : Math.max(ankunft[j - 1] + 0.12 * vh, a - ANLAUF * vh));

    tl = gsap.timeline({
      defaults: { ease: 'none' },
      scrollTrigger: { trigger: reise, start: 'top top', end: 'bottom bottom', scrub: SCRUB, onUpdate: s => anzeigen(s.progress * gesamt) }
    });
    tl.set({}, {}, gesamt);
    for (let j = 1; j < 4; j++) {
      const dauer = ankunft[j] - abfahrt[j];
      schluessel.forEach((k, i) => {
        if (p[i][j] === p[i][j - 1]) return;
        const versatz = variante === 'bund' && j === 1 ? (i - 3.5) * STAGGER * vh : 0;
        tl.to(k, { ...ziele[i][j], duration: dauer, immediateRender: false }, Math.max(0, abfahrt[j] + versatz));
      });
    }
    anzeigen(0);
  }

  // Variantenschalter
  const knoepfe = [...document.querySelectorAll('[data-variante]')];
  function setzeVariante(v) {
    variante = v;
    const [a, b] = document.querySelectorAll('.hinweis span');
    a.textContent = v === 'bund' ? 'Acht Schlüssel' : 'Zimmer 7';
    b.textContent = v === 'bund' ? 'ein Brett im Flur' : 'für Sie reserviert';
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    bauen();
  }
  knoepfe.forEach(b => b.addEventListener('click', () => { pause(); setzeVariante(b.dataset.variante); }));

  // Reduziert: Sections nur kurz einblenden
  const io = new IntersectionObserver(e => e.forEach(x => { if (x.isIntersecting) x.target.classList.add('sichtbar'); }), { threshold: 0.15 });
  document.querySelectorAll('.abschnitt').forEach(a => io.observe(a));

  let zeit = null;
  addEventListener('resize', () => { clearTimeout(zeit); zeit = setTimeout(() => { bauen(); ScrollTrigger.refresh(); }, 180); });
  // Klasse .reduziert kann die Detailseite nachträglich setzen
  let warReduziert = reduziert();
  new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); bauen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
  bauen();
  (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { bauen(); ScrollTrigger.refresh(); });

  // Vorführung: die Seite fährt Wegpunkt für Wegpunkt an, in der Vorschau abwechselnd beide Varianten
  let uhr = null, runde = 0;
  const HALTEN = 1100, FAHRT = 1900;
  const sanft = t => 0.5 - 0.5 * Math.cos(Math.PI * t);
  function starteRunde() {
    if (html.classList.contains('vorschau')) setzeVariante(runde % 2 ? 'bund' : 'einer');
    scrollTo(0, 0); bauen();
    const oben = reise.getBoundingClientRect().top + scrollY;
    const ziele = ankunft.map((a, j) => oben + (j === 0 ? 0 : Math.min(gesamt, a + ANKUNFT * innerHeight)));
    const t0 = performance.now();
    const schritt = t => {
      const z = t - t0, takt = HALTEN + FAHRT;
      const n = Math.floor(z / takt), rest = z - n * takt;
      if (n >= 3 && rest > HALTEN * 1.4) { runde++; starteRunde(); return; }
      const von = ziele[Math.min(n, 3)], nach = ziele[Math.min(n + 1, 3)];
      const y = rest < HALTEN || n >= 3 ? von : von + (nach - von) * sanft((rest - HALTEN) / FAHRT);
      scrollTo(0, y);
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function vorfuehren() { pause(); runde = 0; starteRunde(); }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() { pause(); scrollTo(0, 0); bauen(); }
  function weiter() { if (html.classList.contains('vorschau') && !uhr) vorfuehren(); }

})();
</script>
</body>
</html>