Zum Inhalt
← Footer

Footer-Reveal (Footer liegt unter der Seite)

Die Seite hebt sich am Ende wie ein Vorhang und legt einen Footer frei, der schon darunter liegt.

Der Footer liegt hinter dem Hauptinhalt am unteren Rand. Der Hauptinhalt hat einen deckenden Hintergrund und scrollt am Ende wie ein Vorhang nach oben weg. Darunter wird der Footer sichtbar, er bewegt sich selbst nicht. In der Parallax-Variante liegt der Footer im Fluss: Sein Inhalt startet um 25 % nach oben versetzt und gleitet scrollgekoppelt in die Endposition, ein dunkles Overlay hellt dabei auf. Runde Ecken, ein Schatten oder eine Wölbung an der Unterkante des Inhalts betonen die zwei Ebenen.

Die räumliche Schichtung macht das Seitenende zum Ereignis, ohne Inhalte zu animieren. Der Footer wirkt wie eine Belohnung und wird zur Bühne für den Kontakt. Die Seite fühlt sich geschichtet an statt flach.

Wann einsetzen

Portfolios, Studios, Agenturen und Landingpages mit gestaltetem Footer: Abschluss-CTA, Wortmarke, Kontakt.

Wann nicht

Wenn der Footer höher als der Viewport ist: Dann bleibt ein Teil unerreichbar oder es ruckelt. Ebenfalls nicht bei sehr kurzen Seiten und bei Infinite Scroll.

Varianten

  • Sticky: footer position: sticky; bottom: 0 hinter main, reines CSS ohne Platzhalter
  • Fixed: position: fixed und padding-bottom am body (klassisch) oder fixed in einem clip-path-Wrapper
  • Parallax: Footer-Inhalt aus yPercent -25, Overlay opacity .5 → 0, linear per ScrollTrigger (Huge, Osmo)
  • Wölbung: Unterkante des Inhalts mit großem Bogen, der beim Freilegen flach wird (Dennis Snellenberg)
  • Runde Ecken und Schatten an der Unterkante des Inhalts
  • Opacity-Fade: Footer blendet beim Freilegen von transparent zu deckend (Motion-Beispiel)

Umsetzung

Sticky: footer { position: sticky; bottom: 0; z-index: 0 } und main { position: relative; z-index: 1; background: var(--bg); transform: translate3d(0,0,0) }. Das translate3d behebt einen Stacking-Fehler in Safari. Am Seiten-Wrapper overflow: clip statt hidden, sonst bricht sticky. Parallax: Footer im Fluss mit overflow: hidden, darin Inhalt und ein absolutes Overlay mit opacity 0. gsap.timeline({ scrollTrigger: { trigger: footer, start: 'clamp(top bottom)', end: 'clamp(top top)', scrub: true } }).from(inner, { yPercent: -25, ease: 'linear' }).from(dark, { opacity: .5, ease: 'linear' }, '<'). Das Overlay geht dabei von .5 auf 0, der Footer hellt also auf; die Richtung bewusst wählen. Fixed mit clip-path: Wrapper mit bekannter Höhe und clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%), darin der Footer mit position: fixed. clip-path beschneidet auch fixed-Kinder, das ist der Kern des Tricks. Die Höhe per CSS-Variable oder ResizeObserver pflegen. Ohne JS geht auch animation-timeline: view() auf dem Footer mit animation-range entry. Immer: Footerhöhe ≤ 100svh.

Robust umsetzen

Telefon. Funktioniert. Footer kürzer als 100svh halten, sonst normaler Footer. Mit der iOS-Adressleiste testen, svh-Einheiten nutzen. Parallaxe bei Bedarf abschalten.

Ohne Bewegung. Die Sticky-Variante kann bleiben, sie hat keine eigene Bewegung. Parallaxe und scrollgekoppelte Formänderungen entfernen, den Footer dann normal im Fluss zeigen.

Barrierefrei. Der Footer bleibt im DOM und in natürlicher Reihenfolge am Ende des Tab-Flusses. Bei der Sticky-Variante kann ein fokussierter Footer-Link unter dem Inhalt liegen: Bei focusin im Footer ans Seitenende scrollen. Fokus deutlich sichtbar halten. Dekorative Wortmarken mit aria-hidden versehen.

