Zum Inhalt
← Galerien & Slider

Karussell mit verformten Karten

Arbeiten eines Ludwigshafener Studios als Karussell: die Karte in der Mitte liegt flach, die Nachbarn biegen sich weg, schlagen beim Ziehen Wellen oder rollen sich vom Rand her ein.

Studio-Showcase, Lookbook: ruhigste Form, die Nachbarn krümmen sich weg wie Karton, die Mitte bleibt unverzerrt.

Ein Karussell, in dem die Karte in der Mitte flach liegt und die Karten daneben sich je nach Abstand verformen: Sie biegen sich nach hinten weg, schlagen beim Ziehen Wellen oder rollen sich vom äußeren Rand her ein. Die Verformung hängt an Position und Tempo, nicht an der Zeit. Beim Loslassen rastet die nächste Karte ein und glättet sich.

Die Karten verhalten sich wie Papier oder Stoff. Das macht das Ziehen körperlich: Schnelles Wischen lässt die Karten flattern, langsames Ziehen biegt sie nur. Die flache Mitte lenkt den Blick, ohne dass Nachbarn abgedunkelt werden müssen.

Wann einsetzen

Showcase eines Kreativstudios, Kampagnen- oder Lookbook-Seiten mit wenigen, starken Motiven (5 bis 9 Karten). Als Experiment, wenn das Karussell selbst die Haltung zeigen soll.

Wann nicht

Produkte, Zimmer, Immobilien oder Werke, die unverfälscht wirken müssen (die Verformung verzerrt Kanten und Proportionen). Inhalte, die man vergleichen oder lesen muss. Auf Touch ohne funktionierenden Fallback. Seiten mit Lenis-Pflicht oder mehreren WebGL-Ebenen. Lange Listen.

Varianten

  • biegen: Nachbarkarten krümmen sich um eine senkrechte Achse nach hinten, Stärke nach Abstand zur Mitte
  • welle: Beim Ziehen läuft eine Sinuswelle über die Karten, Amplitude nach Tempo, klingt beim Einrasten aus
  • rollen: Nachbarkarten rollen sich vom äußeren Rand her um einen Zylinder ein (Page-Curl-Prinzip)
  • zerfallen: Karten zerfallen zu Staub (nicht gebaut, hohe Kosten)

Umsetzung

DOM bleibt die Quelle: eine Liste echter Karten (Link, Bild mit Alt-Text, Titel) in einer Spur, die per transform verschoben wird. Darüber ein WebGL-Canvas (three, pointer-events: none). Je Karte eine PlaneGeometry mit 48 × 8 Segmenten (Rollen 64 × 8), die der Lage der DOM-Karte folgt; das DOM-Bild wird unsichtbar, bleibt aber für Screenreader da. Im Vertex-Shader steuert der Abstand d zur Mitte die Verformung: Biegen krümmt die Außenkante nach hinten, Welle legt einen Sinus nach Tempo darüber, Rollen legt alles jenseits einer wandernden Kante auf einen Zylinder. Ziehen per Pointer Events mit Trägheit, Einrasten mit expo.out. Ohne WebGL: rotateY und scale je nach Abstand. Reduzierte Bewegung: normales Karussell.

Robust umsetzen

Telefon. Wischen mit Trägheit, Einrasten auf die nächste Karte. Pixelverhältnis höchstens 1,5. Karten 78 % der Breite, Nachbarn ragen an den Rändern herein.

Ohne Bewegung. Normales Karussell ohne Verformung und ohne WebGL. Wechsel springen ohne Animation, Ziehen bleibt möglich.

Barrierefrei. Section mit aria-roledescription „Karussell“ und Namen, Karten als Liste mit aria-roledescription „Folie“ und „2 von 7: Titel“. Alle Karten-Links sind per Tab erreichbar; der Fokus holt die Karte in die Mitte, er landet also nie im Unsichtbaren. Pfeiltasten, Pos1 und Ende wenn der Fokus im Karussell ist. Vor/Zurück als echte Buttons mit Namen. Wechsel per aria-live polite angesagt. Keine automatische Rotation, daher kein Pause-Knopf nötig (WCAG 2.2.2). Ziehen hat eine Alternative per Klick und Tastatur (WCAG 2.5.7).

Code

