Zum Inhalt
← Galerien & Slider

3D-Karussell / Bildzylinder (Scroll- und Drag-gesteuert)

Zwölf Bilder auf einem CSS-3D-Zylinder: dreht sich langsam von selbst, Scrollen beschleunigt, Ziehen wirft ihn mit Trägheit an.

Bilder sitzen auf einem Zylinder. Jedes Panel bekommt rotateY(i × 360° / n) und wird um den Radius nach vorn verschoben. Gedreht wird nur der Ring, mit perspective auf der Bühne und transform-style preserve-3d. Eine langsame Grundrotation läuft immer (360° in 30 s, linear). Scrollen setzt deren Tempo hoch, danach federt es zurück. Ziehen dreht direkt und wirft den Ring mit Trägheit weiter. Beim Drücken zieht sich jedes Bild leicht zusammen (clip-path inset 5 %). In der Scroll-Variante steuert die Scrollposition Drehung, leichtes Kippen und Helligkeit.

Die Galerie wird zum Objekt mit echter Tiefe, und das ohne WebGL. Weil das Tempo der Scrollgeschwindigkeit folgt, sieht man die eigene Eingabe. Das lädt zum Spielen ein. Ziehen mit Trägheit fühlt sich an wie ein echter Drehteller.

Wann einsetzen

Portfolio- und Kampagnen-Startseiten, Kollektionen, Studio- und Fotografenseiten, Event- und Musikseiten. Überall, wo eine Bildfolge Stimmung machen soll und einzelne Bilder nicht verglichen werden müssen.

Wann nicht

Bei Karten mit Text, denn seitlich verzerrt ist er kaum lesbar. Bei sehr wenigen Bildern oder mehr als etwa 20 Panels. Wenn Nutzer gezielt suchen, filtern oder vergleichen müssen. In nüchternen, ernsten Kontexten.

Varianten

  • Zylinder: CSS 3D, Grundrotation, Tempo aus der Scrollgeschwindigkeit, Ziehen mit Trägheit
  • Scroll-gekoppelt: Drehung, Kippen und Helligkeit hängen an der Scrollposition (scrub)
  • Federnde Spreizung und Neigung je nach Scrollgeschwindigkeit (Steifigkeit 120, Dämpfung 14)
  • Cover Flow: waagerechte Leiste mit scroll-snap, Drehung um die Mitte per animation-timeline: view(inline)
  • Hintergrundverlauf aus der Farbe des vorderen Bildes
  • WebGL: Bildröhre, durch die man hindurchscrollt
  • WebGL: welliges Endloskarussell, dessen Bänder sich mit dem Tempo per Vertex-Shader biegen
  • WebGL: gekrümmtes Produkt-Grid

Umsetzung

Ein Wrapper dreht sich, nicht jedes Panel einzeln. Radius aus Panelbreite und Anzahl: r = (w / 2) / tan(π / n), mit Luft zwischen den Panels auch größer (Osmo: r = 50vw, perspective 90vw). Rückseiten entweder ausblenden (backface-visibility hidden) oder sichtbar lassen und abdunkeln. Grundrotation, Scroll-Tempo und Ziehen in einem gemeinsamen Winkelwert zusammenführen und in einem Takt (requestAnimationFrame oder gsap.ticker) schreiben. Federn mit begrenztem Zeitschritt (höchstens 1/30 s) rechnen. Resize entprellen (200 ms), dann neu messen und ScrollTrigger.refresh(). Fallstrick Safari: overflow, filter oder clip-path auf einem Element innerhalb der preserve-3d-Kette macht den Zylinder flach, also nur auf Blattelementen oder außerhalb einsetzen. Cover Flow ohne JS: .track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory } .item { scroll-snap-align: center; animation: flow linear both; animation-timeline: view(inline) } @keyframes flow { 0% { transform: perspective(900px) rotateY(45deg) scale(.8) } 50% { transform: perspective(900px) rotateY(0) scale(1) } 100% { transform: perspective(900px) rotateY(-45deg) scale(.8) } }. WebGL-Varianten: Plane-Geometrie mit genug Segmenten (etwa 32 × 32) für die Biegung, ein gemeinsamer Bewegungswert speist alle Shader.

Robust umsetzen

Telefon. Ziehen statt Scroll-Kopplung. Kleinerer Radius, größere Panels, senkrechtes Scrollen frei lassen (touch-action: pan-y). Auf schwachen Geräten auf eine flache Wischleiste mit scroll-snap zurückfallen. Cover Flow passt ideal zur nativen Wischgeste.

Ohne Bewegung. Keine Autorotation und keine Trägheit. Bewegungsfaktor stark senken (Codrops: ein Drittel) oder ganz auf eine flache Leiste ohne Drehung wechseln. Navigation über Knöpfe und Pfeiltasten, Sprünge ohne Animation.

Barrierefrei. Panels als Liste von Links mit alt-Text in logischer Reihenfolge. Nur das vordere Bild ist fokussierbar. Knöpfe für vor und zurück mit aria-label, Pfeiltasten auf der Bühne. Die Autorotation lässt sich per Knopf anhalten. Keine Scroll-Falle: Ziehen darf senkrechtes Scrollen nicht blockieren. Den Namen der vorderen Arbeit in einer aria-live-Region ausgeben. Im Cover Flow den scrollbaren Container mit tabindex="0" und aria-label versehen.