Code

<!DOCTYPE html>
<!--
  Footer-Reveal (Footer liegt unter der Seite)
  Fundus · Footer · https://cecodesign.de/lab/fundus/footer/footer-reveal/
  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>Footer-Reveal (Footer liegt unter der Seite) · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<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>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --papier: #eeeae2; --tinte: #171614; --tinte-leise: rgba(23, 22, 20, .64); --tinte-linie: rgba(23, 22, 20, .14);
  --ziegel: #ad422b; --creme: #f7f0e4; --creme-leise: rgba(247, 240, 228, .66); --creme-linie: rgba(247, 240, 228, .24);
  --rand: clamp(16px, 3.4vw, 48px);
  --fuss-hoehe: min(92svh, 46rem);   /* Footer nie höher als der Viewport */
  --ecke: clamp(20px, 2.6vw, 36px);  /* Variante Sticky: runde Unterkante */
  --woelbung: clamp(56px, 9vw, 140px); /* Variante Wölbung: Starthöhe des Bogens */
  --p: 0;                            /* Freilegung 0 → 1, per JS */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--tinte); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--tinte); }
a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; border-radius: 2px; }

/* ---------- Seite ---------- */
.seite { overflow: clip; }  /* clip statt hidden, sonst bricht sticky */
.inhalt { position: relative; z-index: 1; background: var(--papier); transform: translate3d(0, 0, 0); /* Safari-Stacking */ }
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 22px var(--rand); border-bottom: 1px solid var(--tinte-linie); }
.leiste__marke { font-weight: 600; font-size: 17px; letter-spacing: -.02em; }
.leiste nav { display: flex; gap: clamp(14px, 2.4vw, 32px); color: var(--tinte-leise); }

.held { padding: clamp(56px, 12vh, 140px) var(--rand) clamp(48px, 9vh, 110px); display: grid; gap: 36px; }
.held h1 { font-size: clamp(min(40px, 10.4vw), 6.6vw, 104px); font-weight: 600; letter-spacing: -.045em; line-height: .95; max-width: 15ch; }
.held h1 em { font-size: 1.08em; }
.held__fuss { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--tinte-leise); }

.arbeiten { display: grid; grid-template-columns: repeat(12, 1fr); gap: clamp(28px, 4vw, 56px) clamp(16px, 2vw, 28px); padding: 0 var(--rand); }
.arbeit { display: grid; gap: 12px; align-content: start; }   /* Bildrahmen nicht auf Zeilenhöhe strecken */
.arbeit:nth-child(1) { grid-column: 1 / 8; }
.arbeit:nth-child(2) { grid-column: 8 / 13; align-self: end; }
.arbeit:nth-child(3) { grid-column: 1 / 6; }
.arbeit:nth-child(4) { grid-column: 6 / 13; }
.arbeit__bild { overflow: hidden; border-radius: 4px; background: #ddd7cc; }
.arbeit__bild img { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }   /* height: auto, sonst gewinnt das height-Attribut und das Bild wird beschnitten */
.arbeit:nth-child(2) .arbeit__bild img, .arbeit:nth-child(3) .arbeit__bild img { aspect-ratio: 4 / 5; }
.arbeit figcaption { display: flex; justify-content: space-between; gap: 12px; color: var(--tinte-leise); }
.arbeit figcaption b { color: var(--tinte); font-weight: 500; }

.schluss { padding: clamp(80px, 16vh, 180px) var(--rand) clamp(64px, 12vh, 140px); display: grid; gap: 40px; }
.schluss p.satz { font-size: clamp(30px, 4.4vw, 68px); font-weight: 500; letter-spacing: -.035em; line-height: 1.02; max-width: 19ch; }
.schluss__zeile { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--tinte-leise); border-top: 1px solid var(--tinte-linie); padding-top: 18px; }
.punkt { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--ziegel); margin-right: 8px; vertical-align: 1px; }