<!DOCTYPE html>
<!--
  Karussell mit verformten Karten
  Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/karussell-karten-verformen/
  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>Karussell mit verformten Karten · 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: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Rezept kreativstudio-buehne: Schwarz/Weiß, genau eine Signalfarbe, eine Out-Kurve */
  --grund: #0b0b0b; --text: #ffffff; --leise: rgba(255, 255, 255, .7); --linie: rgba(255, 255, 255, .16);
  --signal: #c8f04a;   /* Signal Krempe: Hellgrün */
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);
  --rand: clamp(20px, 4vw, 64px);
  --karte: clamp(210px, min(24vw, 28svh), 380px);
  --spalt: clamp(18px, 2.4vw, 36px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'TikTok Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
:root:has(:focus-visible) { scroll-behavior: auto; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.mono { font: 400 12px/1.4 'DM Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; color: var(--leise); }

/* ---------- Kopf und Intro ---------- */
.kopf { display: flex; align-items: center; gap: 28px; padding: 22px var(--rand); }
.marke { font-size: 22px; font-weight: 800; font-stretch: 75%; letter-spacing: -.02em; text-decoration: none; margin-right: auto; text-transform: uppercase; }
.kopf nav { display: flex; gap: 24px; }
.kopf nav a { text-decoration: none; color: var(--leise); transition: color .2s var(--kurve); }
.kopf nav a:hover, .kopf nav a[aria-current] { color: var(--text); }
.anfrage { display: inline-flex; align-items: center; min-height: 44px; padding: 0 18px; border-radius: 999px; background: var(--signal); color: #0b0b0b; text-decoration: none; font-weight: 600; font-size: 15px; transition: background-color .2s var(--kurve); }
.anfrage:hover { background: #ff7440; }
.anfrage:focus-visible { outline-color: var(--text); }

.intro { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 16px 40px; padding: clamp(8px, 2vw, 28px) var(--rand) clamp(12px, 2vw, 28px); }
h1 { font-size: clamp(3.25rem, 6.4vw, 8rem); font-weight: 800; font-stretch: 75%; letter-spacing: -.03em; line-height: .9; text-transform: uppercase; }
h1 span { color: var(--signal); }
.intro p:not(.mono) { max-width: 34ch; font-size: 1.125rem; line-height: 1.4; color: var(--leise); }
.intro .mono { grid-column: 1 / -1; display: flex; gap: 14px; align-items: center; }
.intro .mono i { width: 32px; height: 1px; background: var(--linie); }

/* ---------- Bühne: DOM ist Layoutquelle, WebGL folgt ---------- */
.karussell { position: relative; padding: clamp(8px, 2vh, 28px) 0 0; }
.buehne { position: relative; height: calc(var(--karte) * 1.25 + 56px); overflow: hidden; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; }
.buehne.zieht { cursor: grabbing; }
.spur { position: absolute; top: 28px; left: 50%; display: flex; gap: var(--spalt); list-style: none; padding: 0; margin: 0 0 0 calc(var(--karte) / -2); will-change: transform; }
.karte { flex: none; width: var(--karte); }
.karte a { display: block; text-decoration: none; outline: none; -webkit-user-drag: none; }
.karte img { display: block; width: 100%; aspect-ratio: 4 / 5; height: auto; object-fit: cover; background: #1a1a1a; pointer-events: none; -webkit-user-drag: none; }
.karte a:focus-visible img { outline: 2px solid var(--signal); outline-offset: 6px; }
.gl-an .karte img { opacity: 0; }                       /* bleibt mit Alt-Text im DOM, WebGL zeichnet darüber */
.gl-an .karte a:focus-visible { outline: 2px solid var(--signal); outline-offset: 8px; }
/* CSS-Fallback ohne WebGL: Drehung und Skalierung nach Abstand (per JS gesetzt) */
.kein-gl .spur { perspective: 1400px; transform-style: preserve-3d; }
.kein-gl .karte { transform-style: preserve-3d; }
.gl { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; display: block; }
.reduziert .gl, .kein-gl .gl { display: none; }

/* ---------- Unter der Bühne: aktive Arbeit, Zähler, Knöpfe ---------- */
.leiste { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 20px 40px; padding: 8px var(--rand) clamp(40px, 6vw, 72px); }
.aktiv h2 { font-size: clamp(2rem, 4.5vw, 4.5rem); font-weight: 600; letter-spacing: -.02em; line-height: .95; }
.aktiv p { margin-top: 12px; }
.aktiv .mono b { color: var(--text); font-weight: 400; }
.steuer { display: flex; align-items: center; gap: 18px; }
.zaehler { font: 500 14px/1 'DM Mono', monospace; font-variant-numeric: tabular-nums; color: var(--leise); }
.zaehler b { color: var(--text); font-weight: 500; }
.pfeil { width: 56px; height: 56px; border-radius: 50%; border: 1px solid var(--linie); background: none; cursor: pointer; display: grid; place-items: center; transition: background-color .2s var(--kurve), border-color .2s var(--kurve), color .2s var(--kurve); }
.pfeil svg { width: 20px; height: 20px; }
.pfeil:hover:not(:disabled) { background: var(--text); color: var(--grund); border-color: var(--text); }
.pfeil:active:not(:disabled) { background: var(--signal); border-color: var(--signal); color: #0b0b0b; }
.pfeil:disabled { opacity: .35; cursor: default; }
.hinweis { padding: 0 var(--rand); }
.wechsel { transition: opacity .35s var(--kurve), transform .9s var(--kurve); }
.wechsel.raus { opacity: 0; transform: translateY(10px); transition-duration: .15s; }

.studio { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(20px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); border-top: 1px solid var(--linie); }
.studio h3 { font-size: 28px; font-weight: 600; letter-spacing: -.01em; margin: 10px 0; }
.studio p:not(.mono) { color: var(--leise); line-height: 1.5; max-width: 34ch; }
.fuss { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; padding: 24px var(--rand) 40px; border-top: 1px solid var(--linie); }

/* Gedachter Zeiger in der Vorführung */
.zeiger { position: absolute; z-index: 5; left: 0; top: 0; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%; border: 1.5px solid var(--text); pointer-events: none; display: none; transition: scale .15s linear, background-color .15s linear; }
.zeiger.drueckt { scale: .72; background: rgba(200, 240, 74, .6); border-color: var(--signal); }
.vorfuehrung .zeiger { display: block; }

/* ---------- Pult ---------- */
.pult { position: fixed; z-index: 20; right: var(--rand); bottom: 18px; display: grid; gap: 8px; justify-items: end; max-width: min(560px, calc(100vw - 2 * var(--rand))); }
.pult .knoepfe { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; border-radius: 999px; background: rgba(30, 30, 30, .86); border: 1px solid var(--linie); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }
.pult button { border: 0; background: none; color: var(--leise); height: 30px; padding: 0 12px; border-radius: 999px; cursor: pointer; font: inherit; letter-spacing: inherit; text-transform: inherit; transition: background-color .15s linear, color .15s linear; }
.pult button:hover { color: var(--text); }
.pult button[aria-pressed=true] { background: var(--text); color: var(--grund); }
.pult .werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 14px; padding: 7px 12px; border-radius: 10px; background: rgba(30, 30, 30, .86); border: 1px solid var(--linie); }
.pult .werte b { color: var(--text); font-weight: 400; }

/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; }
.vorschau .studio, .vorschau .fuss, .vorschau .kopf nav, .vorschau .intro p:not(.mono), .vorschau .hinweis, .vorschau .intro .mono { display: none; }
.vorschau h1 { font-size: 104px; }
.vorschau .karussell { padding-top: 0; }
:root.vorschau { --karte: 260px; }
.vorschau .leiste { display: none; }
.vorschau .buehne { margin-top: 12px; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (max-width: 860px) { .kopf nav { display: none; } .intro { grid-template-columns: minmax(0, 1fr); } .studio { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) {
  :root { --karte: 78vw; }
  .anfrage { padding: 0 14px; font-size: 14px; }
  .leiste { grid-template-columns: minmax(0, 1fr); }
  .steuer { justify-content: space-between; }
}
@media (max-width: 360px) { .kopf { gap: 10px; } .pfeil { width: 48px; height: 48px; } }
@media (hover: none) { .pult button { height: 36px; } .nur-maus { display: none; } }
@media (hover: hover) { .nur-touch { display: none; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
  .gl { display: none; } .gl-an .karte img { opacity: 1; }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#titel">Krempe</a>
  <nav class="mono" aria-label="Hauptmenü"><a href="#arbeiten" aria-current="page">Arbeiten</a><a href="#studio">Studio</a><a href="#studio">Kontakt</a></nav>
  <a class="anfrage" href="#studio">Projekt anfragen</a>
</header>

<main>
  <div class="intro">
    <p class="mono"><span>Ausgewählte Arbeiten</span><i></i><span>2024–2026</span></p>
    <h1 id="titel">Formen, die <span>nachgeben</span>.</h1>
    <p>Krempe ist ein Studio für Marken, Plakate und Leitsysteme in Ludwigshafen. <span class="nur-maus">Ziehen Sie die Arbeiten zur Seite oder nutzen Sie die Pfeiltasten.</span><span class="nur-touch">Wischen Sie durch die Arbeiten.</span></p>
  </div>

  <section class="karussell" id="arbeiten" aria-roledescription="Karussell" aria-label="Ausgewählte Arbeiten" data-karussell>
    <div class="buehne" data-buehne>
      <ul class="spur" data-spur></ul>
      <canvas class="gl" data-gl aria-hidden="true"></canvas>
      <div class="zeiger" data-zeiger aria-hidden="true"></div>
    </div>
    <div class="leiste">
      <div class="aktiv wechsel" data-aktiv>
        <h2 data-a-titel>Kammerton</h2>
        <p class="mono"><span data-a-art>Konzertreihe · Erscheinungsbild</span> · <b data-a-jahr>2025</b></p>
      </div>
      <div class="steuer">
        <p class="zaehler" aria-hidden="true"><b data-nr>02</b> / <span data-gesamt>07</span></p>
        <button class="pfeil" type="button" aria-label="Vorherige Arbeit" data-zurueck><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.5 4 6.5 10l6 6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
        <button class="pfeil" type="button" aria-label="Nächste Arbeit" data-vor><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
      </div>
    </div>
    <p class="nur-sr" aria-live="polite" data-ansage></p>
  </section>

  <section class="studio" id="studio" aria-label="Studio">
    <div><p class="mono">01 · Haltung</p><h3>Wenig, aber laut</h3><p>Eine Schrift, eine Farbe, ein Gedanke. Den Rest erledigen Größe und Schnitt.</p></div>
    <div><p class="mono">02 · Arbeitsweise</p><h3>Erst vor Ort</h3><p>Bevor wir entwerfen, stehen wir einen Abend im Foyer, an der Kasse oder an der Haltestelle.</p></div>
    <div><p class="mono">03 · Anfragen</p><h3>Ab März frei</h3><p>Schreiben Sie uns zwei Sätze über Ihr Vorhaben an hallo@krempe-studio.de.</p></div>
  </section>
</main>
<footer class="fuss mono"><span>Krempe · Rheinuferstraße 12 · 67061 Ludwigshafen</span><span>Impressum · Datenschutz</span></footer>

<div class="pult mono">
  <div class="knoepfe" role="group" aria-label="Verformung">
    <button type="button" data-variante="biegen" aria-pressed="true">Biegen</button>
    <button type="button" data-variante="welle" aria-pressed="false">Welle</button>
    <button type="button" data-variante="rollen" aria-pressed="false">Rollen</button>
  </div>
  <p class="werte" data-werte></p>
</div>

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), spur = $('[data-spur]'), canvas = $('[data-gl]'), zeigerEl = $('[data-zeiger]');
const ansage = $('[data-ansage]'), werteEl = $('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-variante]')];

/* ---------- Arbeiten: Titelbilder werden selbst gezeichnet (Canvas 2D, 800 × 1000) ---------- */
const ARBEITEN = [
  { titel: 'Rheinlust', art: 'Festival am Rheinufer · Plakate, Bühne', jahr: '2026', alt: 'Plakat Rheinlust: schwarze schmale Versalien RHEIN LUST auf Signalorange, darüber eine weiße Sonnenscheibe, unten das Datum 14.–16. August' },
  { titel: 'Kammerton', art: 'Konzertreihe · Erscheinungsbild', jahr: '2025', alt: 'Titel Kammerton: schwarze senkrechte Balken unterschiedlicher Höhe wie Orgelpfeifen auf Hellgrau, darunter der Name' },
  { titel: 'Hafenbad', art: 'Hallenbad · Leitsystem', jahr: '2025', alt: 'Leitsystem Hafenbad: weiße Wellenlinien und eine riesige 25 m auf Kobaltblau' },
  { titel: 'Werkhalle 7', art: 'Kulturzentrum · Marke, Programm', jahr: '2024', alt: 'Marke Werkhalle 7: Raster aus grauen Quadraten auf Schwarz, ein Quadrat orange, davor eine riesige weiße 7' },
  { titel: 'Nachtbus N7', art: 'Verkehrsverbund · Kampagne', jahr: '2025', alt: 'Kampagne Nachtbus N7: gepunktete Route mit Haltestellen und Mond auf Nachtblau, Text Hemshof bis Oggersheim' },
  { titel: 'Kiosk Hemshof', art: 'Kiosk · Wortmarke, Schilder', jahr: '2026', alt: 'Wortmarke Kiosk Hemshof: fette schwarze Buchstaben KIO SK auf Gelb, darunter ein kleines Zeichen aus Kreis und Strich' },
  { titel: 'Lesesaal', art: 'Stadtbibliothek · Ausstellung', jahr: '2024', alt: 'Ausstellungsplakat Lesesaal: breite schwarze Versalien zwischen dünnen Linien wie Buchrücken auf Papierweiß' },
];

function schrift(g, gewicht, breite, px) {
  g.font = `${gewicht} ${px}px "TikTok Sans", sans-serif`;
  if ('fontStretch' in g) g.fontStretch = breite;   // condensed / normal / ultra-expanded
}
function zeichneTitel(i) {
  const c = document.createElement('canvas'); c.width = 800; c.height = 1000;
  const g = c.getContext('2d'), W = 800, H = 1000;
  const fuss = (farbe, text) => { g.fillStyle = farbe; g.font = '500 22px "DM Mono", monospace'; g.fillText(text, 48, H - 48); };
  g.textBaseline = 'alphabetic';
  if (i === 0) {
    g.fillStyle = '#ff5a1f'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#fff4ea'; g.beginPath(); g.arc(560, 230, 150, 0, Math.PI * 2); g.fill();
    g.fillStyle = '#0b0b0b'; schrift(g, 800, 'condensed', 330); g.fillText('RHEIN', 30, 640); g.fillText('LUST', 30, 900);
    fuss('#0b0b0b', '14.–16. AUGUST · RHEINUFER SÜD');
  } else if (i === 1) {
    g.fillStyle = '#ececea'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#0b0b0b';
    for (let k = 0; k < 13; k++) { const h = 180 + Math.abs(Math.sin(k * .9 + 1)) * 460; g.fillRect(48 + k * 55, 720 - h, 34, h); }
    g.fillStyle = '#ff5a1f'; g.fillRect(48 + 6 * 55, 80, 34, 34);
    g.fillStyle = '#0b0b0b'; schrift(g, 700, 'normal', 118); g.fillText('Kammerton', 44, 860);
    fuss('#0b0b0b', 'SAISON 25/26 · SIEBEN ABENDE');
  } else if (i === 2) {
    g.fillStyle = '#1f4fd1'; g.fillRect(0, 0, W, H);
    g.strokeStyle = 'rgba(255,255,255,.9)'; g.lineWidth = 6;
    for (let k = 0; k < 9; k++) { g.beginPath(); for (let x = 0; x <= W; x += 10) { const y = 120 + k * 44 + Math.sin(x / 60 + k * .7) * 12; x ? g.lineTo(x, y) : g.moveTo(x, y); } g.stroke(); }
    g.fillStyle = '#ffffff'; schrift(g, 800, 'condensed', 440); g.fillText('25 m', 26, 900);
    fuss('#ffffff', 'BAHN 1–6 · SPRUNGTURM →');
  } else if (i === 3) {
    g.fillStyle = '#0b0b0b'; g.fillRect(0, 0, W, H);
    for (let y = 0; y < 8; y++) for (let x = 0; x < 6; x++) { g.fillStyle = (x === 4 && y === 1) ? '#ff5a1f' : '#2a2a2a'; g.fillRect(40 + x * 124, 40 + y * 116, 104, 96); }
    g.fillStyle = '#ffffff'; schrift(g, 900, 'condensed', 760); g.fillText('7', 260, 900);
    fuss('#ffffff', 'WERKHALLE 7 · PROGRAMM HERBST');
  } else if (i === 4) {
    g.fillStyle = '#121a33'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#f2efe6'; g.beginPath(); g.arc(620, 170, 70, 0, Math.PI * 2); g.fill();
    g.fillStyle = '#121a33'; g.beginPath(); g.arc(650, 150, 64, 0, Math.PI * 2); g.fill();
    const pkt = [[90, 820], [210, 650], [330, 690], [430, 520], [560, 560], [680, 380]];
    g.fillStyle = '#ff5a1f';
    for (let k = 0; k < pkt.length - 1; k++) { const [a, b] = pkt[k], [c2, d] = pkt[k + 1]; for (let t = 0; t < 1; t += .06) g.fillRect(a + (c2 - a) * t - 3, b + (d - b) * t - 3, 7, 7); }
    pkt.forEach(([x, y]) => { g.beginPath(); g.arc(x, y, 14, 0, Math.PI * 2); g.fillStyle = '#f2efe6'; g.fill(); });
    g.fillStyle = '#f2efe6'; schrift(g, 800, 'condensed', 200); g.fillText('N7', 48, 250);
    fuss('#f2efe6', 'HEMSHOF → OGGERSHEIM · 0:40–4:40');
  } else if (i === 5) {
    g.fillStyle = '#f4d23c'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#0b0b0b'; schrift(g, 900, 'normal', 330); g.fillText('KIO', 40, 400); g.fillText('SK', 40, 700);
    g.beginPath(); g.arc(640, 640, 60, 0, Math.PI * 2); g.fill(); g.fillRect(560, 730, 160, 22);
    fuss('#0b0b0b', 'HEMSHOF · TÄGLICH BIS 23 UHR');
  } else {
    g.fillStyle = '#ebe5d8'; g.fillRect(0, 0, W, H);
    g.fillStyle = '#0b0b0b';
    for (let k = 0; k < 22; k++) g.fillRect(48 + k * 32, 70, k % 5 === 2 ? 10 : 3, 300 + (k * 37 % 120));
    schrift(g, 800, 'ultra-expanded', 128); g.fillText('LESE', 44, 640); g.fillText('SAAL', 44, 780);
    g.fillStyle = '#ff5a1f'; g.fillRect(48, 820, 704, 6);
    fuss('#0b0b0b', 'STADTBIBLIOTHEK · BIS 31. MÄRZ');
  }
  return c;
}

/* ---------- DOM-Karten aufbauen ---------- */
const karten = ARBEITEN.map((a, i) => {
  const li = document.createElement('li');
  li.className = 'karte';
  li.setAttribute('aria-roledescription', 'Folie');
  li.setAttribute('aria-label', `${i + 1} von ${ARBEITEN.length}: ${a.titel}`);
  li.innerHTML = `<a href="#arbeiten" draggable="false"><img width="800" height="1000" alt="${a.alt}"></a>`;
  spur.appendChild(li);
  return { li, a: li.querySelector('a'), img: li.querySelector('img'), daten: a };
});
$('[data-gesamt]').textContent = String(ARBEITEN.length).padStart(2, '0');

/* ---------- Varianten und Werte (exakt die verwendeten) ---------- */
const V = {
  biegen: { nr: 0, name: 'Biegen', werte: [['Biegung', 'z = −0.45 · a · t² (Breite)'], ['Drehung', '0.22 rad · a'], ['a', 'min(|d|, 1.2)'], ['Segmente', '48 × 8'], ['Einrasten', '900 ms expo.out']] },
  welle: { nr: 1, name: 'Welle', werte: [['Amplitude', '0.12 · Breite · v'], ['Wellen', '1.5 je Karte'], ['v', 'px/s / 900, Lerp 6/s'], ['Grundbiegung', '0.15 · a'], ['Einrasten', '900 ms expo.out']] },
  rollen: { nr: 2, name: 'Rollen', werte: [['Radius', '0.12 · Breite'], ['Rollkante', '0.5 − 0.42 · a'], ['Rückseite', '#e9e5dc'], ['Segmente', '64 × 8'], ['Einrasten', '900 ms expo.out']] },
};
const q = new URLSearchParams(location.search);
let variante = V[q.get('v')] ? q.get('v') : 'biegen';

/* ---------- WebGL, robust ohne ---------- */
let renderer = null, szene, kamera, geos = {}, schmutzig = true;
const vertex = /* glsl */`
uniform float uD, uV, uMode, uZeit, uAsp;
varying vec2 vUv; varying float vSchatten; varying float vRueck;
const float PI = 3.14159265;
void main() {
  vUv = uv;
  vec3 p = position;                 // Plane 1 × 1, x in [-0.5, 0.5]; scale = (Breite, Höhe, Breite)
  float s = uD >= 0.0 ? 1.0 : -1.0;
  float a = min(abs(uD), 1.2);
  float t = p.x * s + 0.5;           // 0 an der Innenkante (zur Mitte), 1 außen
  float schatten = 0.0; float rueck = 0.0;
  if (uMode < 0.5) {
    /* Biegen: Außenkante krümmt sich nach hinten, ganze Karte dreht leicht zur Mitte */
    float z = -0.45 * a * t * t;
    p.x -= s * 0.18 * a * t * t;
    float w = s * 0.22 * a;
    vec3 q = vec3(p.x * cos(w) + z * sin(w), p.y, -p.x * sin(w) + z * cos(w));
    p = q;
    schatten = 0.55 * a * t;
  } else if (uMode < 1.5) {
    /* Welle: Sinus über die Breite, Amplitude nach Tempo; leichte Grundbiegung nach Abstand */
    float z = 0.12 * uV * sin(p.x * PI * 3.0 + uZeit * 6.0 + uD * 2.0) * (0.6 + 0.4 * cos(p.y * PI));
    z += -0.15 * a * t * t;
    float dz = 0.12 * uV * PI * 3.0 * cos(p.x * PI * 3.0 + uZeit * 6.0 + uD * 2.0);
    p.z += z;
    schatten = clamp(-dz * 0.9, -0.25, 0.6) + 0.2 * a * t;
  } else {
    /* Rollen: ab der Rollkante wird x auf einen Zylinder gelegt (Page-Curl), Richtung Betrachter */
    float r = 0.12;
    float kante = 0.5 - 0.42 * min(a, 1.2);
    float x = p.x * s;
    if (x > kante) {
      float th = (x - kante) / r;
      if (th < PI) { x = kante + r * sin(th); p.z = r * (1.0 - cos(th)); }
      else { x = kante - (x - kante - PI * r); p.z = 2.0 * r; }
      schatten = 0.5 * sin(min(th, PI)) ;
      rueck = step(PI * 0.5, th);
    }
    p.x = x * s;
    float w = s * 0.12 * a;
    p = vec3(p.x * cos(w) + p.z * sin(w), p.y, -p.x * sin(w) + p.z * cos(w));
  }
  vSchatten = schatten; vRueck = rueck;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`;
const fragment = /* glsl */`
precision highp float;
uniform sampler2D uTex;
varying vec2 vUv; varying float vSchatten; varying float vRueck;
void main() {
  vec3 c = texture2D(uTex, vUv).rgb;
  vec3 papier = vec3(0.914, 0.898, 0.863);
  if (!gl_FrontFacing || vRueck > 0.5) c = papier * (0.82 + 0.18 * vUv.y);
  c *= 1.0 - clamp(vSchatten, -0.25, 0.7) * 0.6;
  gl_FragColor = vec4(c, 1.0);
}`;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2') || probe.getContext('webgl')) {
  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); } catch (e) { renderer = null; }
}
if (!renderer) html.classList.add('kein-gl');
if (renderer) {
  renderer.setClearColor(0x000000, 0);
  szene = new THREE.Scene();
  kamera = new THREE.PerspectiveCamera(30, 1, 10, 20000);
  geos = { 0: new THREE.PlaneGeometry(1, 1, 48, 8), 1: new THREE.PlaneGeometry(1, 1, 48, 8), 2: new THREE.PlaneGeometry(1, 1, 64, 8) };
  canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); renderer = null; html.classList.remove('gl-an'); html.classList.add('kein-gl'); });
}

/* ---------- Titelbilder erst nach den Schriften zeichnen ---------- */
async function bilderBauen() {
  try { await Promise.all([document.fonts.load('800 100px "TikTok Sans"'), document.fonts.load('500 20px "DM Mono"')]); } catch (e) {}
  karten.forEach((k, i) => {
    const c = zeichneTitel(i);
    k.img.src = c.toDataURL('image/jpeg', .86);
    if (renderer) {
      const tex = new THREE.CanvasTexture(c);
      tex.colorSpace = THREE.NoColorSpace;   /* Shader gibt die sRGB-Werte unverändert aus */ tex.anisotropy = 4;
      k.mat = new THREE.ShaderMaterial({ vertexShader: vertex, fragmentShader: fragment, side: THREE.DoubleSide,
        uniforms: { uTex: { value: tex }, uD: { value: 0 }, uV: { value: 0 }, uMode: { value: V[variante].nr }, uZeit: { value: 0 }, uAsp: { value: .8 } } });
      k.mesh = new THREE.Mesh(geos[V[variante].nr], k.mat);
      k.mesh.frustumCulled = false;
      szene.add(k.mesh);
    }
  });
  bereit = true; glPruefen(); schmutzig = true;
}
let bereit = false;
function glPruefen() {
  const an = !!renderer && bereit && !reduziert();
  html.classList.toggle('gl-an', an);
  return an;
}

/* ---------- Position: pos in Kartenschritten (0 = erste Karte in der Mitte) ---------- */
let pos = 0, index = 0, schritt = 300, tween = null, tempo = 0, vGl = 0, vMess = 0;
const expoOut = t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t);   // ≈ cubic-bezier(0.16, 1, 0.3, 1)
const n = karten.length;
function messen() {
  const r0 = karten[0].li.getBoundingClientRect(), r1 = karten[1].li.getBoundingClientRect();
  schritt = (r1.left - r0.left) || 300;
  if (renderer) {
    const w = buehne.clientWidth, h = buehne.clientHeight;
    renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5));
    renderer.setSize(w, h, false);
    kamera.aspect = w / h; kamera.position.z = (h / 2) / Math.tan(THREE.MathUtils.degToRad(15)); kamera.updateProjectionMatrix();
  }
  schmutzig = true;
}
function geheZu(i, sofort) {
  i = Math.max(0, Math.min(n - 1, i));
  const neu = i !== index; index = i;
  if (sofort || reduziert()) { tween = null; pos = i; }
  else tween = { von: pos, nach: i, start: performance.now(), dauer: 900 };
  aktivSetzen(neu);
  schmutzig = true;
}
function aktivSetzen(neu) {
  const a = ARBEITEN[index], box = $('[data-aktiv]');
  karten.forEach((k, i) => k.li.toggleAttribute('data-aktiv', i === index));
  $('[data-nr]').textContent = String(index + 1).padStart(2, '0');
  $('[data-zurueck]').disabled = index === 0; $('[data-vor]').disabled = index === n - 1;
  const fuellen = () => { $('[data-a-titel]').textContent = a.titel; $('[data-a-art]').textContent = a.art; $('[data-a-jahr]').textContent = a.jahr; box.classList.remove('raus'); };
  if (neu && !reduziert()) { box.classList.add('raus'); clearTimeout(aktivSetzen.t); aktivSetzen.t = setTimeout(fuellen, 160); } else fuellen();
  if (neu) ansage.textContent = `Arbeit ${index + 1} von ${n}: ${a.titel}, ${a.art}, ${a.jahr}`;
}

