Zum Inhalt
← Hero & Section-Layouts

Hero-Komposition nach Seitenverhältnis

Ein Hotel-Hero in einem frei ziehbaren Rahmen: Bildausschnitt und Textlage gleiten stufenlos mit dem Seitenverhältnis, unterhalb von 22rem Höhe steht der Text unter dem Bild.

Foto-Hero mit festem Textblock unten links (Hotel, Gastro): nur der Ausschnitt folgt dem Format, das ist die sicherste Form.

Der Hero kennt kein Quer- und Hochformat-Umschalten. Textblock, Bildausschnitt und Lage der Headline gleiten stufenlos mit dem Seitenverhältnis des Bildschirms. Im Querformat steht der Text links neben dem Motiv, im Hochformat rutscht er Schritt für Schritt nach unten. Der Bildausschnitt wandert mit, sodass das Motiv nie unter dem Text liegt.

Foto-Heros brechen sonst an Breakpoints um und schneiden das Motiv falsch an. Hier bleibt die Bildregie in jedem Format erhalten, auch auf Tablets und geteilten Fenstern.

Wann einsetzen

Foto-Heros mit klarem Motiv: Hotel, Gastro, Mode, Kreativstudio. Wenn ein Bild das Format trägt.

Wann nicht

Nicht bei Text-Heros ohne Bild. Nicht, wenn das Rezept ohnehin zwei feste Bildschnitte liefert (art direction per picture ist dann einfacher). Nicht als Experiment auf Kundenseiten ohne Test aller Zwischenzustände.

Varianten

  • objektposition: nur object-position folgt dem Verhältnis
  • textlage: Textblock wandert zusätzlich

Umsetzung

  1. Hero mit container-type: size und fester Höhe (z. B. 100svh).
  2. --r: calc(100cqw / 100cqh) als Seitenverhältnis. --t: clamp(0, (var(--r) - 0.6) / 1.2, 1) als Grad zwischen Hoch- und Querformat.
  3. object-position des Bildes und inset bzw. Grid-Position des Textblocks über --t interpolieren.
  4. Mindestzustand für sehr kleine Höhen (Zoom): Text unter dem Bild, kein Überlagern.
  5. Fallback: zwei Zustände per @media (orientation).

Robust umsetzen

Telefon. Hochformat ist der Grundzustand. Text unter dem Motiv.

Ohne Bewegung. Keine Zeitbewegung.

Barrierefrei. Text-Kontrast über dem Bild in jedem Zwischenzustand prüfen (deckende Fläche hinter dem Text). Bei 200 % und 400 % Zoom greift der Mindestzustand. Keine Bewegung über 5 s.

Code

<!DOCTYPE html>
<!--
  Hero-Komposition nach Seitenverhältnis
  Fundus · Hero & Section-Layouts · https://cecodesign.de/lab/fundus/hero-layouts/hero-seitenverhaeltnis-fliessend/
  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>Hero-Komposition nach Seitenverhältnis · 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-display: swap; }
@font-face { font-family: 'Newsreader'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; 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; }

/* Werkbank dunkel; im Rahmen Rezept boutique-hotel-gastgeber */
:root {
  --bank: #141312; --hell: #efebe3; --leise: rgba(239, 235, 227, .68); --linie: rgba(239, 235, 227, .16); --akzent: #d9a26b;
  --grund: #F3EEE7; --creme: #FEF2E3; --text: #3E3A37; --linie-r: #D5D1C8; --meta: #6A6C67; --haus: #6E5A47;
  --ui: 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--bank); color: var(--hell); font-family: 'Geist Mono', ui-monospace, monospace; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(14px, 3vw, 40px); }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }

/* ——— Pult ——— */
.pult { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding-bottom: 14px; color: var(--leise); }
.wahl { display: flex; flex-wrap: wrap; gap: 3px; padding: 3px; border: 1px solid var(--linie); border-radius: 999px; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 0; border-radius: 999px; padding: 0 11px; min-height: 32px; cursor: pointer; transition: color var(--ui), background-color var(--ui); }
.pult button:hover { color: var(--hell); }
.pult button[aria-pressed="true"] { background: var(--hell); color: var(--bank); }
.regler { display: flex; align-items: center; gap: 8px; }
.regler input { width: clamp(90px, 11vw, 160px); accent-color: var(--akzent); }
.regler output { min-width: 4.4em; color: var(--hell); font-variant-numeric: tabular-nums; }
.kopf { display: flex; flex-wrap: wrap; gap: 8px 26px; margin-bottom: 10px; color: var(--leise); }
.kopf b { color: var(--hell); font-weight: 500; font-variant-numeric: tabular-nums; }
.kopf .t b { color: var(--akzent); }
.stand i { font-style: normal; color: #8fd0a4; }
.stand i.nein { color: #f0a383; }
/* Skala unter dem Kopf: wo steht --t zwischen Hoch (0) und Quer (1) */
.skala { position: relative; height: 4px; max-width: 420px; margin: 4px 0 34px; background: var(--linie); border-radius: 2px; }
.skala i { position: absolute; top: -4px; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: var(--akzent); left: calc(var(--zeiger, 1) * 100%); }
.skala span { position: absolute; top: 10px; color: var(--leise); }
.skala span:last-child { right: 0; }

/* ——— Bühne: Rahmen in Breite und Höhe ziehbar ——— */
.buehne { position: relative; }
.halter { position: relative; width: max-content; transform-origin: 0 0; }
.rahmen { container: held / size; position: relative; width: 1200px; height: 640px; min-width: 300px; min-height: 260px; background: var(--grund); color: var(--text); overflow: clip; border-radius: 3px; box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .8); font: 400 1.0625rem/1.6 'Mona Sans', system-ui, sans-serif; }
.griff { position: absolute; right: -14px; bottom: -14px; z-index: 3; width: 28px; height: 28px; border-radius: 50%; background: var(--hell); cursor: nwse-resize; touch-action: none; box-shadow: 0 0 0 4px var(--bank); transition: scale var(--ui), background-color var(--ui); }
.griff::before { content: ""; position: absolute; inset: 8px; border-right: 2px solid var(--bank); border-bottom: 2px solid var(--bank); }
.griff::after { content: ""; position: absolute; inset: -10px; }
.griff:hover, .griff.zieht, .vorfuehrung .griff { background: var(--akzent); scale: 1.12; }

/* =====================================================================
   DAS MUSTER: Seitenverhältnis des Containers als Zahl
   --r = 100cqw / 100cqh (Länge durch Länge, typisierte Arithmetik)
   --t = 0 im Hochformat (r ≤ 0,6) … 1 im Querformat (r ≥ 1,8)
   ===================================================================== */
@property --t { syntax: '<number>'; inherits: true; initial-value: 1; }
.held { --t: 1; position: absolute; inset: 0; --rand: clamp(16px, 4cqw, 48px); }
@supports (width: calc(100cqw / 100cqh * 1px)) {
  :root:not(.rueckfall) .held { --r: calc(100cqw / 100cqh); --t: clamp(0, (var(--r) - 0.6) / 1.2, 1); }
}
/* Rückfall: zwei Zustände nach Ausrichtung des Containers (auf echten Seiten @media (orientation)) */
@container held (orientation: portrait) { :root.rueckfall .held { --t: 0; } }
@supports not (width: calc(100cqw / 100cqh * 1px)) {
  @container held (orientation: portrait) { .held { --t: 0; } }
}

.held-bild { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  /* Variante objektposition: nur der Ausschnitt folgt. Haus liegt bei x ≈ 68 % des Bildes */
  object-position: calc(80% - 30% * var(--t)) calc(58% - 6% * var(--t));
  /* Mittelfeld (fast quadratisch): Bild leicht vergrößern, Drehpunkt unten, damit das Haus über den Text rückt */
  scale: calc(1 + 0.25 * sin(var(--t) * 180deg)); transform-origin: 72% 100%; }
[data-v="textlage"] .held-bild { scale: calc(1 + 0.18 * (1 - var(--t))); transform-origin: 72% 100%; }
.held-nav { position: absolute; inset: 0 0 auto; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px var(--rand); color: #2b2725; background: linear-gradient(rgba(243, 238, 231, .6), rgba(243, 238, 231, 0)); }
/* Oben liegt in jedem Ausschnitt heller Abendhimmel: dunkle Schrift, dazu ein Hauch Sand als Sicherheit */
.held-nav .marke { font: 400 1.5rem/1 'Newsreader', Georgia, serif; font-style: italic; }
.held-nav .links { display: flex; align-items: center; gap: 22px; }
.label { font: 600 .75rem/1.3 'Mona Sans', system-ui, sans-serif; font-stretch: 118%; letter-spacing: .06em; text-transform: uppercase; }
.anfragen { display: inline-flex; align-items: center; min-height: 44px; padding: 0 18px; border-radius: 2px; background: var(--haus); color: #fff; text-decoration: none; }
.held-nav .anfragen { min-height: 36px; background: rgba(254, 242, 227, .92); color: var(--text); }

.held-text {
  position: absolute; z-index: 2; left: var(--rand); bottom: var(--rand);
  width: min(30rem, 100% - 2 * var(--rand));
  padding: clamp(18px, 3cqmin, 32px); background: var(--creme); color: var(--text);
}
.held-text h1 { font: 350 clamp(2rem, 1rem + 4.2cqmin, 4.25rem)/1.04 'Newsreader', Georgia, serif; font-variation-settings: "opsz" 72; letter-spacing: -.015em; margin: 10px 0 12px; text-wrap: balance; }
.held-text h1 em { font-style: italic; }
.held-text p.lead { max-width: 36ch; font-size: clamp(.9375rem, .8rem + .6cqmin, 1.0625rem); }
.held-text a.anfragen { margin-top: 18px; color: #fff; }
.held-text .label { color: var(--meta); }

/* Variante textlage: der Textblock wandert mit --t, das Bild weicht nach oben aus */
[data-v="textlage"] .held-bild { height: calc(100% - (1 - var(--t)) * 30%); object-position: 78% 50%; }
[data-v="textlage"] .held-text {
  bottom: auto;
  top: calc(var(--t) * 50% + (1 - var(--t)) * (100% - var(--rand)));
  translate: 0 calc(var(--t) * -50% + (1 - var(--t)) * -100%);
  width: calc(var(--t) * min(30rem, 40%) + (1 - var(--t)) * (100% - 2 * var(--rand)));
  left: calc(var(--rand) + var(--t) * 2cqw);
}

@container held (width < 46rem) { .held-nav .links span { display: none; } }

/* Mindestzustand für kleine Höhen (Zoom, Telefon quer): nichts überlagert, Text unter dem Bild */
@container held (height < 22rem) {
  .held { overflow-y: auto; display: flex; flex-direction: column; }
  .held-nav { position: static; order: -1; background: var(--text); color: #fbf6ee; }
  .held-bild, [data-v="textlage"] .held-bild { position: static; inset: auto; flex: none; height: 60cqh; width: 100%; object-position: 70% 55%; scale: none; }
  .held-text, [data-v="textlage"] .held-text { position: static; translate: none; width: auto; margin: 0; flex: none; }
  .held-text h1 { font-size: 2rem; }
}

/* Werte unter der Bühne */
.werte { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); gap: 6px 32px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--linie); color: var(--leise); font: 400 11.5px/1.6 'Geist Mono', ui-monospace, monospace; }
.werte div { display: flex; gap: 12px; min-width: 0; }
.werte dt { color: var(--hell); flex: none; min-width: 8.5em; }
.werte dd { overflow-wrap: anywhere; }

.vorschau body { overflow: hidden; padding: 22px 26px; }
.vorschau .pult, .vorschau .werte, .vorschau .kopf .hilfe { display: none; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (hover: none) { .griff:hover { background: var(--hell); scale: 1; } }
@media (max-width: 640px) {
  .wahl { width: 100%; }
  .wahl button { flex: 1; padding: 0 4px; letter-spacing: .05em; }
  .regler { width: 100%; }
  .regler input { flex: 1; width: auto; }
  .werte dt { min-width: 7em; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .held-text { border: 1px solid CanvasText; }
}
</style>
</head>
<body>
<div class="pult mono">
  <div class="wahl" role="group" aria-label="Variante">
    <button type="button" data-v-wahl="objektposition" aria-pressed="true">Objektposition</button>
    <button type="button" data-v-wahl="textlage" aria-pressed="false">Textlage</button>
  </div>
  <div class="wahl" role="group" aria-label="Format">
    <button type="button" data-format="1.78" aria-pressed="false">16 : 9</button>
    <button type="button" data-format="1.33" aria-pressed="false">4 : 3</button>
    <button type="button" data-format="1" aria-pressed="false">1 : 1</button>
    <button type="button" data-format="0.56" aria-pressed="false">9 : 16</button>
    <button type="button" data-format="flach" aria-pressed="false">Flach</button>
  </div>
  <label class="regler">B <input type="range" min="300" max="1240" step="1" value="1200" data-b aria-label="Breite des Rahmens in Pixel"><output data-b-aus>1200</output></label>
  <label class="regler">H <input type="range" min="260" max="900" step="1" value="640" data-h aria-label="Höhe des Rahmens in Pixel"><output data-h-aus>640</output></label>
  <button type="button" data-rueckfall aria-pressed="false" style="border: 1px solid var(--linie)">Rückfall: zwei Zustände</button>
</div>
<div class="kopf mono">
  <span>Rahmen <b data-masse>1200 × 640</b></span>
  <span>Verhältnis <b data-r>1,88</b></span>
  <span class="t">--t <b data-t>1,00</b></span>
  <span class="stand" data-stand></span>
  <span class="hilfe">Ecke unten rechts ziehen</span>
</div>
<div class="skala mono" aria-hidden="true" data-skala><i></i><span>Hoch 0</span><span>Quer 1</span></div>

<div class="buehne" data-buehne>
  <div class="halter" data-halter>
    <div class="rahmen" data-rahmen>
      <section class="held" aria-labelledby="h1">
        <img class="held-bild" src="bilder/hotel-haus-am-see.webp" alt="Backsteinhaus mit erleuchteten Fenstern am See in der Abenddämmerung, links eine kleine Holzsauna am Steg" width="1920" height="1200">
        <header class="held-nav">
          <span class="marke">Uferlicht</span>
          <nav class="links label" aria-label="Beispielnavigation Gästehaus"><span>Zimmer</span><span>Sauna</span><span>Anreise</span><a class="anfragen" href="#h1">Anfragen</a></nav>
        </header>
        <div class="held-text">
          <p class="label">Gästehaus am Großen Plöner See</p>
          <h1 id="h1">Zwölf Zimmer, <em>ein</em> Steg.</h1>
          <p class="lead">Backsteinhaus von 1894, Fasssauna am Wasser, Frühstück bis elf. Ab 128 € die Nacht für zwei.</p>
          <a class="anfragen label" href="#h1">Zimmer anfragen</a>
        </div>
      </section>
    </div>
    <div class="griff" data-griff role="presentation" title="Ziehen, um Breite und Höhe zu ändern"></div>
  </div>
</div>

<dl class="werte" data-werte></dl>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
  const buehne = $('[data-buehne]'), halter = $('[data-halter]'), rahmen = $('[data-rahmen]'), griff = $('[data-griff]'), held = $('.held');
  const bR = $('[data-b]'), hR = $('[data-h]'), bA = $('[data-b-aus]'), hA = $('[data-h-aus]');
  const masse = $('[data-masse]'), rT = $('[data-r]'), tT = $('[data-t]'), stand = $('[data-stand]'), skala = $('[data-skala]'), werteListe = $('[data-werte]');
  const rueckKnopf = $('[data-rueckfall]');
  const kannTeilen = CSS.supports('width', 'calc(100cqw / 100cqh * 1px)');
  const MINB = 300, MAXB = 1240, MINH = 260, MAXH = 900;

  const IDS = ['objektposition', 'textlage'];
  const GEMEINSAM = [
    ['Container', '.rahmen { container: held / size } · Höhe muss feststehen (auf Seiten z. B. 100svh)'],
    ['Verhältnis', '--r: calc(100cqw / 100cqh) · Länge durch Länge = Zahl'],
    ['Grad', '@property --t &lt;number&gt;; --t: clamp(0, (var(--r) - 0.6) / 1.2, 1) · 0 = Hochformat 0,6, 1 = Querformat 1,8'],
    ['Mindestzustand', '@container held (height < 22rem): Bild 60cqh oben, Text darunter, Bereich scrollt'],
    ['Rückfall', '@container held (orientation: portrait) → --t: 0, sonst 1 (auf Seiten: @media (orientation))'],
    ['Textfläche', 'deckend #FEF2E3, Text #3E3A37 (11 : 1), Ränder clamp(16px, 4cqw, 48px)'],
    ['Herkunft', 'eigene Ableitung aus der Plattform-Funktion, kein Fremdbeleg']
  ];
  const WERTE = {
    objektposition: [
      ['Ausschnitt', 'object-position: calc(80% - 30% × t) calc(58% - 6% × t) · Hochformat zeigt das Haus (x ≈ 68 % im Bild)'],
      ['Mittelfeld', 'scale: calc(1 + 0.25 × sin(t × 180deg)), transform-origin 72% 100% · bei t ≈ 0,5 wächst das Bild um bis zu 25 % nach oben, das Haus rückt über den Text'],
      ['Text', 'fest unten links, Breite min(30rem, 100% - 2 × Rand)']
    ],
    textlage: [
      ['Bildfläche', 'height: calc(100% - (1 - t) × 30%), scale: calc(1 + 0.18 × (1 - t)) vom unteren Rand (72% 100%) · im Hochformat weicht das Bild nach oben aus, object-position 78% 50%'],
      ['Text oben', 'top: calc(t × 50% + (1 - t) × (100% - Rand)); translate: 0 calc(t × -50% + (1 - t) × -100%) · im Hochformat steht der Text unten und greift ins Bild, wird aber nie abgeschnitten'],
      ['Text Breite', 'calc(t × min(30rem, 40%) + (1 - t) × (100% - 2 × Rand))'],
      ['Text links', 'calc(Rand + t × 2cqw)']
    ]
  };

  let v = new URLSearchParams(location.search).get('v');
  if (!IDS.includes(v)) v = IDS[0];
  let b = 1200, h = 640, massstab = 1;

  function platz() { return buehne.clientWidth - 22; }
  function grenzeB() { const p = platz(); return p >= 700 ? Math.max(MINB, Math.min(MAXB, p)) : MAXB; }
  function messen() {
    const r = b / h;
    let t = parseFloat(getComputedStyle(held).getPropertyValue('--t'));   // so, wie CSS ihn gerechnet hat
    if (isNaN(t)) t = 1;
    rT.textContent = r.toFixed(2).replace('.', ',');
    tT.textContent = t.toFixed(2).replace('.', ',');
    skala.style.setProperty('--zeiger', t);
    masse.textContent = `${b} × ${h}`;
  }
  function setze(nb, nh) {
    b = Math.round(Math.max(MINB, Math.min(grenzeB(), nb)));
    h = Math.round(Math.max(MINH, Math.min(MAXH, nh)));
    rahmen.style.width = b + 'px'; rahmen.style.height = h + 'px';
    massstab = Math.min(1, platz() / b);
    halter.style.transform = massstab < 1 ? `scale(${massstab})` : '';
    halter.style.height = massstab < 1 ? h * massstab + 'px' : '';
    bR.max = grenzeB(); bR.value = b; hR.value = h; bA.textContent = b; hA.textContent = h;
    $$('[data-format]').forEach(k => k.setAttribute('aria-pressed', 'false'));
    messen();
  }
  function format(f) {
    const pb = Math.min(grenzeB(), platz() >= 700 ? grenzeB() : platz());
    if (f === 'flach') return setze(Math.min(pb, 900), 300);
    const r = +f;
    // möglichst groß, Höhe höchstens 720
    let nh = Math.min(720, pb / r), nb = nh * r;
    setze(nb, nh);
  }
  $$('[data-format]').forEach(k => k.addEventListener('click', () => { stopp(); format(k.dataset.format); k.setAttribute('aria-pressed', 'true'); }));
  bR.addEventListener('input', () => { stopp(); setze(+bR.value, h); });
  hR.addEventListener('input', () => { stopp(); setze(b, +hR.value); });

  function wahl(neu, url) {
    v = neu;
    rahmen.dataset.v = neu;
    $$('[data-v-wahl]').forEach(k => k.setAttribute('aria-pressed', String(k.dataset.vWahl === neu)));
    if (url) {
      const q = new URLSearchParams(location.search); q.set('v', neu);
      history.replaceState(null, '', location.pathname + '?' + q.toString().replace(/=(?=&|$)/g, '') + location.hash);
    }
    werteListe.innerHTML = [...WERTE[neu], ...GEMEINSAM].map(([a, c]) => `<div><dt>${a}</dt><dd>${c}</dd></div>`).join('');
    messen();
  }
  $$('[data-v-wahl]').forEach(k => k.addEventListener('click', () => { stopp(); wahl(k.dataset.vWahl, true); }));
  function zeigeStand() {
    const rueck = html.classList.contains('rueckfall');
    stand.innerHTML = kannTeilen ? (rueck ? 'Rückfall: <i class="nein">zwei Zustände</i>' : 'Länge ÷ Länge: <i>läuft hier</i>') : 'Länge ÷ Länge: <i class="nein">fehlt</i> · zwei Zustände';
  }
  rueckKnopf.addEventListener('click', () => {
    const an = !html.classList.contains('rueckfall');
    html.classList.toggle('rueckfall', an);
    rueckKnopf.setAttribute('aria-pressed', String(an));
    zeigeStand(); messen();
  });
  if (!kannTeilen) { rueckKnopf.disabled = true; rueckKnopf.textContent = 'Rückfall aktiv'; }

  let zug = null;
  griff.addEventListener('pointerdown', e => { stopp(); zug = { x: e.clientX, y: e.clientY, b, h }; griff.setPointerCapture(e.pointerId); griff.classList.add('zieht'); });
  griff.addEventListener('pointermove', e => { if (zug) setze(zug.b + (e.clientX - zug.x) / massstab, zug.h + (e.clientY - zug.y) / massstab); });
  const los = () => { zug = null; griff.classList.remove('zieht'); };
  griff.addEventListener('pointerup', los); griff.addEventListener('pointercancel', los);
  new ResizeObserver(() => setze(b, h)).observe(buehne);
  rahmen.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });

  // Vorführung: Quer → Quadrat → Hoch → zurück, Breite und Höhe gleiten gegeneinander
  let uhr = null, tick = null, start = 0, versatz = 0;
  const RUNDE = 10000;
  function stopp() { cancelAnimationFrame(uhr); clearTimeout(tick); uhr = tick = null; html.classList.remove('vorfuehrung'); }
  function grund() { if (platz() < 700) setze(platz(), Math.min(640, Math.round(platz() * 1.6))); else setze(grenzeB(), 620); }
  function neustart() { stopp(); versatz = 0; grund(); }
  function vorfuehren() {
    stopp();
    html.classList.add('vorfuehrung');
    const pb = platz() >= 700 ? grenzeB() : platz();
    const quer = [pb, Math.min(620, pb / 1.9)], hoch = [Math.min(pb, 400), 680];
    if (reduziert()) {
      const stufen = ['1.78', '1', '0.56', '1.33']; let i = 0;
      const schalten = () => { format(stufen[i++ % stufen.length]); tick = setTimeout(schalten, 2200); };
      schalten();
      return;
    }
    start = performance.now() - versatz;
    const schritt = t => {
      versatz = (t - start) % RUNDE;
      const p = .5 - .5 * Math.cos(versatz / RUNDE * 2 * Math.PI);   // 0 → 1 → 0
      setze(quer[0] + (hoch[0] - quer[0]) * p, quer[1] + (hoch[1] - quer[1]) * p);
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); clearTimeout(tick); uhr = tick = null; }
  function weiter() { vorfuehren(); }

  zeigeStand();
  wahl(v, false);
  grund();

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