/* ---------- Footer ---------- */
.fuss { --creme-leise: rgba(247, 240, 228, .91); position: relative; z-index: 0; height: var(--fuss-hoehe); background: var(--ziegel); color: var(--creme); overflow: hidden; }
.fuss__innen { position: relative; height: 100%; display: flex; flex-direction: column; justify-content: space-between; padding: clamp(28px, 6vh, 64px) var(--rand) 0; }
.fuss__dunkel { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; }
.fuss__oben { display: grid; grid-template-columns: repeat(12, 1fr); gap: 28px clamp(16px, 2vw, 28px); }
.fuss__ruf { grid-column: 1 / 8; display: grid; gap: clamp(18px, 3vh, 32px); align-content: start; }
.fuss__ruf .mono { color: var(--creme-leise); }
.fuss h2 { font-size: clamp(38px, 5.6vw, 92px); font-weight: 600; letter-spacing: -.045em; line-height: .94; }
.fuss h2 em { font-size: 1.08em; }
.mail { justify-self: start; display: inline-flex; align-items: baseline; gap: 12px; font-size: clamp(18px, 1.7vw, 26px); font-weight: 500; letter-spacing: -.01em; border-bottom: 1px solid var(--creme-linie); padding-bottom: 6px; transition: border-color 150ms linear; }
.mail:hover { border-color: var(--creme); }
.mail span { transition: transform 150ms linear; }
.mail:hover span { transform: translateX(4px); }
.fuss__spalten { grid-column: 9 / 13; display: grid; grid-template-columns: 1fr 1fr; gap: 28px 20px; align-content: start; padding-top: 4px; }
.spalte { display: grid; gap: 10px; align-content: start; font-size: 15px; line-height: 1.45; }
.spalte .mono { color: var(--creme-leise); margin-bottom: 2px; }
.spalte a:hover { text-decoration: underline; text-underline-offset: 3px; }
.fuss__unten { display: grid; gap: 4px; }
.fuss__fein { position: relative; z-index: 1; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; color: var(--creme-leise); border-top: 1px solid var(--creme-linie); padding-top: 14px; }
.fuss__fein nav { display: flex; gap: 20px; }
.fuss__fein a:hover { color: var(--creme); }
.wortmarke { font-size: min(25.5vw, 36svh); font-weight: 600; letter-spacing: -.065em; line-height: 1; color: var(--tinte); margin: -.1em 0 -.13em -.05em; white-space: nowrap; user-select: none; }
.wortmarke sup { font-size: .14em; letter-spacing: 0; vertical-align: top; position: relative; top: .35em; margin-left: .2em; font-weight: 500; }

/* ---------- Variante A: Sticky ---------- */
.v-sticky .fuss, .v-woelbung .fuss { position: sticky; bottom: 0; }
.v-sticky .inhalt { border-radius: 0 0 var(--ecke) var(--ecke); box-shadow: 0 30px 60px -30px rgba(0, 0, 0, .45); }

/* ---------- Variante B: Parallax (GSAP, Footer im Fluss) ---------- */
.v-parallax .fuss { position: relative; }

/* ---------- Variante C: Wölbung, flacht beim Freilegen ab ---------- */
.inhalt::after { content: ''; position: absolute; left: -10%; right: -10%; top: calc(100% - 1px); height: calc(var(--woelbung) * (1 - var(--p))); background: var(--papier); border-radius: 0 0 50% 50% / 0 0 100% 100%; box-shadow: 0 34px 50px -34px rgba(0, 0, 0, .5); display: none; }
.v-woelbung .inhalt::after { display: block; }

/* ---------- Bedienfeld ---------- */
/* top 80 px: unter der Kopfleiste (64 px + Luft), damit die Hauptnavigation oben rechts frei bleibt */
.feld { position: fixed; z-index: 10; top: 80px; right: var(--rand); display: grid; gap: 8px; padding: 10px; border-radius: 14px; background: rgba(23, 22, 20, .84); color: var(--creme); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); max-width: calc(100vw - 28px); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--creme-leise); background: none; border: 1px solid var(--creme-linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: background-color 150ms linear, color 150ms linear; }
.modi button[aria-pressed='true'] { background: var(--creme); color: var(--tinte); border-color: var(--creme); }
.werte { display: flex; justify-content: space-between; gap: 14px; padding: 2px 4px 0; color: var(--creme-leise); font-size: 10px; }
.werte b { color: var(--creme); font-weight: 500; }
.werte output { font-variant-numeric: tabular-nums; min-width: 9ch; text-align: right; white-space: nowrap; flex-shrink: 0; }