/* ---------- Ziehen mit Trägheit (Pointer Events) ---------- */
let zug = null, unterdrueckeKlick = false;
buehne.addEventListener('pointerdown', e => {
  if (e.button !== 0) return;
  stopVorfuehrung();
  zug = { x: e.clientX, start: pos, t: performance.now(), lx: e.clientX, lt: performance.now(), v: 0, bewegt: false, id: e.pointerId };
  tween = null; buehne.classList.add('zieht');
});
addEventListener('pointermove', e => {
  if (!zug || e.pointerId !== zug.id) return;
  const dx = e.clientX - zug.x;
  if (!zug.bewegt && Math.abs(dx) > 6) { zug.bewegt = true; try { buehne.setPointerCapture(e.pointerId); } catch (_) {} }
  if (!zug.bewegt) return;
  let p = zug.start - dx / schritt;
  if (p < 0) p *= .35; if (p > n - 1) p = n - 1 + (p - (n - 1)) * .35;   // Gummiband an den Enden
  pos = p;
  const jetzt = performance.now(), dt = Math.max(1, jetzt - zug.lt);
  zug.v = zug.v * .7 + ((e.clientX - zug.lx) / dt * 1000) * .3; zug.lx = e.clientX; zug.lt = jetzt;
  schmutzig = true;
});
function loslassen(e) {
  if (!zug || (e && e.pointerId !== zug.id)) return;
  buehne.classList.remove('zieht');
  if (zug.bewegt) {
    unterdrueckeKlick = true; setTimeout(() => unterdrueckeKlick = false, 50);
    const wurf = -zug.v * .18 / schritt;          // Trägheit: 180 ms Wurfweite
    geheZu(Math.round(pos + Math.max(-1.5, Math.min(1.5, wurf))));
  }
  zug = null;
}
addEventListener('pointerup', loslassen); addEventListener('pointercancel', loslassen);
/* Tab auf eine Karte holt sie in die Mitte: der Fokus landet nie im Unsichtbaren */
karten.forEach((k, i) => k.a.addEventListener('focus', () => { if (i !== index && !zug) { stopVorfuehrung(); geheZu(i); } }));
karten.forEach((k, i) => k.a.addEventListener('click', e => {
  e.preventDefault();
  if (unterdrueckeKlick) return;
  if (i !== index) { stopVorfuehrung(); geheZu(i); k.a.focus({ preventScroll: true }); }
  else ansage.textContent = `${k.daten.titel}: Projektseite öffnen (in der Demo ohne Ziel)`;
}));
$('[data-zurueck]').addEventListener('click', () => { stopVorfuehrung(); geheZu(index - 1); });
$('[data-vor]').addEventListener('click', () => { stopVorfuehrung(); geheZu(index + 1); });
$('[data-karussell]').addEventListener('keydown', e => {
  const ziel = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: n - 1 }[e.key];
  if (ziel === undefined) return;
  e.preventDefault(); stopVorfuehrung(); geheZu(ziel);
  if (e.target.closest('.karte')) karten[index].a.focus({ preventScroll: true });
});