Code

<!DOCTYPE html>
<!--
  3D-Karussell / Bildzylinder (Scroll- und Drag-gesteuert)
  Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/3d-karussell-zylinder/
  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>3D-Karussell / Bildzylinder (Scroll- und Drag-gesteuert) · 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: '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: '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; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }

/* Hintergrund-Ton folgt dem vorderen Bild (Variante „reaktiver Verlauf“) */
@property --ton { syntax: '<color>'; inherits: true; initial-value: #2a363a; }

:root {
  --grund: #111110; --text: #f1efe9; --leise: rgba(241, 239, 233, .56); --linie: rgba(241, 239, 233, .14);
  --kurve: cubic-bezier(0.55, 0, 0.1, 1);   /* UI-Kurve aus der Messung (CLOU), 700 ms */
  --raus: cubic-bezier(0.16, 1, 0.3, 1);    /* expo.out für Bildunterschriften */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
:focus-visible { outline: 1.5px solid var(--text); outline-offset: 3px; }

/* ---------- Seite ---------- */
.strecke { height: 320svh; position: relative; }
.buehne {
  position: sticky; top: 0; height: 100svh; overflow: hidden;
  background:
    radial-gradient(60% 46% at 50% 54%, color-mix(in oklab, var(--ton) 40%, transparent), transparent 72%),
    var(--grund);
  transition: --ton 1.2s linear;
}
.kopf {
  position: absolute; inset: 0 0 auto; z-index: 4; display: grid; grid-template-columns: 1fr auto 1fr; align-items: baseline;
  padding: clamp(18px, 3vw, 34px) clamp(18px, 3.4vw, 44px);
}
.marke { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.marke em { font: italic 400 17px/1 'Instrument Serif', serif; letter-spacing: 0; color: var(--leise); }
.kopf .mitte { text-align: center; }
.kopf .rechts { text-align: right; }

/* ---------- Zylinder ---------- */
.zyl { position: absolute; inset: 0; z-index: 1; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
.zyl.zieht { cursor: grabbing; }
.ring {
  position: absolute; left: 50%; top: 45%; width: var(--pw); height: var(--ph);
  margin: calc(var(--ph) / -2) 0 0 calc(var(--pw) / -2); padding: 0; list-style: none;
  transform-style: preserve-3d; will-change: transform;
}
.panel {
  position: absolute; inset: 0;
  transform: rotateY(calc(var(--i) * 30deg)) translateZ(var(--r));
  backface-visibility: visible;
}
.karte {
  display: block; width: 100%; height: 100%; border-radius: 5px; overflow: hidden;
  clip-path: inset(0% round 5px); -webkit-user-drag: none; outline-offset: 6px;
}
.karte img { display: block; width: 100%; height: 100%; object-fit: cover; filter: brightness(var(--licht, 1)); pointer-events: none; }
.boden {
  position: absolute; left: 50%; top: calc(45% + var(--ph) / 2 + 18px); width: calc(var(--r) * 1.5); height: 46px;
  translate: -50% 0; border-radius: 50%; pointer-events: none;
  background: radial-gradient(closest-side, rgba(0, 0, 0, .55), transparent);
}

/* ---------- Cover Flow (scroll-driven CSS, JS-Ersatz unten) ---------- */
.flow { position: absolute; inset: 0; z-index: 1; display: none; align-items: center; }
.flow.an { display: flex; }
.track {
  display: flex; gap: clamp(0px, 1vw, 14px); width: 100%; padding: 6vh calc(50% - var(--kw) / 2) 8vh; margin: 0; list-style: none;
  overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; overscroll-behavior-x: contain;
  margin-top: -6vh;
}
.track::-webkit-scrollbar { display: none; }
.blatt { flex: 0 0 var(--kw); scroll-snap-align: center; }
.blatt img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 5px; transform-origin: 50% 50%; }
@keyframes fluss {
  0%   { transform: perspective(900px) rotateY(45deg) scale(.8); filter: brightness(.55); }
  50%  { transform: perspective(900px) rotateY(0deg) scale(1); filter: brightness(1); }
  100% { transform: perspective(900px) rotateY(-45deg) scale(.8); filter: brightness(.55); }
}
@supports (animation-timeline: view()) {
  .blatt img { animation: fluss linear both; animation-timeline: view(inline); }
}

/* ---------- Bildunterschrift und Bedienung ---------- */
.titel {
  position: absolute; z-index: 3; left: clamp(18px, 3.4vw, 44px); bottom: clamp(70px, 9vh, 96px);
  pointer-events: none; max-width: 60vw;
}
.titel .name { font: italic 400 clamp(38px, 5.6vw, 76px)/.95 'Instrument Serif', serif; letter-spacing: -.01em; margin: 8px 0 10px; white-space: nowrap; }
.tempo { position: absolute; z-index: 3; right: clamp(18px, 3.4vw, 44px); bottom: clamp(70px, 9vh, 96px); text-align: right; pointer-events: none; }
.tempo b { display: block; font: italic 400 clamp(30px, 3.6vw, 48px)/1 'Instrument Serif', serif; color: var(--text); letter-spacing: 0; text-transform: none; margin-top: 6px; font-variant-numeric: tabular-nums; }

.leiste {
  position: absolute; z-index: 4; inset: auto 0 0; display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
  padding: 14px clamp(18px, 3.4vw, 44px) clamp(16px, 2.4vw, 24px); border-top: 1px solid var(--linie);
}
.schalter { display: flex; gap: 4px; }
.schalter button, .steuer button { padding: 7px 10px; border: 1px solid var(--linie); border-radius: 999px; transition: background-color .15s linear, color .15s linear, border-color .15s linear; }
.schalter button[aria-pressed="true"] { background: var(--text); color: var(--grund); border-color: var(--text); }
.schalter button:hover, .steuer button:hover { border-color: var(--leise); }
.werte { flex: 1; min-width: 0; color: rgba(241, 239, 233, .42); letter-spacing: .08em; font-size: 10px; }
.steuer { display: flex; gap: 4px; }
.steuer button { width: 34px; height: 30px; display: grid; place-items: center; padding: 0; }
.steuer svg { width: 12px; height: 12px; }
.hinweis { position: absolute; z-index: 3; left: 50%; top: calc(45% + var(--ph) / 2 + 64px); translate: -50% 0; white-space: nowrap; pointer-events: none; transition: opacity .4s linear; }

.fuss { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 40px clamp(18px, 3.4vw, 44px) 48px; border-top: 1px solid var(--linie); }
.fuss p:first-child { font-size: 15px; max-width: 34em; color: var(--leise); }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 44px; height: 44px; pointer-events: none; opacity: 0; transition: opacity .3s linear; }
.zeiger circle { transition: r .3s cubic-bezier(0.16, 1, 0.3, 1); }
.zeiger.an { opacity: 1; }
.rad { position: absolute; z-index: 5; right: clamp(18px, 3.4vw, 44px); top: 50%; translate: 0 -50%; display: grid; justify-items: center; gap: 8px; opacity: 0; transition: opacity .3s linear; pointer-events: none; }
.rad.an { opacity: 1; }
.rad span { width: 1px; height: 64px; background: linear-gradient(var(--text), transparent); animation: rad 1s cubic-bezier(0.16, 1, 0.3, 1) infinite; transform-origin: top; }
@keyframes rad { from { scale: 1 0; } to { scale: 1 1; } }

.vorschau body { overflow: hidden; }
.vorschau .leiste, .vorschau .fuss, .vorschau .hinweis { display: none; }
.vorschau .titel, .vorschau .tempo { bottom: 34px; }

/* Touch: Ziehen statt Scroll-Kopplung */
@media (hover: none) {
  .tempo { display: none; }
  .zyl { cursor: auto; }
}
@media (max-width: 640px) {
  .kopf { grid-template-columns: 1fr auto; }
  .kopf .mitte { display: none; }
  .ring { top: 39%; }
  .boden { top: calc(39% + var(--ph) / 2 + 14px); }
  .hinweis { top: calc(39% + var(--ph) / 2 + 48px); }
  .titel { bottom: 166px; max-width: none; right: 140px; }
  .titel .name { white-space: normal; font-size: 40px; }
  .tempo { display: none; }
  .leiste { gap: 10px; }
  .schalter button { padding: 7px 9px; }
  .werte { flex-basis: 100%; }
  .steuer { position: absolute; right: 18px; bottom: calc(100% + 22px); }
  .vorschau .titel { bottom: 34px; }
}
/* Reduzierte Bewegung: keine Autorotation, kein Tempo, flache Leiste im Cover Flow */
.reduziert .blatt img { animation: none; transform: none; filter: none; }
.reduziert .buehne { transition: none; }
.reduziert .tempo, .reduziert .rad { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="strecke" data-strecke>
  <div class="buehne" data-buehne>
    <header class="kopf">
      <p class="marke">Studio Rundlauf <em>Köln</em></p>
      <p class="mono mitte">Marke · Print · Web</p>
      <p class="mono rechts">Arbeiten 2019–2024</p>
    </header>

    <div class="zyl" data-zyl role="region" aria-roledescription="Karussell" aria-label="Ausgewählte Arbeiten, Bildzylinder. Pfeiltasten drehen." tabindex="0">
      <ul class="ring" data-ring></ul>
      <div class="boden" aria-hidden="true"></div>
    </div>

    <div class="flow" data-flow>
      <ul class="track" data-track tabindex="0" aria-label="Ausgewählte Arbeiten, waagerecht scrollbar"></ul>
    </div>

    <div class="titel" aria-live="polite">
      <p class="mono" data-nummer>01 / 06</p>
      <p class="name" data-name>Gefüge</p>
      <p class="mono" data-art>Editorial · 2025</p>
    </div>
    <p class="mono tempo" data-tempo-box>Tempo<b data-tempo>×1,0</b></p>
    <p class="mono hinweis" data-hinweis>Ziehen dreht · Scrollen beschleunigt</p>
    <div class="rad" data-rad aria-hidden="true"><p class="mono">Scroll</p><span></span></div>

    <div class="leiste">
      <div class="schalter" role="group" aria-label="Variante">
        <button class="mono" data-variante="zylinder" aria-pressed="true">Zylinder</button>
        <button class="mono" data-variante="scroll" aria-pressed="false">Scroll</button>
        <button class="mono" data-variante="flow" aria-pressed="false">Cover Flow</button>
      </div>
      <p class="mono werte" data-werte></p>
      <div class="steuer">
        <button data-zurueck aria-label="Vorige Arbeit"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M7.5 2 3.5 6l4 4" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button>
        <button data-halt aria-label="Drehung anhalten" aria-pressed="false"><svg viewBox="0 0 12 12" aria-hidden="true" data-halt-icon><path d="M4 2.5v7M8 2.5v7" stroke="currentColor" stroke-width="1.4"/></svg></button>
        <button data-vor aria-label="Nächste Arbeit"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M4.5 2l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button>
      </div>
    </div>
  </div>
</section>
<footer class="fuss">
  <p>Studio Rundlauf gestaltet Marken, Drucksachen und Websites für Cafés, Handwerk und kleine Agenturen. Seit 2016 in Köln-Ehrenfeld.</p>
  <p class="mono">Anfragen · Termine nach Absprache</p>
</footer>
<svg class="zeiger" data-zeiger viewBox="0 0 44 44" aria-hidden="true"><circle cx="22" cy="22" r="18" fill="rgba(241,239,233,.18)" stroke="#f1efe9" stroke-width="1.2"/></svg>

<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/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/InertiaPlugin.min.js"></script>
<script>
(() => {
  gsap.registerPlugin(ScrollTrigger, Draggable, InertiaPlugin);
  const html = document.documentElement, $ = s => document.querySelector(s);
  if (new URLSearchParams(location.search).has('reduziert')) html.classList.add('reduziert');
  const reduziert = () => html.classList.contains('reduziert');
  const touch = matchMedia('(hover: none)').matches;

  // Arbeiten (eigene Projekte aus medien/). Der Ring zeigt die ersten sechs zweimal, gegenüberliegend.
  const ARBEITEN = [
    { id: 'gefuege', bild: 'magazin-doppelseite', name: 'Gefüge', art: 'Editorial', jahr: 2025, ton: '#b98a6a' },
    { id: 'roestkontor', bild: 'marke-roestkontor', name: 'Röstkontor', art: 'Marke & Verpackung', jahr: 2024, ton: '#c8432b' },
    { id: 'ambra-nord', bild: 'flakon', name: 'Ambra Nord', art: 'Kampagne', jahr: 2025, ton: '#2340d0' },
    { id: 'krume', bild: 'marke-krume', name: 'Krume', art: 'Backstube, Marke', jahr: 2023, ton: '#d9b65a' },
    { id: 'atelier-hain', bild: 'keramik-stillleben', name: 'Atelier Hain', art: 'Keramik, Bildsprache', jahr: 2026, ton: '#c9663a' },
    { id: 'eselsohr', bild: 'marke-eselsohr', name: 'Eselsohr', art: 'Buchhandlung, Website', jahr: 2024, ton: '#3a3b42' },
    { id: 'licht', bild: 'typo-plakat', name: 'Licht, Halle 4', art: 'Plakat', jahr: 2025, ton: '#b8cf34' },
    { id: 'leinwerk', bild: 'stoff-stapel', name: 'Leinwerk', art: 'Onlineshop', jahr: 2023, ton: '#c99aa5' },
  ];
  const N = 12, SCHRITT = 360 / N, IM_RING = 6;

  // ---------- Werte (Messung: Osmo, Codrops 3DCarousel, CLOU) ----------
  const W = {
    umdrehung_s: 30,            // Grundrotation 360° in 30 s, ease none
    intro_tempo: 15, intro_s: 2, // Intro: Tempo 15 → 1 in 2 s, expo.inOut
    scroll_faktor: 0.005, tempo_max: 60, rueckkehr_s: 1.2,
    drag_strecke: 3,            // 3 × Fensterbreite = eine Umdrehung
    druck: 'inset(5% round 5px)', druck_s: 0.3, los_s: 0.5,   // power4.out
    schritt_s: 0.7,             // Knöpfe/Pfeiltasten, cubic-bezier(.55,0,.1,1)
    scroll_dreh: -180, kipp: 3, hell_von: 1.5,   // Codrops misst 250 %; auf hellen Logos brennt das aus, daher 150 %
    hell_bis: 0.8,
    feder_k: 120, feder_d: 14, spreiz_f: 0.05, spreiz_max: 180, neig_f: 0.006, neig_max: 20,
    drag_spin: 0.12, drag_glatt_s: 0.6,
  };
  const bezier = (x1, y1, x2, y2) => t => {           // cubic-bezier als GSAP-Ease
    let a = t; for (let i = 0; i < 8; i++) { const x = 3 * (1 - a) ** 2 * a * x1 + 3 * (1 - a) * a * a * x2 + a ** 3 - t; const d = 3 * (1 - a) ** 2 * x1 + 6 * (1 - a) * a * (x2 - x1) + 3 * a * a * (1 - x2); if (Math.abs(d) < 1e-6) break; a -= x / d; a = Math.min(1, Math.max(0, a)); }
    return 3 * (1 - a) ** 2 * a * y1 + 3 * (1 - a) * a * a * y2 + a ** 3;
  };
  const UI = bezier(0.55, 0, 0.1, 1);

  // ---------- DOM aufbauen ----------
  const ring = $('[data-ring]'), zyl = $('[data-zyl]'), track = $('[data-track]'), flow = $('[data-flow]');
  const pfad = id => `bilder/${id}-hoch.jpg`;
  for (let i = 0; i < N; i++) {
    const a = ARBEITEN[i % IM_RING];
    ring.insertAdjacentHTML('beforeend', `<li class="panel" style="--i:${i}"><a class="karte" href="#${a.id}" draggable="false"><img src="${pfad(a.bild)}" alt="${a.name}, ${a.art} ${a.jahr}"></a></li>`);
  }
  ARBEITEN.forEach(a => track.insertAdjacentHTML('beforeend', `<li class="blatt"><img src="${pfad(a.bild)}" alt="${a.name}, ${a.art} ${a.jahr}" draggable="false"></li>`));
  const panels = [...ring.children], karten = panels.map(p => p.firstChild), blaetter = [...track.children];

  // ---------- Maße: r = 0,5 B, perspective 0,9 B (Osmo: 50vw / 90vw) ----------
  let B = 0, R = 0;
  function messen() {
    const w = innerWidth, h = innerHeight, schmal = w < 640;
    B = schmal ? Math.min(w * 1.8, h * 0.9) : Math.min(w, h * 1.5);
    R = 0.5 * B;
    const pw = (schmal ? 0.24 : 0.22) * B;
    zyl.style.perspective = (0.9 * B).toFixed(1) + 'px';
    zyl.style.setProperty('--pw', pw.toFixed(1) + 'px');
    zyl.style.setProperty('--ph', (pw * 1.25).toFixed(1) + 'px');
    zyl.style.setProperty('--r', R.toFixed(1) + 'px');
    $('[data-buehne]').style.setProperty('--ph', (pw * 1.25).toFixed(1) + 'px');
    track.style.setProperty('--kw', Math.min(w * (schmal ? 0.56 : 0.24), h * 0.42).toFixed(1) + 'px');
  }
  messen();
  let entprellt; addEventListener('resize', () => { clearTimeout(entprellt); entprellt = setTimeout(() => { messen(); ScrollTrigger.refresh(); }, 200); });

  // ---------- Zustand ----------
  const st = { spin: 0, tempo: 1, drag: 0, dragS: 0, prog: 0, scrollRot: 0, kipp: W.kipp, hell: W.hell_von, spreiz: 0, spreizV: 0, neig: 0, neigV: 0 };
  let variante = 'zylinder', auto = true, angehalten = false, vel = 0, velZeit = 0;

  // Tempo-Tweens nur für „tempo“ ersetzen (overwrite: true würde auch die Scroll-Timeline auf st töten)
  const tempoZu = (wert, dauer, ease = 'power1.out') => { gsap.killTweensOf(st, 'tempo'); return gsap.to(st, { tempo: wert, duration: dauer, ease }); };
  const winkel = () => variante === 'scroll' ? st.scrollRot + st.dragS : st.spin + st.drag;

  // Scroll-Timeline der Variante „Scroll“ (Codrops: scrub, sine.inOut, brightness power3)
  const tl = gsap.timeline({ paused: true })
    .fromTo(st, { scrollRot: 0, kipp: W.kipp }, { scrollRot: W.scroll_dreh, kipp: -W.kipp, ease: 'sine.inOut', duration: 1 }, 0)
    .fromTo(st, { hell: W.hell_von }, { hell: W.hell_bis, ease: 'power3.out', duration: 1 }, 0);

  // ---------- Bildunterschrift ----------
  const elNr = $('[data-nummer]'), elName = $('[data-name]'), elArt = $('[data-art]'), buehne = $('[data-buehne]');
  let gezeigt = -1, wechselZeit = 0, anim = null;
  function zeigeArbeit(k, gesamt) {
    if (k === gezeigt) return; gezeigt = k;
    const jetzt = performance.now(), schnell = jetzt - wechselZeit < 350; wechselZeit = jetzt;
    const a = ARBEITEN[k];
    elNr.textContent = `${String(k + 1).padStart(2, '0')} / ${String(gesamt).padStart(2, '0')}`;
    elName.textContent = a.name; elArt.textContent = `${a.art} · ${a.jahr}`;
    buehne.style.setProperty('--ton', a.ton);
    if (anim) anim.cancel();
    // bei schneller Drehung Text nur tauschen, sonst steigt der Name kurz ein
    if (!reduziert() && !schnell) anim = elName.animate([{ opacity: 0, transform: 'translateY(.35em)' }, { opacity: 1, transform: 'none' }], { duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
  }
  function vorne() { return ((Math.round(-winkel() / SCHRITT) % N) + N) % N; }
  function fokusVorne() { const v = vorne(); karten.forEach((k, i) => k.tabIndex = i === v ? 0 : -1); }

  // ---------- Malen (ein Takt für alles) ----------
  const elTempo = $('[data-tempo]');
  let letztesTempo = '';
  gsap.ticker.add((zeit, dtMs) => {
    const dt = Math.min(dtMs, 1000 / 30) / 1000;          // Zeitschritt auf 1/30 s begrenzt
    if (!angehalten && !reduziert()) st.spin -= 360 / W.umdrehung_s * dt * st.tempo;
    let rx = 0, rz = 0, hell = 1, r = R;
    if (variante === 'scroll') {
      // Federn: Spreizung und Neigung aus der Scrollgeschwindigkeit (Steifigkeit 120, Dämpfung 14)
      const frisch = performance.now() - velZeit < 120 && !reduziert();
      const zielS = frisch ? Math.min(Math.abs(vel) * W.spreiz_f, W.spreiz_max) : 0;
      const zielN = frisch ? gsap.utils.clamp(-W.neig_max, W.neig_max, vel * W.neig_f) : 0;
      st.spreizV += (W.feder_k * (zielS - st.spreiz) - W.feder_d * st.spreizV) * dt; st.spreiz += st.spreizV * dt;
      st.neigV += (W.feder_k * (zielN - st.neig) - W.feder_d * st.neigV) * dt; st.neig += st.neigV * dt;
      rx = st.kipp + st.neig; rz = st.kipp; hell = reduziert() ? 1 : st.hell; r = R + st.spreiz;
    }
    const a = winkel();
    ring.style.transform = `translateZ(${(-R).toFixed(1)}px) rotateX(${rx.toFixed(2)}deg) rotateZ(${rz.toFixed(2)}deg) rotateY(${a.toFixed(2)}deg)`;
    ring.style.setProperty('--r', r.toFixed(1) + 'px');
    for (let i = 0; i < N; i++) {
      const c = Math.cos((a + i * SCHRITT) * Math.PI / 180);
      panels[i].style.setProperty('--licht', ((0.18 + 0.82 * ((c + 1) / 2) ** 1.6) * hell).toFixed(3));
    }
    if (variante !== 'flow') { zeigeArbeit(vorne() % IM_RING, IM_RING); }
    const t = variante === 'zylinder' && !angehalten ? (auto ? st.tempo : 0) : 0;
    const txt = '×' + Math.abs(t).toFixed(1).replace('.', ',') + (t < -0.05 ? ' ←' : '');
    if (txt !== letztesTempo) { elTempo.textContent = txt; letztesTempo = txt; }
  });

  // ---------- Scroll: Tempo (Zylinder) bzw. Drehung (Scroll) ----------
  function scrollDaten(p, v) {
    vel = v; velZeit = performance.now();
    tl.progress(p);
    if (variante === 'zylinder' && auto && !reduziert() && !touch) {
      const dir = Math.sign(v) || 1;
      let ts = gsap.utils.clamp(-W.tempo_max, W.tempo_max, v * W.scroll_faktor);
      if (Math.abs(ts) < 1) ts = dir;
      st.tempo = ts;
      tempoZu(dir, W.rueckkehr_s, 'power1.out');
    }
  }
  const zug = ScrollTrigger.create({ trigger: '[data-strecke]', start: 'top top', end: 'bottom bottom', onUpdate: s => scrollDaten(s.progress, s.getVelocity()) });

  // ---------- Ziehen ----------
  const proxy = document.createElement('div');
  let x0 = 0, d0 = 0, dS0 = 0;
  const glatt = gsap.quickTo(st, 'dragS', { duration: W.drag_glatt_s, ease: 'power3' });
  const druecken = an => gsap.to(karten, { clipPath: an ? W.druck : 'inset(0% round 5px)', duration: an ? W.druck_s : W.los_s, ease: 'power4.out', overwrite: 'auto' });
  function aufZug(d) {
    const dx = d.x - x0;
    if (variante === 'scroll') glatt(dS0 + dx * W.drag_spin * (reduziert() ? 1 / 3 : 1));
    else st.drag = d0 + dx / (W.drag_strecke * innerWidth) * 360 * (reduziert() ? 1 / 3 : 1);
  }
  function weiterDrehen(richtung) { if (auto && !reduziert()) tempoZu(richtung, W.rueckkehr_s, 'power1.out'); }
  function einrasten() {   // reduzierte Bewegung: ohne Trägheit auf das nächste Bild
    const a = winkel(), z = Math.round(a / SCHRITT) * SCHRITT;
    if (variante === 'scroll') { st.dragS += z - a; glatt.tween.kill(); } else st.drag += z - a;
  }
  Draggable.create(proxy, {
    type: 'x', trigger: zyl, inertia: true, dragClickables: true, allowNativeTouchScrolling: true, minimumMovement: 4,
    onPress() { x0 = this.x; d0 = st.drag; dS0 = st.dragS; zyl.classList.add('zieht'); if (!reduziert()) druecken(true); if (variante === 'zylinder') tempoZu(0, W.druck_s); },
    onDrag() { aufZug(this); },
    onThrowUpdate() { if (variante === 'zylinder' && !reduziert()) aufZug(this); },
    onRelease() {
      zyl.classList.remove('zieht'); druecken(false);
      if (reduziert()) { this.tween && this.tween.kill(); einrasten(); }
      if (variante === 'zylinder') weiterDrehen(this.x - x0 > 0 ? -1 : 1);
    },
  });

  // ---------- Schritte (Knöpfe, Pfeiltasten, Klick auf seitliches Bild) ----------
  function schritt(n) {
    if (variante === 'flow') { const kw = blaetter[0].offsetWidth + parseFloat(getComputedStyle(track).columnGap || 0); track.scrollBy({ left: n * kw, behavior: reduziert() ? 'auto' : 'smooth' }); return; }
    const a = winkel(), ziel = Math.round(a / SCHRITT) * SCHRITT - n * SCHRITT, delta = ziel - a;
    if (variante === 'scroll') { reduziert() ? (glatt.tween.kill(), st.dragS += delta) : glatt(st.dragS + delta); }
    else { setzeAuto(false); reduziert() ? (st.drag += delta) : (gsap.killTweensOf(st, 'drag'), gsap.to(st, { drag: st.drag + delta, duration: W.schritt_s, ease: UI })); }
    setTimeout(fokusVorne, reduziert() ? 0 : W.schritt_s * 1000);
  }
  $('[data-vor]').addEventListener('click', () => schritt(1));
  $('[data-zurueck]').addEventListener('click', () => schritt(-1));
  zyl.addEventListener('keydown', e => { if (e.key === 'ArrowRight') { e.preventDefault(); schritt(1); } if (e.key === 'ArrowLeft') { e.preventDefault(); schritt(-1); } });
  karten.forEach((k, i) => k.addEventListener('click', e => {
    e.preventDefault();
    const v = vorne(); let d = ((i - v) % N + N) % N; if (d > N / 2) d -= N;
    if (d) schritt(d);
  }));

  // Tastatur: Fokus im Zylinder hält die Drehung an (wie der Pause-Knopf) und holt die fokussierte Karte nach vorn
  zyl.addEventListener('focusin', e => {
    if (variante === 'flow' || !e.target.matches(':focus-visible')) return;   // nur Tastaturfokus, Ziehen und Klicken bleiben wie bisher
    if (variante === 'zylinder' && auto) setzeAuto(false);
    const i = karten.indexOf(e.target);
    if (i < 0) { fokusVorne(); schritt(0); return; }
    const v = vorne(); let d = ((i - v) % N + N) % N; if (d > N / 2) d -= N;
    if (d) schritt(d);
  });

  // ---------- Autorotation anhalten ----------
  const halt = $('[data-halt]');
  function setzeAuto(an) {
    auto = an && !reduziert();
    halt.setAttribute('aria-pressed', String(!auto));
    halt.setAttribute('aria-label', auto ? 'Drehung anhalten' : 'Drehung fortsetzen');
    halt.querySelector('svg').innerHTML = auto ? '<path d="M4 2.5v7M8 2.5v7" stroke="currentColor" stroke-width="1.4"/>' : '<path d="M3.5 2.2v7.6L9.8 6Z" fill="currentColor"/>';
    tempoZu(auto ? 1 : 0, auto ? W.rueckkehr_s : W.druck_s, 'power1.out');
  }
  halt.addEventListener('click', () => setzeAuto(!auto));

  // ---------- Cover Flow ----------
  const sdaNativ = CSS.supports('animation-timeline: view()');
  function flowMalen() {
    const m = track.scrollLeft + track.clientWidth / 2;
    let best = 0, bestD = Infinity;
    blaetter.forEach((b, i) => {
      const c = b.offsetLeft + b.offsetWidth / 2, d = c - m;
      if (Math.abs(d) < bestD) { bestD = Math.abs(d); best = i; }
      if (!sdaNativ && !reduziert()) {   // JS-Ersatz für animation-timeline: view(inline)
        const p = gsap.utils.clamp(-1, 1, d / (track.clientWidth / 2 + b.offsetWidth / 2));
        b.firstChild.style.transform = `perspective(900px) rotateY(${(-45 * p).toFixed(2)}deg) scale(${(1 - 0.2 * Math.abs(p)).toFixed(3)})`;
        b.firstChild.style.filter = `brightness(${(1 - 0.45 * Math.abs(p)).toFixed(3)})`;
      }
    });
    if (variante === 'flow') zeigeArbeit(best, ARBEITEN.length);
  }
  track.addEventListener('scroll', flowMalen, { passive: true });

  // ---------- Varianten ----------
  const werte = {
    zylinder: '12 × 30° · r 0,5 B · perspective 0,9 B · 360° in 30 s, linear · Scroll: Tempo = v × 0,005 (max. ±60), zurück in 1,2 s · Ziehen: 3 × Breite = 360°, Trägheit · Drücken inset 5 %, 300/500 ms power4.out · Intro ×15 → 1 in 2 s expo.inOut',
    scroll: 'rotateY 0 → −180° · rotateX/Z +3 → −3° · sine.inOut über die Strecke · brightness 150 → 80 % power3.out · Feder 120/14: Spreizung v × 0,05 (max. 180 px), Neigung v × 0,006 (max. 20°) · Ziehen × 0,12, 0,6 s power3',
    flow: 'scroll-snap-type x mandatory · animation-timeline view(inline) · rotateY 45° → 0 → −45° · scale 0,8 → 1 → 0,8 · perspective 900px',
  };
  const hinweise = { zylinder: touch ? 'Ziehen dreht' : 'Ziehen dreht · Scrollen beschleunigt', scroll: 'Scrollen dreht · Ziehen dreht nach', flow: touch ? 'Wischen' : 'Seitlich scrollen oder wischen' };
  const knoepfe = [...document.querySelectorAll('[data-variante]')];
  function setzeVariante(v) {
    variante = v; gezeigt = -1;
    knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.variante === v)));
    $('[data-werte]').textContent = werte[v];
    $('[data-hinweis]').textContent = reduziert() ? 'Pfeiltasten oder Knöpfe' : hinweise[v];
    zyl.style.display = v === 'flow' ? 'none' : '';
    flow.classList.toggle('an', v === 'flow');
    $('[data-tempo-box]').style.visibility = v === 'zylinder' ? '' : 'hidden';
    halt.disabled = v !== 'zylinder' || reduziert(); halt.style.opacity = halt.disabled ? .35 : '';
    if (v === 'scroll') { st.dragS = 0; glatt.tween && glatt.tween.kill(); }
    if (v === 'flow') { track.scrollLeft = 0; requestAnimationFrame(() => { track.scrollLeft = 0; flowMalen(); }); }
    fokusVorne();
  }
  knoepfe.forEach(k => k.addEventListener('click', () => { pause(); html.classList.remove('vorfuehrung'); setzeVariante(k.dataset.variante); }));

  // ---------- Intro ----------
  function intro() {
    if (reduziert()) { gsap.set(karten, { opacity: 1 }); st.tempo = 0; return; }
    gsap.fromTo(karten, { opacity: 0 }, { opacity: 1, duration: 0.6, ease: 'power1.out', stagger: { amount: 0.8, from: 'random' }, overwrite: 'auto' });
    st.tempo = W.intro_tempo;
    tempoZu(auto ? 1 : 0, W.intro_s, 'expo.inOut');
  }

  // ---------- Vorführung ----------
  const zeiger = $('[data-zeiger]'), rad = $('[data-rad]');
  let film = null;
  const zeigerBei = (x, y) => gsap.set(zeiger, { x: x - 22, y: y - 22 });
  function vorfuehren() {
    pause(); angehalten = false; gsap.globalTimeline.resume();
    html.classList.add('vorfuehrung');
    const w = innerWidth, h = innerHeight, cy = h * 0.45;
    if (variante === 'flow') {
      let i = 0;
      film = gsap.timeline({ repeat: -1 });
      for (let k = 0; k < 5; k++) film.call(() => { i = (i + 1) % 5; i ? schritt(1) : track.scrollTo({ left: 0, behavior: 'smooth' }); }, null, k * 1.6 + 0.8);
      film.to({}, { duration: 0.4 }, '>');
      return;
    }
    if (variante === 'scroll') {
      const lang = document.querySelector('[data-strecke]').offsetHeight - innerHeight;
      const s = { p: 0 }; let alt = 0, altT = performance.now();
      const upd = () => { const t = performance.now(), v = (s.p - alt) * lang / Math.max(1, t - altT) * 1000; alt = s.p; altT = t; scrollDaten(s.p, v); };
      film = gsap.timeline({ repeat: -1, onStart: () => rad.classList.add('an') })
        .to(s, { p: 1, duration: 3.6, ease: 'power2.inOut', onUpdate: upd })
        .to(s, { p: 0, duration: 3.6, ease: 'power2.inOut', onUpdate: upd }, '+=0.6')
        .to({}, { duration: 0.6 });
      return;
    }
    // Zylinder: Intro, Ziehen mit Trägheit, dann Scroll-Schub
    const sx = w * 0.62, ex = w * 0.3, z = { x: sx };
    film = gsap.timeline({ repeat: -1 })
      .call(() => { setzeAuto(true); intro(); zeiger.classList.remove('an'); rad.classList.remove('an'); })
      .call(() => { zeigerBei(sx, cy); zeiger.classList.add('an'); }, null, 2.6)
      .call(() => { z.x = sx; x0 = sx; d0 = st.drag; zeiger.querySelector('circle').setAttribute('r', 12); druecken(true); tempoZu(0, W.druck_s); }, null, 3.1)
      .to(z, { x: ex, duration: 0.9, ease: 'power2.inOut', onUpdate: () => { zeigerBei(z.x, cy); st.drag = d0 + (z.x - x0) / (W.drag_strecke * w) * 360; } }, 3.3)
      .call(() => { zeiger.querySelector('circle').setAttribute('r', 18); druecken(false); zeiger.classList.remove('an'); gsap.to(st, { drag: st.drag - 26, duration: 1.4, ease: 'power3.out' }); weiterDrehen(1); }, null, 4.2)
      .call(() => { rad.classList.add('an'); scrollDaten(0.3, 2600); }, null, 6.4)
      .call(() => rad.classList.remove('an'), null, 7.4)
      .to({}, { duration: 2.4 }, 7.4);
  }
  function pause() { if (film) { film.kill(); film = null; } }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung'); zeiger.classList.remove('an'); rad.classList.remove('an');
    angehalten = false; gsap.globalTimeline.resume();
    st.drag = 0; st.spin = 0; st.dragS = 0; druecken(false);
    setzeAuto(true); setzeVariante(variante); intro();
  }
  function weiter() { angehalten = false; gsap.globalTimeline.resume(); if (!film && html.classList.contains('vorfuehrung')) vorfuehren(); }
  function anhalten() { angehalten = true; gsap.globalTimeline.pause(); }

  setzeVariante('zylinder'); setzeAuto(true); intro();
  // Schalter „reduzierte Bewegung“ der Detailseite kann die Klasse später setzen
  let warRed = reduziert();
  new MutationObserver(() => { if (reduziert() !== warRed) { warRed = reduziert(); gsap.set(karten, { opacity: 1 }); setzeAuto(true); setzeVariante(variante); } }).observe(html, { attributes: true, attributeFilter: ['class'] });

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