/* Vorschau: nur die Bühne, Bedienfeld als Etikett */
.vorschau body { overflow: hidden; }
.vorschau .modi button:not([aria-pressed='true']) { display: none; }
.vorschau .werte { display: none; }   /* nur das Varianten-Etikett, sonst deckt das Feld den Text */

/* Reduzierte Bewegung: keine Parallaxe, keine Formänderung; Sticky bleibt (bewegt sich nicht selbst) */
.reduziert .fuss__innen, .reduziert .fuss__dunkel { transform: none !important; opacity: 0 !important; }
.reduziert .fuss__innen { opacity: 1 !important; }
.reduziert .inhalt::after { display: none !important; }
.reduziert.v-woelbung .inhalt { border-radius: 0 0 var(--ecke) var(--ecke); }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
@media (hover: none) { .mail span { transform: none !important; } }
@media (max-width: 760px) {
  :root { --fuss-hoehe: min(84svh, 40rem); }
  .inhalt { padding-top: 96px; }
  .werte { font-size: 9.5px; letter-spacing: .1em; }
  .feld { left: 14px; right: 14px; top: 10px; }
  .modi button { flex: 1; padding: 0 6px; }
  .leiste nav a:not(:last-child) { display: none; }
  .arbeit:nth-child(n) { grid-column: 1 / -1; }
  .arbeit:nth-child(1) .arbeit__bild img, .arbeit:nth-child(4) .arbeit__bild img { aspect-ratio: 4 / 3; }   /* Hochformate behalten 4:5, sonst wird das Signet beschnitten */
  .fuss__ruf, .fuss__spalten { grid-column: 1 / -1; }
  .fuss__spalten { padding-top: 0; }
  .spalte { font-size: 14px; }
  .fuss__fein nav { gap: 14px; }
  .wortmarke { font-size: min(33vw, 30svh); }
}
@media (max-width: 760px) and (max-height: 700px) { .fuss__spalten .spalte:nth-child(n+3) { display: none; } }
</style>
</head>
<body>
<div class="feld mono" aria-label="Demo-Steuerung">
  <div class="modi" role="group" aria-label="Variante">
    <button type="button" data-v="sticky" aria-pressed="false">Sticky</button>
    <button type="button" data-v="parallax" aria-pressed="true">Parallax</button>
    <button type="button" data-v="woelbung" aria-pressed="false">Wölbung</button>
  </div>
  <div class="werte"><span data-werte><b>yPercent −25 → 0</b> · Overlay .5 → 0 · linear</span><output data-stand>frei 0 %</output></div>
</div>