/* ---------- Varianten ---------- */
function werteZeigen() {
  werteEl.innerHTML = reduziert()
    ? '<span>Reduziert <b>keine Verformung, Wechsel ohne Animation</b></span>'
    : !renderer ? '<span>Ohne WebGL <b>CSS rotateY 26° · scale 0.86 je Schritt</b></span>'
    : V[variante].werte.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
}
function setzeVariante(v) {
  variante = v;
  knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.variante === v ? 'true' : 'false'));
  const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(null, '', u);
  karten.forEach(k => { if (k.mesh) { k.mesh.geometry = geos[V[v].nr]; k.mat.uniforms.uMode.value = V[v].nr; } });
  werteZeigen(); schmutzig = true;
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.variante); ansage.textContent = 'Verformung: ' + V[b.dataset.variante].name; }));

/* ---------- Schleife ---------- */
let letzte = performance.now(), zeit = 0, posAlt = 0;
function bild(jetzt) {
  requestAnimationFrame(bild);
  const dt = Math.min(.05, (jetzt - letzte) / 1000); letzte = jetzt;
  if (vorfuehrung) vorfuehrSchritt(jetzt);
  if (tween) {
    const t = Math.min(1, (jetzt - tween.start) / tween.dauer);
    pos = tween.von + (tween.nach - tween.von) * expoOut(t);
    if (t >= 1) tween = null;
    schmutzig = true;
  }
  /* Tempo in px/s aus der Positionsänderung, normiert und gelerpt (für die Welle) */
  vMess = (pos - posAlt) * schritt / Math.max(dt, 1e-3); posAlt = pos;
  const vZiel = reduziert() ? 0 : Math.max(-1, Math.min(1, vMess / 900));
  vGl += (vZiel - vGl) * Math.min(1, dt * 6);
  if (Math.abs(vGl) < 1e-3) vGl = 0;
  const bewegt = schmutzig || vGl !== 0 || tween || zug;
  if (!bewegt) return;
  zeit += dt;
  spur.style.transform = `translate3d(${-pos * schritt}px, 0, 0)`;
  const gl = glPruefen();
  karten.forEach((k, i) => {
    const d = i - pos;
    if (!gl && !reduziert() && !renderer) {   // CSS-Fallback
      const a = Math.min(Math.abs(d), 1.6), s = Math.sign(d);
      k.li.style.transformOrigin = s > 0 ? 'left center' : 'right center';
      k.li.style.transform = `rotateY(${-s * a * 26}deg) scale(${1 - a * .14})`;
    } else k.li.style.transform = '';
    if (gl && k.mesh) {
      const r = k.img.getBoundingClientRect(), b = buehne.getBoundingClientRect();
      const sichtbar = r.right > b.left - r.width && r.left < b.right + r.width;
      k.mesh.visible = sichtbar;
      if (sichtbar) {
        k.mesh.position.set(r.left + r.width / 2 - b.left - b.width / 2, -(r.top + r.height / 2 - b.top - b.height / 2), 0);
        k.mesh.scale.set(r.width, r.height, r.width);
        k.mat.uniforms.uD.value = d; k.mat.uniforms.uV.value = vGl; k.mat.uniforms.uZeit.value = zeit;
      }
    }
  });
  if (gl) renderer.render(szene, kamera);
  schmutzig = false;
}