<div class="seite">
  <main class="inhalt">
    <header class="leiste">
      <a class="leiste__marke" href="#">Studio Stoll</a>
      <nav class="mono" aria-label="Hauptnavigation"><a href="#">Arbeiten</a><a href="#">Studio</a><a href="#kontakt">Kontakt</a></nav>
    </header>

    <section class="held">
      <h1>Erscheinungsbilder mit <em>Haltung</em> für Handwerk, Gastronomie und Kultur.</h1>
      <div class="held__fuss mono"><span>Markenbüro · Mannheim · seit 2014</span><span>Ausgewählte Arbeiten 2023–2026</span></div>
    </section>

    <section class="arbeiten" aria-label="Ausgewählte Arbeiten">
      <figure class="arbeit">
        <div class="arbeit__bild"><img src="bilder/marke-krume.jpg" alt="Wortmarke krume in fetter dunkelbrauner Schrift auf Sonnengelb, darüber ein Brotlaib" width="1200" height="800"></div>
        <figcaption class="mono"><span><b>Krume Backstube</b> · Logo, Tüten</span><span>2025</span></figcaption>
      </figure>
      <figure class="arbeit">
        <div class="arbeit__bild"><img src="bilder/marke-roestkontor-hoch.jpg" alt="Wortmarke Röstkontor in breiten weißen Versalien auf Ziegelrot, darüber eine Kaffeebohne" width="800" height="1000"></div>
        <figcaption class="mono"><span><b>Röstkontor</b> · Marke</span><span>2023</span></figcaption>
      </figure>
      <figure class="arbeit">
        <div class="arbeit__bild"><img src="bilder/marke-eselsohr-hoch.jpg" alt="Wortmarke Eselsohr in heller Kursive auf Anthrazit, darüber ein Blatt mit umgeknickter rosa Ecke" width="800" height="1000"></div>
        <figcaption class="mono"><span><b>Eselsohr</b> · Marke</span><span>2024</span></figcaption>
      </figure>
      <figure class="arbeit">
        <div class="arbeit__bild"><img src="bilder/typo-plakat.jpg" alt="Ausstellungsplakat in Neongelb: großes schwarzes LICHT, ein orangefarbener Kreis und die Kursive und was es übrig lässt" width="1200" height="800"></div>
        <figcaption class="mono"><span><b>Halle 4</b> · Plakat</span><span>2025</span></figcaption>
      </figure>
    </section>

    <section class="schluss">
      <p class="satz">Wir gestalten wenige Marken im Jahr. Dafür <em>gründlich</em>, von der ersten Skizze bis zum Ladenschild.</p>
      <div class="schluss__zeile mono"><span><i class="punkt" aria-hidden="true"></i>Zwei Plätze frei ab Januar 2027</span><span>Weiter ↓</span></div>
    </section>
  </main>

  <footer class="fuss" id="kontakt">
    <div class="fuss__innen">
      <div class="fuss__oben">
        <div class="fuss__ruf">
          <p class="mono">Neues Projekt</p>
          <h2>Erzählen Sie uns von Ihrer <em>Marke.</em></h2>
          <a class="mail" href="mailto:hallo@studio-stoll.de">hallo@studio-stoll.de <span aria-hidden="true">→</span></a>
        </div>
        <div class="fuss__spalten">
          <div class="spalte"><p class="mono">Studio</p><p>Hafenstraße 12<br>68159 Mannheim</p></div>
          <div class="spalte"><p class="mono">Telefon</p><a href="tel:+496214380120">0621 438 01 20</a></div>
          <div class="spalte"><p class="mono">Seiten</p><a href="#">Arbeiten</a><a href="#">Studio</a><a href="#">Journal</a></div>
          <div class="spalte"><p class="mono">Termine</p><p>Mo–Do<br>9 bis 17 Uhr</p></div>
        </div>
      </div>
      <div class="fuss__unten">
        <div class="fuss__fein mono"><span>© 2026 Studio Stoll</span><nav aria-label="Rechtliches"><a href="#">Impressum</a><a href="#">Datenschutz</a><a href="#" data-hoch>Nach oben ↑</a></nav></div>
        <p class="wortmarke" aria-hidden="true">Stoll<sup>®</sup></p>
      </div>
    </div>
    <div class="fuss__dunkel" aria-hidden="true"></div>
  </footer>
</div>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const inhalt = document.querySelector('.inhalt');
  const fuss = document.querySelector('.fuss');
  const innen = fuss.querySelector('.fuss__innen');
  const dunkel = fuss.querySelector('.fuss__dunkel');
  const knoepfe = [...document.querySelectorAll('[data-v]')];
  const anzeigeWerte = document.querySelector('[data-werte]');
  const anzeigeStand = document.querySelector('[data-stand]');
  const hatGsap = !!(window.gsap && window.ScrollTrigger);
  if (hatGsap) gsap.registerPlugin(ScrollTrigger);

  const VARIANTEN = ['parallax', 'woelbung', 'sticky'];
  const WERTE = {
    sticky:   '<b>sticky · bottom 0</b> · z-index 0 · Ecken 36px',
    parallax: '<b>yPercent −25 → 0</b> · Overlay .5 → 0 · linear',
    woelbung: '<b>Bogen 9vw → 0</b> · sticky · linear',
  };
  const WERTE_REDUZIERT = {
    sticky:   '<b>sticky · bottom 0</b> · ohne Animation',
    parallax: '<b>Footer im Fluss</b> · Parallaxe aus',
    woelbung: '<b>sticky · Ecken 36px</b> · Bogen aus',
  };
  let variante = 'parallax';
  let zeitleiste = null;

  // Parallax wie Huge/Osmo: Footer-Inhalt kommt aus yPercent -25, Overlay .5 → 0, beides linear an den Scroll gekoppelt
  function parallaxAn() {
    if (!hatGsap || reduziert()) return;
    zeitleiste = gsap.timeline({
      scrollTrigger: { trigger: fuss, start: 'clamp(top bottom)', end: 'clamp(top top)', scrub: true }
    })
      .from(innen, { yPercent: -25, ease: 'linear' })
      .from(dunkel, { opacity: 0.5, ease: 'linear' }, '<');
  }
  function parallaxAus() {
    if (!zeitleiste) return;
    zeitleiste.scrollTrigger.kill(); zeitleiste.kill(); zeitleiste = null;
    gsap.set([innen, dunkel], { clearProps: 'all' });
  }

  function setzen(neu) {
    variante = neu;
    parallaxAus();
    VARIANTEN.forEach(v => html.classList.toggle('v-' + v, v === variante));
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === variante)));
    anzeigeWerte.innerHTML = (reduziert() ? WERTE_REDUZIERT : WERTE)[variante];
    if (variante === 'parallax') parallaxAn();
    if (hatGsap) ScrollTrigger.refresh();
    messen();
  }

  // Freilegung 0 → 1 über die Footerhöhe (gleicher Bereich wie clamp(top bottom) → clamp(top top))
  function messen() {
    const max = html.scrollHeight - innerHeight;
    const h = fuss.offsetHeight;
    const p = Math.min(1, Math.max(0, (scrollY - (max - h)) / h)) || 0;
    html.style.setProperty('--p', p.toFixed(4));
    anzeigeStand.textContent = 'frei ' + Math.round(p * 100) + ' %';
  }
  addEventListener('scroll', messen, { passive: true });
  addEventListener('resize', messen);

  knoepfe.forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.v); }));

  // Barrierefreiheit: fokussierte Footer-Links liegen sonst unter dem Inhalt
  fuss.addEventListener('focusin', () => {
    const max = html.scrollHeight - innerHeight;
    if (scrollY < max - 2) scrollTo({ top: max, behavior: reduziert() ? 'auto' : 'smooth' });
  });
  document.querySelector('[data-hoch]').addEventListener('click', e => { e.preventDefault(); pause(); scrollTo({ top: 0, behavior: reduziert() ? 'auto' : 'smooth' }); });

  setzen(variante);
  // .reduziert kann später kommen (?reduziert, Schalter der Detailseite): dann neu aufbauen
  let warReduziert = reduziert();
  new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); setzen(variante); } })
    .observe(html, { attributes: true, attributeFilter: ['class'] });

  // Vorführung: Seite scrollt ans Ende, legt den Footer frei, fährt zurück; jede Runde die nächste Variante
  const RUNDE = 7200;
  let uhr = null, t0 = 0, vorher = 0, runde = 0, sprung = null;
  const weich = x => 0.5 - 0.5 * Math.cos(Math.PI * Math.min(1, Math.max(0, x)));
  function positionen() {
    const max = html.scrollHeight - innerHeight;
    return { a: Math.max(0, max - fuss.offsetHeight - innerHeight * 0.3), b: max };
  }
  function vorfuehren() {
    pause();
    runde = VARIANTEN.indexOf(variante);
    const { a } = positionen();
    scrollTo(0, a);
    if (reduziert()) {   // ohne weiches Scrollen: zwischen beiden Zuständen springen
      let unten = false;
      const takt = () => {
        const p = positionen();
        unten = !unten;
        if (!unten) { runde = (runde + 1) % VARIANTEN.length; setzen(VARIANTEN[runde]); }
        scrollTo(0, unten ? p.b : p.a);
        sprung = setTimeout(takt, 2400);
      };
      sprung = setTimeout(takt, 1200);
      return;
    }
    t0 = performance.now(); vorher = 0;
    const schritt = t => {
      const q = (Math.max(0, t - t0) % RUNDE) / RUNDE;
      if (q < vorher) { runde = (runde + 1) % VARIANTEN.length; setzen(VARIANTEN[runde]); }
      vorher = q;
      const { a, b } = positionen();
      // 0–8 % halten · 8–52 % hinunter · 52–78 % halten · 78–100 % zurück
      const k = q < 0.52 ? weich((q - 0.08) / 0.44) : 1 - weich((q - 0.78) / 0.22);
      scrollTo(0, a + (b - a) * k);
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; clearTimeout(sprung); sprung = null; }
  function neustart() { pause(); scrollTo(0, 0); setzen(variante); }

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