/* ---------- Vorführung: gedachter Zeiger zieht, lässt los, Karte rastet ein ---------- */
let vorfuehrung = false, vStart = 0, vRichtung = 1, vZug = null;
function vorfuehrSchritt(jetzt) {
  const zyklus = 2600, s = (jetzt - vStart) % zyklus, runde = Math.floor((jetzt - vStart) / zyklus);
  const b = buehne.getBoundingClientRect(), cx = b.width / 2, cy = b.height / 2;
  if (!vZug || vZug.runde !== runde) {
    if (index + vRichtung > n - 1 || index + vRichtung < 0) vRichtung *= -1;
    vZug = { runde, start: index, los: false };
  }
  const weg = 1.1 * vRichtung;
  let x;
  if (s < 300) { x = cx + vRichtung * 120; zeigerEl.classList.remove('drueckt'); }
  else if (s < 1000) {                               // ziehen
    const t = (s - 300) / 700, e = t * t * (3 - 2 * t);
    zeigerEl.classList.add('drueckt'); tween = null;
    pos = vZug.start + weg * e * .62; schmutzig = true;
    x = cx + vRichtung * 120 - vRichtung * e * .62 * schritt;
  } else {
    if (!vZug.los) { vZug.los = true; zeigerEl.classList.remove('drueckt'); geheZu(vZug.start + vRichtung); }
    x = cx + vRichtung * 120 - vRichtung * .62 * schritt;
  }
  zeigerEl.style.transform = `translate(${x}px, ${cy}px)`;
}
function stopVorfuehrung() { if (!vorfuehrung) return; vorfuehrung = false; html.classList.remove('vorfuehrung'); zeigerEl.classList.remove('drueckt'); }
const START = 1;   /* zweite Arbeit in der Mitte: links und rechts liegen Nachbarn */
function neustart() { stopVorfuehrung(); setzeVariante(variante); geheZu(START, true); if (!reduziert()) { pos = START - .6; geheZu(START); } }
function vorfuehren() { neustart(); geheZu(1, true); vorfuehrung = true; vRichtung = 1; vZug = null; vStart = performance.now(); html.classList.add('vorfuehrung'); }
function pause() { stopVorfuehrung(); }
function weiter() { vorfuehren(); }

new MutationObserver(() => { werteZeigen(); glPruefen(); if (reduziert()) { tween = null; pos = index; } karten.forEach(k => k.li.style.transform = ''); schmutzig = true; }).observe(html, { attributes: true, attributeFilter: ['class'] });
let rz = 0;
addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { messen(); pos = index; }, 100); });
new ResizeObserver(() => messen()).observe(buehne);

messen(); setzeVariante(variante); geheZu(START, true);
bilderBauen();
requestAnimationFrame(bild);

</script>
</body>
</html>