Zum Inhalt
← Hero & Section-Layouts

Testimonials neu gedacht

Ein Kundenzitat als große Schlagzeile, zeilenweise aus der Maske enthüllt, dazu Kundenliste mit Hover-Zitat und eine Masonry-Wand.

Wenige, sehr konkrete Stimmen mit Produkt oder Projekt daneben: Freelancer, Kanzlei, Praxis, Immobilien, Hotel. Eine Stimme pro Bildschirm, gelesen wie eine Schlagzeile.

Statt Slider mit Sternchen und Stockfoto steht ein einzelnes, konkretes Zitat als großer Editorial-Satz da. Name, Ort und Kontext klein darunter, Produkt oder Porträt sparsam daneben. Mehrere Stimmen erscheinen als typografische Kundenliste mit Zitat bei Hover, als Notizzettel beim Scrollen oder als Wand aus unterschiedlich großen Karten. Beim Wechsel fährt das Zitat zeilenweise ein. Jedes Zitat verlinkt auf das zugehörige Produkt oder die Case Study.

Ein groß gesetztes, konkretes Zitat liest sich wie eine Schlagzeile. Details wie Ort, Dauer und ein messbares Ergebnis machen es glaubwürdiger als zehn allgemeine Lobeshymnen.

Wann einsetzen

Dienstleister, Studios, Freelancer und Shops mit echten, konkreten Kundenstimmen. Gut für Bewertungsbereiche in Mode- und Beauty-Shops.

Wann nicht

Bei erfundenen oder austauschbaren Zitaten. Nicht als Auto-Slider ohne Steuerung.

Varianten

  • Ein großes Editorial-Zitat mit Vor/Zurück
  • Kundenliste mit Zitat bei Hover oder Fokus
  • Masonry-Wand (display: grid-lanes mit Fallback)
  • Notizzettel-Zitate, die beim Scrollen auftauchen
  • Eigene Testimonial-Unterseite
  • Stimmen-Reihe: echte Stimmen unterschiedlicher Länge als Wischreihe, die Höhe folgt der aktiven Stimme; Name, Ort, Datum und Quelle, keine Sterne, Echtheitshinweis direkt darunter

Umsetzung

Zitat als <figure><blockquote>…</blockquote><figcaption>…</figcaption></figure>. Großes Zitat mit hängendem Anführungszeichen (hanging-punctuation in Safari oder negativer text-indent in der ersten Zeile). Zeilen-Enthüllung: Text in Zeilen teilen, jede Zeile in eine Maske mit overflow: clip, dann yPercent 105 → 0 mit power3.out, 600–900 ms, 60–100 ms Versatz. Wand: display: grid-lanes (Safari 26.4) mit Fallback über CSS columns oder display: grid. Notizzettel: absolut positionierte Karten mit ScrollTrigger oder Scroll-getriggerten CSS-Animationen.

Robust umsetzen

Telefon. Ein Zitat pro Bildschirm, Wechsel per Wischen oder Pfeiltasten. In der Liste steht das Zitat über den Namen, Tippen ersetzt Hover. Die Wand wird einspaltig.

Ohne Bewegung. Kein Auto-Wechsel, keine Zeilen- oder Kartenbewegung. Zitate wechseln nur auf Wunsch mit kurzer Einblendung.

Barrierefrei. Keine automatisch rotierenden Slider ohne Pause. Zitat und Quelle semantisch in figure und figcaption verbinden. Listeneinträge sind Buttons mit aria-pressed und reagieren auch auf Fokus. Der Zitatbereich ist eine aria-live-Region (polite), Pfeiltasten wechseln das Zitat.

Code

<!DOCTYPE html>
<!--
  Testimonials neu gedacht
  Fundus · Hero & Section-Layouts · https://cecodesign.de/lab/fundus/hero-layouts/testimonials-neu-gedacht/
  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>Testimonials neu gedacht · 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: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-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: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Hedvig Letters Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hedvig-letters-serif:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Google Sans Flex'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 1000; font-display: swap; }
@font-face { font-family: 'EB Garamond'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/eb-garamond:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 400 800; font-style: italic; font-display: swap; }

/* Welt: Rezept „Kollektion und Ritual“ (mode-beauty-kollektion). Marke RUESS, Hautpflege aus Luzern. */
:root {
  --grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #6a6359; --signal: #a3402a;
  --kurve: cubic-bezier(0.25, 1, 0.5, 1);   /* power3.out (= easeOutQuart), auch Totemes Karussell-Kurve */
  --ui: 200ms;                              /* UI-Tempo */
  --bild: 1000ms;                           /* Bildüberblendung */
  --rand: clamp(16px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; }
.mono { font: 400 11px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.leise { color: var(--meta); }
:focus-visible { outline: 1.5px solid var(--text); outline-offset: 3px; }

/* Kopf */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 22px var(--rand); }
.marke { display: flex; align-items: baseline; gap: 14px; text-decoration: none; }
.marke b { font-weight: 500; font-stretch: 125%; font-size: 15px; letter-spacing: .3em; }
.modi { display: flex; gap: 2px; }
.modi button { height: 30px; padding: 0 12px; border-radius: 999px; color: var(--meta); transition: color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.modi button:hover { color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); }

/* Bühne */
.buehne { min-height: calc(100svh - 74px); display: grid; grid-template-rows: auto 1fr auto; gap: clamp(20px, 3vw, 40px); padding: 0 var(--rand) clamp(18px, 2.4vw, 28px); }
.rubrik { display: flex; justify-content: space-between; gap: 12px 24px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid var(--linie); }
.rubrik h2 { font: inherit; }
.ansicht { align-self: center; min-width: 0; }
.ansicht[hidden] { display: none; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 18px; color: var(--meta); font-size: 10.5px; }
.werte b { color: var(--text); font-weight: 400; }

/* Gemeinsam: Zitat-Typo */
.zitat-text { font-weight: 300; font-stretch: 125%; letter-spacing: -.01em; text-wrap: pretty; }
.zitat-text em, .karte em { font-family: 'EB Garamond', Georgia, serif; font-style: italic; font-weight: 400; font-stretch: 100%; font-size: 1.1em; letter-spacing: 0; line-height: .9; }
.js .zitat-text { visibility: hidden; }
.zeile-maske { display: block; overflow: clip; padding-block: .08em; margin-block: -.08em; padding-inline-start: .7em; margin-inline-start: -.7em; }
.zeile { display: block; }
/* Hängendes «: Einzug nur in der ersten Zeile, die Maske reicht dafür nach links */
.zitat-text .zeile { text-indent: 0; }
.zitat-text .zeile-maske:first-child .zeile { text-indent: inherit; }
.quelle { display: grid; gap: 4px; }
.quelle .name { font-size: 15px; font-weight: 500; }

/* Produktkachel: einheitliche Off-White-Fläche, 4:5 */
.kachel { position: relative; aspect-ratio: 4 / 5; background: radial-gradient(110% 75% at 50% 32%, #f4f0ea 0%, #e9e4dc 62%, #e1dbd2 100%); overflow: hidden; }
.kachel .bild { position: absolute; inset: 0; opacity: 0; transition: opacity var(--bild) var(--kurve) 120ms; }
.kachel .bild.an { opacity: 1; }
.kachel .bild.weg { opacity: 0; transition: opacity 300ms var(--kurve); }
.kachel svg { width: 100%; height: 100%; display: block; }
.produkt-name { font-size: 15px; font-weight: 500; }
.formel { color: var(--meta); }
.textlink { display: inline-flex; gap: 8px; align-items: center; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; transition: text-decoration-thickness 100ms var(--kurve); }
.textlink:hover { text-decoration-thickness: 2px; }

/* Variante 1: ein großes Zitat */
.zitat-raster { display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, 23vw); gap: clamp(28px, 6vw, 112px); align-items: end; }
.zitat-gross .zitat-text { font-size: clamp(28px, 4.6vw, 72px); line-height: 1.05; text-indent: -.52em; max-width: 16.5em; }
.zitat-gross .quelle { margin-top: clamp(22px, 3vw, 40px); grid-template-columns: auto 1fr; align-items: baseline; column-gap: 20px; }
.steuerung { display: flex; align-items: center; gap: 14px; margin-top: clamp(22px, 3vw, 36px); }
.pfeil { width: 44px; height: 44px; border: 1px solid var(--linie); border-radius: 50%; display: grid; place-items: center; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.pfeil:hover { border-color: var(--text); }
.pfeil svg { width: 16px; height: 16px; }
.zaehler { min-width: 64px; text-align: center; }
.produkt { display: grid; gap: 6px; }
.produkt .kachel { margin-bottom: 10px; max-height: min(54svh, 520px); }
.produkt .textlink { margin-top: 8px; font-size: 13px; }

/* Variante 2: Kundenliste, Zitat bei Hover */
.liste-raster { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: clamp(28px, 6vw, 112px); align-items: center; }
.stimmen { list-style: none; border-bottom: 1px solid var(--linie); }
.stimme { width: 100%; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: baseline; gap: 12px; padding: clamp(10px, 1.3vw, 16px) 0; border-top: 1px solid var(--linie); text-align: left; }
.stimme .s-name { font-weight: 300; font-stretch: 112%; font-size: clamp(22px, 2.5vw, 38px); line-height: 1.1; transition: color var(--ui) var(--kurve); }
.stimme .s-pfeil { opacity: 0; transform: translateX(-10px); transition: opacity var(--ui) var(--kurve), transform 300ms var(--kurve); }
.stimme .s-meta { color: var(--meta); text-align: right; transition: color var(--ui) var(--kurve); }
.stimmen.hat-aktiv .stimme:not([aria-pressed='true']) .s-name { color: #aaa398; }
.stimme[aria-pressed='true'] .s-pfeil { opacity: 1; transform: none; }
.stimme[aria-pressed='true'] .s-meta { color: var(--text); }
.panel { display: grid; gap: clamp(18px, 2.4vw, 28px); }
.panel .zitat-text { font-size: clamp(22px, 2.3vw, 36px); line-height: 1.14; font-stretch: 112%; text-indent: -.5em; min-height: 5.7em; }
.produktzeile { display: grid; grid-template-columns: 96px 1fr; gap: 16px; align-items: end; padding-top: 18px; border-top: 1px solid var(--linie); }
.produktzeile > div { display: grid; gap: 4px; }

/* Variante 3: Wand. Native grid-lanes, Fallback CSS columns */
.wand { columns: 4 230px; column-gap: 14px; }
.wand > .karte { break-inside: avoid; margin-bottom: 14px; contain: paint; }
@supports (display: grid-lanes) {
  .wand { display: grid-lanes; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; columns: auto; }
  .wand > .karte { margin: 0; display: grid; }
}
.karte { background: var(--flaeche); padding: clamp(18px, 1.8vw, 26px); display: grid; gap: 18px; }
.karte blockquote { font-weight: 300; font-stretch: 112%; font-size: 18px; line-height: 1.3; text-indent: -.45em; text-wrap: pretty; }
.karte.kurz blockquote { font-size: clamp(22px, 1.9vw, 28px); line-height: 1.12; font-stretch: 125%; }
.karte figcaption { display: grid; gap: 3px; }
.karte .name { font-size: 14px; font-weight: 500; }
.karte .textlink { font-size: 12px; margin-top: 6px; }
.karte.produktkarte { padding: 0; gap: 0; background: none; }
.karte.produktkarte .kachel .bild { opacity: 1; }
.karte.produktkarte p { padding: 12px 2px 0; }
.karte.dunkel { background: var(--text); color: var(--grund); }
.karte.dunkel .gross { font-weight: 300; font-stretch: 125%; font-size: clamp(48px, 5vw, 76px); line-height: .95; font-variant-numeric: tabular-nums; }
.karte.dunkel .leise { color: #a8a196; }

/* Fuß */
.fuss { display: grid; grid-template-columns: 1.4fr 1fr auto; gap: 16px 32px; padding: 26px var(--rand) 40px; border-top: 1px solid var(--linie); color: var(--meta); }
.fuss p:first-child { max-width: 46ch; }


/* Variante 4: Stimmen-Reihe, Höhe folgt der aktiven Stimme (Rezept praxis-gesundheit-ruhig: Physiotherapie an der Ilmenau, Lüneburg) */
.marke-pr { display: none; }
html[data-v="stimmen-reihe"] { --grund: #F5F7F7; --text: #363B3B; --flaeche: #E2E0DE; --linie: #D6D9D9; --meta: #596666; --kurve: cubic-bezier(0.2, 0, 0.38, 0.9); background: #F5F7F7; font-family: 'Google Sans Flex', system-ui, sans-serif; }
html[data-v="stimmen-reihe"] :is(.marke, .rubrik, .fuss) { display: none; }
html[data-v="stimmen-reihe"] .marke-pr { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; text-decoration: none; }
.marke-pr b { font: 400 1.375rem/1 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; color: #005450; letter-spacing: -.01em; }
.marke-pr span { font-size: .875rem; color: #596666; }
html[data-v="stimmen-reihe"] .buehne { grid-template-rows: 1fr auto; }
.sr { --sr-k: cubic-bezier(0.2, 0, 0.38, 0.9); --sr-hoehe: 400ms; --sr-breite: min(640px, 82%); display: grid; gap: clamp(18px, 2.4vw, 28px); border-top: 1px solid #D6D9D9; padding-top: clamp(20px, 3vw, 36px); font-family: 'Google Sans Flex', system-ui, sans-serif; color: #363B3B; }
.sr-kopf { display: flex; justify-content: space-between; align-items: end; gap: 16px 32px; flex-wrap: wrap; }
.sr-kopf h2 { font: 400 clamp(1.75rem, 1.3rem + 1.6vw, 2.75rem)/1.1 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; letter-spacing: -.01em; max-width: 20ch; text-wrap: balance; }
.sr-steuer { display: flex; align-items: center; gap: 10px; }
.sr-zahl { min-width: 4.5em; text-align: center; font-size: .9375rem; font-variant-numeric: tabular-nums; color: #596666; }
.sr-zahl b { color: #363B3B; font-weight: 500; }
.sr-pfeil { width: 44px; height: 44px; border-radius: 50%; border: 1px solid #b9bfbf; display: grid; place-items: center; color: #363B3B; transition: border-color 150ms var(--sr-k), background-color 150ms var(--sr-k); }
.sr-pfeil:hover { border-color: #005450; background: #e7eded; }
.sr-pfeil svg { width: 16px; height: 16px; }
/* Fenster: Höhe = Höhe der aktiven Stimme (per JS gemessen), Übergang 400 ms; die Stimmen stehen oben bündig */
.sr-spur { display: flex; align-items: flex-start; gap: clamp(12px, 1.6vw, 20px); overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; list-style: none;
  transition: height var(--sr-hoehe) var(--sr-k); outline: none; overscroll-behavior-x: contain; }
.sr-spur::-webkit-scrollbar { display: none; }
.sr-spur::after { content: ''; flex: 0 0 calc(100% - var(--sr-breite) - clamp(12px, 1.6vw, 20px)); height: 1px; }   /* damit auch die letzte Stimme links einrasten kann */
.sr-spur:focus-visible { box-shadow: 0 0 0 2px #363B3B; border-radius: 16px; }
.reduziert .sr-spur { transition: none; }
.sr-stimme { flex: 0 0 var(--sr-breite); scroll-snap-align: start; scroll-snap-stop: always; }
.sr-stimme figure { display: grid; gap: 22px; padding: clamp(22px, 2.6vw, 34px); border-radius: 16px; background: #E2E0DE; }
.sr-stimme blockquote p { font: 400 clamp(1.25rem, 1rem + .8vw, 1.625rem)/1.4 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; text-wrap: pretty; max-width: 40ch; }
.sr-stimme figcaption { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 16px; align-items: baseline; padding-top: 16px; border-top: 1px solid rgba(54, 59, 59, .16); font-size: .9375rem; }
.sr-stimme figcaption b { font-weight: 600; }
.sr-stimme figcaption .ort { color: #596666; grid-column: 1; }
.sr-stimme figcaption .quelle-sr { grid-column: 2; grid-row: 1 / span 2; justify-self: end; text-align: right; color: #596666; font-size: .8125rem; line-height: 1.4; font-variant-numeric: tabular-nums; }
.sr-stimme figcaption .quelle-sr span { display: block; color: #363B3B; }
.sr-stimme:not(.aktiv) figure { background: #ebeae8; }
.sr-echt { max-width: 72ch; font-size: .875rem; line-height: 1.55; color: #596666; padding: 14px 16px; border-left: 3px solid #FFC776; background: #fff; border-radius: 0 12px 12px 0; }
.sr-echt b { color: #363B3B; font-weight: 600; }
.sr :is(button):focus-visible { outline: 2px solid #363B3B; outline-offset: 3px; }
.vorschau .sr-echt { display: none; }
@media (max-width: 760px) {
  .sr { --sr-breite: 88%; }
  .sr-stimme figcaption { grid-template-columns: minmax(0, 1fr); }
  .sr-stimme figcaption .quelle-sr { grid-column: 1; grid-row: auto; justify-self: start; text-align: left; margin-top: 8px; }
  .marke-pr span { display: none; }
}

/* gedachter Zeiger der Vorführung */
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.zeiger { position: fixed; left: 0; top: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%; border: 1.5px solid var(--text); background: rgba(246, 243, 238, .5); pointer-events: none; opacity: 0; z-index: 9; transition: opacity 300ms var(--kurve); }
:root.vorfuehrung .zeiger.an { opacity: 1; }

/* Telefon */
@media (max-width: 760px) {
  .kopf { padding-block: 16px; flex-wrap: wrap; row-gap: 10px; }
  .marke span { display: none; }
  .buehne { min-height: auto; padding-bottom: 28px; }
  .zitat-raster, .liste-raster { grid-template-columns: 1fr; }
  .zitat-gross .zitat-text, .panel .zitat-text { text-indent: 0; }
  .zitat-text .zeile-maske { padding-inline-start: .2em; margin-inline-start: -.2em; }
  .zitat-gross .quelle { grid-template-columns: 1fr; }
  .produkt { grid-template-columns: 84px 1fr; column-gap: 16px; align-items: end; grid-template-rows: auto auto auto 1fr; }
  .produkt .kachel { grid-row: 1 / 5; margin: 0; }
  .liste-raster .panel { order: -1; }
  .panel .zitat-text { min-height: 4.7em; }
  .karte.produktkarte { grid-template-columns: 42% 1fr; align-items: end; column-gap: 16px; }
  .karte.produktkarte .kachel { grid-row: 1 / 3; }
  .karte.produktkarte p { padding: 0; }
  .stimme { grid-template-columns: 18px minmax(0, 1fr); }
  .stimme .s-meta { grid-column: 2; text-align: left; }
  .fuss { grid-template-columns: 1fr; }
}
@media (hover: none) {
  .stimme .s-pfeil { transition-duration: 0ms; }
}

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .fuss { display: none; }
.vorschau .buehne { height: calc(100svh - 74px); overflow: hidden; }
.vorschau .ansicht[data-ansicht='wand'] { align-self: start; mask-image: linear-gradient(#000 78%, transparent); }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg class="defs" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
  <defs>
    <filter id="weich" x="-50%" y="-200%" width="200%" height="500%"><feGaussianBlur stdDeviation="7"/></filter>
    <linearGradient id="g-glas" x1="0" x2="1"><stop offset="0" stop-color="#5e7873"/><stop offset=".16" stop-color="#93aba5"/><stop offset=".28" stop-color="#c6d6d1"/><stop offset=".42" stop-color="#9cb3ad"/><stop offset=".82" stop-color="#718b86"/><stop offset="1" stop-color="#566f6a"/></linearGradient>
    <linearGradient id="g-ton" x1="0" x2="1"><stop offset="0" stop-color="#9f7b60"/><stop offset=".18" stop-color="#c7a68c"/><stop offset=".32" stop-color="#d9bfa8"/><stop offset=".6" stop-color="#c09d81"/><stop offset="1" stop-color="#94725a"/></linearGradient>
    <linearGradient id="g-deckel" x1="0" x2="1"><stop offset="0" stop-color="#d3cdc4"/><stop offset=".24" stop-color="#f7f4ef"/><stop offset=".6" stop-color="#ebe6de"/><stop offset="1" stop-color="#c9c2b8"/></linearGradient>
    <linearGradient id="g-tube" x1="0" x2="1"><stop offset="0" stop-color="#c3bdb3"/><stop offset=".2" stop-color="#e8e4dd"/><stop offset=".38" stop-color="#f5f2ed"/><stop offset=".74" stop-color="#dcd7cf"/><stop offset="1" stop-color="#bab3a9"/></linearGradient>
    <linearGradient id="g-milch" x1="0" x2="1"><stop offset="0" stop-color="#d4cec5"/><stop offset=".22" stop-color="#f6f4f0"/><stop offset=".44" stop-color="#fcfbf8"/><stop offset=".8" stop-color="#e2ddd5"/><stop offset="1" stop-color="#c9c2b8"/></linearGradient>
    <linearGradient id="g-schwarz" x1="0" x2="1"><stop offset="0" stop-color="#121010"/><stop offset=".3" stop-color="#3b3634"/><stop offset=".46" stop-color="#2a2625"/><stop offset="1" stop-color="#0d0b0b"/></linearGradient>

    <symbol id="p-serum" viewBox="0 0 400 500">
      <ellipse cx="200" cy="430" rx="74" ry="9" fill="#2a2018" opacity=".28" filter="url(#weich)"/>
      <rect x="176" y="214" width="48" height="30" fill="#6d8782"/>
      <rect x="142" y="236" width="116" height="194" rx="16" fill="url(#g-glas)"/>
      <rect x="149" y="282" width="102" height="140" rx="11" fill="#3f5753" opacity=".18"/>
      <rect x="155" y="248" width="7" height="170" rx="3.5" fill="#fff" opacity=".38"/>
      <rect x="168" y="176" width="64" height="44" rx="4" fill="url(#g-schwarz)"/>
      <rect x="180" y="110" width="40" height="72" rx="20" fill="url(#g-schwarz)"/>
      <rect x="160" y="300" width="80" height="80" fill="#f5f2ed" opacity=".94"/>
      <text x="201" y="324" text-anchor="middle" font-family="Zalando Sans" font-stretch="125%" font-weight="500" font-size="11" letter-spacing="3" fill="#121010">RUESS</text>
      <text x="200" y="346" text-anchor="middle" font-family="Fragment Mono" font-size="6.6" letter-spacing=".6" fill="#121010">SEESERUM N°4</text>
      <text x="200" y="358" text-anchor="middle" font-family="Fragment Mono" font-size="5.4" letter-spacing=".5" fill="#8c857b">NIACINAMID 5 %</text>
      <text x="200" y="368" text-anchor="middle" font-family="Fragment Mono" font-size="5.4" letter-spacing=".5" fill="#8c857b">30 ML</text>
    </symbol>
    <symbol id="p-balsam" viewBox="0 0 400 500">
      <ellipse cx="200" cy="430" rx="116" ry="10" fill="#2a2018" opacity=".26" filter="url(#weich)"/>
      <rect x="114" y="316" width="172" height="114" rx="12" fill="url(#g-ton)"/>
      <rect x="128" y="330" width="9" height="88" rx="4.5" fill="#fff" opacity=".22"/>
      <rect x="106" y="262" width="188" height="60" rx="6" fill="url(#g-deckel)"/>
      <rect x="106" y="314" width="188" height="8" rx="2" fill="#000" opacity=".07"/>
      <text x="202" y="298" text-anchor="middle" font-family="Zalando Sans" font-stretch="125%" font-weight="500" font-size="13" letter-spacing="3.6" fill="#121010">RUESS</text>
      <text x="200" y="368" text-anchor="middle" font-family="Fragment Mono" font-size="7" letter-spacing=".7" fill="#3a2a1e">FÖHNBALSAM</text>
      <text x="200" y="382" text-anchor="middle" font-family="Fragment Mono" font-size="5.6" letter-spacing=".5" fill="#5b4535">CERAMIDE 3 % · 50 ML</text>
    </symbol>
    <symbol id="p-fluid" viewBox="0 0 400 500">
      <ellipse cx="200" cy="430" rx="60" ry="8" fill="#2a2018" opacity=".28" filter="url(#weich)"/>
      <path d="M174 376 L150 134 Q149 124 159 124 L241 124 Q251 124 250 134 L226 376 Z" fill="url(#g-tube)"/>
      <rect x="146" y="102" width="108" height="26" rx="2" fill="#d6d1c9"/>
      <path d="M150 110 H250 M150 116 H250 M150 122 H250" stroke="#000" stroke-opacity=".08"/>
      <path d="M166 140 L184 360" stroke="#fff" stroke-width="6" stroke-linecap="round" opacity=".5"/>
      <rect x="170" y="372" width="60" height="58" rx="6" fill="url(#g-schwarz)"/>
      <text x="201" y="174" text-anchor="middle" font-family="Zalando Sans" font-stretch="125%" font-weight="500" font-size="12" letter-spacing="3.2" fill="#121010">RUESS</text>
      <text x="200" y="192" text-anchor="middle" font-family="Fragment Mono" font-size="6.6" letter-spacing=".6" fill="#121010">NEBELFLUID</text>
      <text x="200" y="204" text-anchor="middle" font-family="Fragment Mono" font-size="5.4" letter-spacing=".5" fill="#8c857b">LSF 30 · 50 ML</text>
    </symbol>
    <symbol id="p-milch" viewBox="0 0 400 500">
      <ellipse cx="200" cy="430" rx="76" ry="9" fill="#2a2018" opacity=".26" filter="url(#weich)"/>
      <rect x="176" y="118" width="50" height="18" rx="4" fill="url(#g-schwarz)"/>
      <rect x="220" y="122" width="44" height="7" rx="3" fill="#1b1817"/>
      <rect x="193" y="134" width="14" height="38" fill="#2a2625"/>
      <rect x="140" y="196" width="120" height="234" rx="34" fill="url(#g-milch)"/>
      <rect x="154" y="222" width="9" height="182" rx="4.5" fill="#fff" opacity=".6"/>
      <rect x="178" y="168" width="44" height="32" rx="4" fill="url(#g-schwarz)"/>
      <text x="201" y="290" text-anchor="middle" font-family="Zalando Sans" font-stretch="125%" font-weight="500" font-size="13" letter-spacing="3.6" fill="#121010">RUESS</text>
      <text x="200" y="310" text-anchor="middle" font-family="Fragment Mono" font-size="7" letter-spacing=".7" fill="#121010">MOLKENMILCH</text>
      <text x="200" y="324" text-anchor="middle" font-family="Fragment Mono" font-size="5.6" letter-spacing=".5" fill="#8c857b">MILCHSÄURE 2 % · 150 ML</text>
    </symbol>
  </defs>
</svg>

<header class="kopf">
  <a class="marke" href="#"><b>RUESS</b><span class="mono leise">Hautpflege aus Luzern</span></a>
  <a class="marke-pr" href="#"><b>Physiotherapie an der Ilmenau</b><span>Lüneburg</span></a>
  <div class="modi mono" role="group" aria-label="Variante">
    <button type="button" data-variante="zitat" aria-pressed="true">Zitat</button>
    <button type="button" data-variante="liste" aria-pressed="false">Liste</button>
    <button type="button" data-variante="wand" aria-pressed="false">Wand</button>
    <button type="button" data-variante="stimmen-reihe" aria-pressed="false">Reihe</button>
  </div>
</header>

<main>
<section class="buehne" aria-labelledby="rubrik">
  <div class="rubrik">
    <h2 id="rubrik" class="mono">Bewertungen</h2>
    <p class="mono leise">412 geprüfte Käufe · Ø 4,8 von 5</p>
  </div>

  <!-- Variante 1: ein großes Editorial-Zitat -->
  <div class="ansicht" data-ansicht="zitat">
    <div class="zitat-raster">
      <div>
        <figure class="zitat-gross">
          <blockquote><p class="zitat-text" data-z-text aria-live="polite"></p></blockquote>
          <figcaption class="quelle" data-z-quelle></figcaption>
        </figure>
        <div class="steuerung">
          <button class="pfeil" type="button" data-z-zurueck aria-label="Vorherige Bewertung"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><path d="M14 8H2M7 3 2 8l5 5"/></svg></button>
          <span class="mono zaehler" data-z-zaehler>01 / 05</span>
          <button class="pfeil" type="button" data-z-vor aria-label="Nächste Bewertung"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true"><path d="M2 8h12M9 3l5 5-5 5"/></svg></button>
        </div>
      </div>
      <aside class="produkt" aria-label="Bewertetes Produkt">
        <div class="kachel" data-z-kachel></div>
        <p class="produkt-name" data-z-pname></p>
        <p class="mono formel" data-z-formel></p>
        <a class="textlink" href="#" data-z-link>Zum Produkt</a>
      </aside>
    </div>
  </div>

  <!-- Variante 2: Kundenliste, Hover oder Fokus zeigt das Zitat -->
  <div class="ansicht" data-ansicht="liste" hidden>
    <div class="liste-raster">
      <ol class="stimmen" data-stimmen></ol>
      <figure class="panel">
        <blockquote><p class="zitat-text" data-l-text aria-live="polite"></p></blockquote>
        <figcaption class="produktzeile">
          <div class="kachel" data-l-kachel></div>
          <div><span class="mono leise" data-l-quelle></span><span class="produkt-name" data-l-pname></span><span class="mono formel" data-l-formel></span></div>
        </figcaption>
      </figure>
    </div>
  </div>

  <!-- Variante 3: Wand aus unterschiedlich großen Karten -->
  <div class="ansicht" data-ansicht="wand" hidden>
    <div class="wand" data-wand></div>
  </div>

  <!-- Variante 4: Stimmen-Reihe, Wischreihe mit Höhe der aktiven Stimme (Embla Auto Height, Flickity adaptiveHeight) -->
  <div class="ansicht" data-ansicht="stimmen-reihe" hidden>
    <section class="sr" aria-labelledby="sr-titel">
      <div class="sr-kopf">
        <h2 id="sr-titel">Was Patientinnen und Patienten nach der Behandlung schreiben</h2>
        <div class="sr-steuer">
          <button class="sr-pfeil" type="button" data-sr-schritt="-1" aria-label="Vorherige Stimme"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M14 8H2M7 3 2 8l5 5"/></svg></button>
          <span class="sr-zahl" aria-hidden="true"><b data-sr-nr>01</b> / 05</span>
          <button class="sr-pfeil" type="button" data-sr-schritt="1" aria-label="Nächste Stimme"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M2 8h12M9 3l5 5-5 5"/></svg></button>
        </div>
      </div>
      <ul class="sr-spur" data-sr-spur role="region" aria-roledescription="Karussell" aria-label="Rückmeldungen, mit Pfeiltasten blättern" tabindex="0">
        <li class="sr-stimme aktiv" data-sr-stimme role="group" aria-roledescription="Stimme" aria-label="1 von 5: Ilse Martens">
          <figure>
            <blockquote><p>„Frau Jessen hat mir beim ersten Termin erst einmal erklärt, was sie vorhat und warum. Die Übungen für zu Hause hat sie mir aufgezeichnet, mit Wiederholungen und Pausen. So konnte ich sie zwischen den Terminen allein machen, ohne Angst, etwas falsch zu machen.“</p></blockquote>
            <figcaption><b>Ilse Martens</b><span class="ort">Lüneburg</span><span class="quelle-sr"><span><time datetime="2026-10-14">14.10.2026</time></span>Rückmeldekarte</span></figcaption>
          </figure>
        </li>
        <li class="sr-stimme" data-sr-stimme role="group" aria-roledescription="Stimme" aria-label="2 von 5: Tobias Reinke">
          <figure>
            <blockquote><p>„Rezept abgegeben, zwei Tage später kam der Anruf. Fahrstuhl bis vor die Tür.“</p></blockquote>
            <figcaption><b>Tobias Reinke</b><span class="ort">Adendorf</span><span class="quelle-sr"><span><time datetime="2026-10-02">02.10.2026</time></span>Google-Bewertung</span></figcaption>
          </figure>
        </li>
        <li class="sr-stimme" data-sr-stimme role="group" aria-roledescription="Stimme" aria-label="3 von 5: Hanne Schüler">
          <figure>
            <blockquote><p>„Ich komme mit Rollator. Die Kabine ist groß genug, und niemand hat mich gehetzt. Beim Anziehen hat jemand geholfen, ohne dass ich fragen musste.“</p></blockquote>
            <figcaption><b>Hanne Schüler</b><span class="ort">Lüneburg</span><span class="quelle-sr"><span><time datetime="2026-09-19">19.09.2026</time></span>Rückmeldekarte</span></figcaption>
          </figure>
        </li>
        <li class="sr-stimme" data-sr-stimme role="group" aria-roledescription="Stimme" aria-label="4 von 5: Deniz Kaya">
          <figure>
            <blockquote><p>„Am Telefon wurde gleich geklärt, was die Kasse zahlt und was ich selbst zahle. Keine Überraschung auf der Rechnung.“</p></blockquote>
            <figcaption><b>Deniz Kaya</b><span class="ort">Reppenstedt</span><span class="quelle-sr"><span><time datetime="2026-08-28">28.08.2026</time></span>Google-Bewertung</span></figcaption>
          </figure>
        </li>
        <li class="sr-stimme" data-sr-stimme role="group" aria-roledescription="Stimme" aria-label="5 von 5: Paula Brandt">
          <figure>
            <blockquote><p>„Termine auch um sieben Uhr, vor der Schicht. Das war für mich der Grund.“</p></blockquote>
            <figcaption><b>Paula Brandt</b><span class="ort">Lüneburg</span><span class="quelle-sr"><span><time datetime="2026-08-11">11.08.2026</time></span>Rückmeldekarte</span></figcaption>
          </figure>
        </li>
      </ul>
      <p class="sr-echt"><b>Woher die Stimmen kommen:</b> Rückmeldekarten füllen Patientinnen und Patienten nach Behandlungsende aus und geben sie schriftlich zur Veröffentlichung frei, wir kürzen nichts. Google-Bewertungen übernehmen wir unverändert, prüfen aber nicht, ob die Verfasser bei uns in Behandlung waren. Gezeigt werden die fünf jüngsten.</p>
      <p class="unsichtbar" data-sr-live aria-live="polite" aria-atomic="true"></p>
    </section>
  </div>

  <p class="werte mono" data-werte aria-live="polite"></p>
</section>
</main>

<footer class="fuss mono">
  <p>Alle Bewertungen stammen aus geprüften Käufen. Wir kürzen nichts und antworten auf jede Kritik.</p>
  <p>RUESS Hautpflege · Hertensteinstrasse 12 · <span style="white-space:nowrap">6004 Luzern</span></p>
  <a class="textlink" href="#">Alle 412 Bewertungen</a>
</footer>
<div class="zeiger" aria-hidden="true"></div>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const S = '\u202f', N = '\u00a0';   // schmales Leerzeichen vor %, festes Leerzeichen in Mengen und Preisen

  // Werte aus den Messwerten des Musters: power3.out, 600–900 ms pro Wechsel, 60–100 ms pro Zeile
  const W = {
    zeilen: 0.8, versatz: 0.08,           // großes Zitat
    listeZeilen: 0.7, listeVersatz: 0.06, // Zitat in der Liste
    abgang: 0.3,                          // altes Zitat verlässt die Maske
    karten: 0.8, kartenVersatz: 0.08, kartenWeg: 24,
  };

  const PRODUKTE = {
    serum:  { name: 'Seeserum N°4',      formel: `Niacinamid 5${S}% + Zink 1${S}% · 30${N}ml · CHF${N}46` },
    balsam: { name: 'Föhnbalsam',        formel: `Ceramide 3${S}% + Squalan 10${S}% · 50${N}ml · CHF${N}58` },
    fluid:  { name: 'Nebelfluid LSF 30', formel: `Zinkoxid 12${S}% + Panthenol 2${S}% · 50${N}ml · CHF${N}39` },
    milch:  { name: 'Molkenmilch',       formel: `Milchsäure 2${S}% + Hafer 1${S}% · 150${N}ml · CHF${N}32` },
  };
  // Konkrete Stimmen statt Sternchen: Ort, Hauttyp, Dauer, Produkt
  const STIMMEN = [
    { name: 'Marco Achermann', ort: 'Luzern', haut: 'Trockene Haut', seit: 'seit 7 Monaten', p: 'balsam',
      text: '«Bei Föhn spannt meine Haut sonst bis zum Mittag. Seit dem Balsam merke ich den Wetterwechsel <em>nur noch am Kopfweh.</em>»' },
    { name: 'Lea Brunner', ort: 'Kriens', haut: 'Mischhaut', seit: 'seit 6 Wochen', p: 'serum',
      text: '«Nach sechs Wochen sind die Poren an der Nase sichtbar kleiner. <em>Sonst habe ich nichts geändert.</em>»' },
    { name: 'Nadia Kälin', ort: 'Horw', haut: 'Empfindliche Haut', seit: 'seit einem Jahr', p: 'fluid',
      text: '«Der erste Sonnenschutz, der auf meinen geröteten Wangen nicht brennt. <em>Ich trage ihn jetzt auch im November.</em>»' },
    { name: 'Ursula Bucher', ort: 'Meggen', haut: 'Reife Haut', seit: 'seit 2 Jahren', p: 'balsam',
      text: '«Kein Duft, keine grossen Versprechen auf der Dose. <em>Ein Tiegel reicht mir vier Monate.</em>»' },
    { name: 'Jonas Wey', ort: 'Emmen', haut: 'Ölige Haut', seit: 'seit 3 Wochen', p: 'milch',
      text: '«Ich dachte, Milch sei nichts für fettige Haut. <em>Am Abend glänzt die Stirn nicht mehr.</em>»' },
    { name: 'Nalan Arslan', ort: 'Ebikon', haut: 'Normale Haut', seit: 'seit 5 Monaten', p: 'serum',
      text: '«Im Laden an der Hertensteinstrasse probiert und <em>mit Proben für drei Freundinnen</em> heimgegangen.»' },
    { name: 'Beat Zimmermann', ort: 'Sursee', haut: 'Trockene Haut', seit: 'seit 4 Monaten', p: 'fluid',
      text: '«Endlich ein LSF, der im Bart <em>keine weissen Reste</em> lässt.»' },
    { name: 'Mia Felder', ort: 'Luzern', haut: 'Unreine Haut', seit: 'seit 2 Monaten', p: 'serum',
      text: '«Weniger Pickel vor den Prüfungen. Ob es das Serum war oder weniger Kaffee, weiss ich nicht. <em>Ich bleibe bei beidem.</em>»' },
  ];
  const ZITATE = [0, 1, 2, 3, 4];
  const LISTE = [0, 1, 2, 3, 4, 5];
  const zwei = n => String(n).padStart(2, '0');
  const bild = p => `<svg viewBox="40 60 320 400" role="img" aria-label="${PRODUKTE[p].name}"><use href="#p-${p}"/></svg>`;

  // Produktbild: weiche Überblendung über Opazität (1000 ms), nie Bewegung
  function setzeKachel(box, p) {
    if (box.dataset.p === p) return;
    box.dataset.p = p;
    const neu = document.createElement('div');
    neu.className = 'bild';
    neu.innerHTML = bild(p);
    box.appendChild(neu);
    const alte = [...box.children].filter(c => c !== neu);
    alte.forEach(c => c.classList.add('weg'));   // altes Bild geht schnell (300 ms), neues kommt langsam (1000 ms)
    requestAnimationFrame(() => requestAnimationFrame(() => neu.classList.add('an')));
    setTimeout(() => alte.forEach(c => c.remove()), 1100);
  }

  // Zitat zeilenweise aus der Maske. Eigene Zeilenteilung: Wörter messen (offsetTop),
  // je Zeile eine Maske mit overflow: clip. Kursive <em>-Passagen bleiben erhalten.
  function teileZeilen(el, inhalt) {
    const tmp = document.createElement('div');
    tmp.innerHTML = inhalt;
    // Wörter als Folge von Teilen (z. B. „geändert.“ kursiv + „»“ normal ohne Leerzeichen dazwischen)
    const woerter = [];
    let klebt = false;
    tmp.childNodes.forEach(n => {
      const em = n.nodeName === 'EM';
      n.textContent.split(/(\s+)/).forEach(t => {
        if (!t) return;
        if (/^\s+$/.test(t)) { klebt = false; return; }
        if (klebt && woerter.length) woerter[woerter.length - 1].push({ t, em }); else woerter.push([{ t, em }]);
        klebt = true;
      });
    });
    const wort = w => w.map(x => x.em ? `<em>${x.t}</em>` : x.t).join('');
    el.innerHTML = woerter.map(w => `<span class="wort">${wort(w)}</span>`).join(' ');
    const halb = parseFloat(getComputedStyle(el).fontSize) / 2, zeilen = [];
    let oben = null;
    el.querySelectorAll('.wort').forEach((sp, i) => {
      if (oben === null || sp.offsetTop > oben + halb) { zeilen.push([]); oben = sp.offsetTop; }
      zeilen[zeilen.length - 1].push(woerter[i]);
    });
    // Teile einer Zeile zusammenführen, Leerzeichen zwischen zwei kursiven Wörtern bleibt kursiv
    const satz = z => {
      const teile = [];
      z.forEach((w, i) => {
        if (i) teile.push({ t: ' ', em: z[i - 1][z[i - 1].length - 1].em && w[0].em });
        teile.push(...w);
      });
      const laeufe = [];
      teile.forEach(x => { const l = laeufe[laeufe.length - 1]; if (l && l.em === x.em) l.t += x.t; else laeufe.push({ ...x }); });
      return laeufe.map(x => x.em ? `<em>${x.t}</em>` : x.t).join('');
    };
    el.innerHTML = zeilen.map(z => `<span class="zeile-maske"><span class="zeile">${satz(z)}</span></span>`).join('');
    el._inhalt = inhalt; el._breite = el.clientWidth;
    return [...el.querySelectorAll('.zeile')];
  }
  function enthuelle(el, inhalt, dauer, versatz, verz = 0) {
    gsap.killTweensOf(el);
    el.querySelectorAll('.zeile').forEach(z => gsap.killTweensOf(z));
    gsap.set(el, { visibility: 'visible', opacity: 1 });
    if (reduziert()) { el.innerHTML = inhalt; el._inhalt = null; gsap.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: 'none' }); return; }
    const zeilen = teileZeilen(el, inhalt);
    gsap.from(zeilen, { yPercent: 105, duration: dauer, stagger: versatz, ease: 'power3.out', delay: verz });
  }
  // Neuer Umbruch bei geänderter Breite, ohne erneutes Abspielen
  const beobachter = new ResizeObserver(eintraege => eintraege.forEach(({ target: el }) => {
    if (!el._inhalt || el.clientWidth === el._breite || !el.clientWidth) return;
    clearTimeout(el._uhr);
    el._uhr = setTimeout(() => { el.querySelectorAll('.zeile').forEach(z => gsap.killTweensOf(z)); teileZeilen(el, el._inhalt); }, 120);
  }));
  function abgang(el) {
    const z = el.querySelectorAll('.zeile');
    if (reduziert() || !z.length) return gsap.to(el, { opacity: 0, duration: reduziert() ? 0.15 : W.abgang, ease: 'none' });
    return gsap.to(z, { yPercent: -105, duration: W.abgang, stagger: 0.03, ease: 'power2.in' });
  }
  function blendeEin(el, verz) {
    gsap.killTweensOf(el);
    if (reduziert()) return gsap.set(el, { opacity: 1, y: 0 });
    gsap.fromTo(el, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.6, delay: verz, ease: 'power3.out' });
  }

  /* Variante 1: großes Zitat */
  const z = { text: $('[data-z-text]'), quelle: $('[data-z-quelle]'), zaehler: $('[data-z-zaehler]'), kachel: $('[data-z-kachel]'),
              pname: $('[data-z-pname]'), formel: $('[data-z-formel]'), link: $('[data-z-link]') };
  let zIndex = 0, zLaeuft = null;
  function zeigeZitat(i, sofort) {
    zIndex = (i + ZITATE.length) % ZITATE.length;
    const s = STIMMEN[ZITATE[zIndex]], p = PRODUKTE[s.p];
    const rein = () => {
      enthuelle(z.text, s.text, W.zeilen, W.versatz);
      z.quelle.innerHTML = `<span class="name">${s.name}</span><span class="mono leise">${s.ort} · ${s.haut} · ${s.seit}</span>`;
      blendeEin(z.quelle, 0.35);
    };
    z.zaehler.textContent = `${zwei(zIndex + 1)} / ${zwei(ZITATE.length)}`;
    setzeKachel(z.kachel, s.p);
    z.pname.textContent = p.name; z.formel.textContent = p.formel;
    z.link.textContent = `Zum ${p.name.split(' ')[0]}`;
    if (zLaeuft) { zLaeuft.kill(); zLaeuft = null; }
    if (sofort || !z.text.textContent) { rein(); return; }
    gsap.to(z.quelle, { opacity: 0, duration: 0.2, ease: 'none' });
    zLaeuft = abgang(z.text).eventCallback('onComplete', rein);
  }
  $('[data-z-vor]').addEventListener('click', () => { stoppeVorfuehrung(); zeigeZitat(zIndex + 1); });
  $('[data-z-zurueck]').addEventListener('click', () => { stoppeVorfuehrung(); zeigeZitat(zIndex - 1); });
  // Telefon: wischen
  let wischX = null;
  const zitatFlaeche = $('[data-ansicht="zitat"]');
  zitatFlaeche.addEventListener('touchstart', e => { wischX = e.touches[0].clientX; }, { passive: true });
  zitatFlaeche.addEventListener('touchend', e => {
    if (wischX === null) return;
    const dx = e.changedTouches[0].clientX - wischX; wischX = null;
    if (Math.abs(dx) > 48) { stoppeVorfuehrung(); zeigeZitat(zIndex + (dx < 0 ? 1 : -1)); }
  });
  document.addEventListener('keydown', e => {
    if (variante !== 'zitat' || e.target.closest('input, textarea')) return;
    if (e.key === 'ArrowRight') zeigeZitat(zIndex + 1);
    if (e.key === 'ArrowLeft') zeigeZitat(zIndex - 1);
  });

  /* Variante 2: Liste */
  const l = { liste: $('[data-stimmen]'), text: $('[data-l-text]'), kachel: $('[data-l-kachel]'), quelle: $('[data-l-quelle]'), pname: $('[data-l-pname]'), formel: $('[data-l-formel]') };
  l.liste.innerHTML = LISTE.map((k, i) => {
    const s = STIMMEN[k];
    return `<li><button class="stimme" type="button" data-i="${i}" aria-pressed="false"><span class="s-pfeil" aria-hidden="true">→</span><span class="s-name">${s.name}</span><span class="s-meta mono">${s.ort} · ${s.haut}</span></button></li>`;
  }).join('');
  const knoepfe = [...l.liste.querySelectorAll('.stimme')];
  let lIndex = -1;
  function aktiviere(i, erzwingen) {
    if (i === lIndex && !erzwingen) return;
    lIndex = i;
    const s = STIMMEN[LISTE[i]], p = PRODUKTE[s.p];
    knoepfe.forEach((b, k) => b.setAttribute('aria-pressed', String(k === i)));
    l.liste.classList.add('hat-aktiv');
    enthuelle(l.text, s.text, W.listeZeilen, W.listeVersatz);
    setzeKachel(l.kachel, s.p);
    l.quelle.textContent = `${s.name} · ${s.seit}`;
    l.pname.textContent = p.name; l.formel.textContent = p.formel;
  }
  knoepfe.forEach((b, i) => {
    b.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { stoppeVorfuehrung(); aktiviere(i); } });
    b.addEventListener('focus', () => aktiviere(i));
    b.addEventListener('click', () => { stoppeVorfuehrung(); aktiviere(i); });
  });

  /* Variante 3: Wand */
  const wand = $('[data-wand]');
  const karte = k => {
    const s = STIMMEN[k], kurz = s.text.replace(/<[^>]+>/g, '').length < 78;
    return `<figure class="karte${kurz ? ' kurz' : ''}"><blockquote>${s.text}</blockquote><figcaption><span class="name">${s.name}</span><span class="mono leise">${s.ort} · ${s.haut}</span><a class="textlink mono" href="#">${PRODUKTE[s.p].name}</a></figcaption></figure>`;
  };
  wand.innerHTML = [
    `<div class="karte dunkel"><p class="mono leise">Ø aus 412 Käufen</p><p class="gross">4,8</p><p class="mono leise">Bewertungen erscheinen ungekürzt, auch die kritischen.</p></div>`,
    karte(1), karte(6),
    `<figure class="karte produktkarte"><div class="kachel"><div class="bild">${bild('balsam')}</div></div><p class="produkt-name">Föhnbalsam</p><p class="mono formel">${PRODUKTE.balsam.formel}</p></figure>`,
    karte(0), karte(4), karte(7), karte(2), karte(3), karte(5),
  ].join('');
  const karten = [...wand.children];
  function zeigeWand() {
    gsap.killTweensOf(karten);
    if (reduziert()) { gsap.set(karten, { opacity: 1, y: 0 }); return; }
    gsap.fromTo(karten, { opacity: 0, y: W.kartenWeg }, { opacity: 1, y: 0, duration: W.karten, stagger: W.kartenVersatz, ease: 'power3.out', clearProps: 'transform,opacity' });
  }


  /* Variante 4: Stimmen-Reihe. Scroll-Snap-Reihe, das Fenster nimmt die Höhe der aktiven Stimme an.
     Die Höhe wird gemessen (wie Embla Auto Height und Flickity adaptiveHeight): interpolate-size hilft hier nicht,
     weil height: auto die höchste Stimme der Reihe ergäbe, nicht die aktive. Übergang 400 ms, bei reduzierter Bewegung ohne. */
  const sr = { spur: $('[data-sr-spur]'), nr: $('[data-sr-nr]'), live: $('[data-sr-live]') };
  const srStimmen = [...document.querySelectorAll('[data-sr-stimme]')];
  let srAktiv = 0, srZiel = null, srUhr = null;
  function srHoehe() { sr.spur.style.height = srStimmen[srAktiv].offsetHeight + 'px'; }
  function srWelche() {
    const r = sr.spur.getBoundingClientRect(); let best = 0, d = Infinity;
    srStimmen.forEach((el, i) => { const x = Math.abs(el.getBoundingClientRect().left - r.left); if (x < d) { d = x; best = i; } });
    return best;
  }
  function srMarkieren(i, ansage) {
    if (i === srAktiv && !ansage) return;
    srAktiv = i;
    srStimmen.forEach((el, k) => el.classList.toggle('aktiv', k === i));
    sr.nr.textContent = zwei(i + 1);
    srHoehe();
    if (ansage) sr.live.textContent = srStimmen[i].getAttribute('aria-label');
  }
  sr.spur.addEventListener('scroll', () => {
    const i = srWelche(); if (i !== srAktiv) srMarkieren(i);
    clearTimeout(srUhr); srUhr = setTimeout(() => { srZiel = null; }, 150);
  }, { passive: true });
  function srGehe(i, nutzer = true) {
    i = (i + srStimmen.length) % srStimmen.length; srZiel = i;
    const r = sr.spur.getBoundingClientRect(), b = srStimmen[i].getBoundingClientRect();
    srMarkieren(i, nutzer);   // Höhe wechselt sofort mit, nicht erst nach dem Scrollen
    sr.spur.scrollTo({ left: sr.spur.scrollLeft + b.left - r.left, behavior: reduziert() ? 'auto' : 'smooth' });
  }
  document.querySelectorAll('[data-sr-schritt]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); srGehe((srZiel ?? srAktiv) + +b.dataset.srSchritt); }));
  sr.spur.addEventListener('keydown', e => {
    const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
    if (!d || e.target !== sr.spur) return;
    e.preventDefault(); stoppeVorfuehrung(); srGehe((srZiel ?? srAktiv) + d);
  });
  // Neue Breite (Drehen, Textabstände, Zoom): Höhe neu messen
  new ResizeObserver(() => { if (variante === 'stimmen-reihe') srHoehe(); }).observe(sr.spur);
  srStimmen.forEach(el => new ResizeObserver(() => { if (variante === 'stimmen-reihe' && el.classList.contains('aktiv')) srHoehe(); }).observe(el));

  /* Umschalter */
  const werte = $('[data-werte]');
  const lanes = CSS.supports('display', 'grid-lanes');
  function zeigeWerte() {
    if (reduziert() && variante === 'stimmen-reihe') { werte.innerHTML = '<span>Reduzierte Bewegung: <b>Höhe und Reihe springen ohne Übergang</b></span><span>Keine Sterne · Name, Ort, Datum, Quelle</span>'; return; }
    if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>kein Auto-Wechsel, nur Einblenden 200 ms</b></span><span>Zitat <b>clamp(28px, 4.6vw, 72px)</b></span>'; return; }
    const kurve = '<span>Kurve <b>power3.out · cubic-bezier(0.25, 1, 0.5, 1)</b></span>';
    werte.innerHTML = {
      zitat: `${kurve}<span>Zeilen <b>800 ms</b></span><span>Versatz <b>80 ms</b></span><span>Weg <b>yPercent 105 → 0</b></span><span>Abgang <b>300 ms</b></span><span>Bild <b>aus 300 ms, ein 1000 ms (Opazität)</b></span><span>Zitat <b>clamp(28px, 4.6vw, 72px)</b></span>`,
      liste: `${kurve}<span>Auslöser <b>Hover · Fokus · Tippen</b></span><span>Zeilen <b>700 ms</b></span><span>Versatz <b>60 ms</b></span><span>Namen <b>200 ms</b></span><span>Bild <b>aus 300 ms, ein 1000 ms</b></span>`,
      'stimmen-reihe': `<span>Kurve <b>cubic-bezier(0.2, 0, 0.38, 0.9)</b></span><span>Höhe <b>gemessen, 400 ms</b></span><span>Reihe <b>scroll-snap x mandatory, start</b></span><span>Breite <b>min(640px, 82 %)</b></span><span>Keine Sterne · Name, Ort, Datum, Quelle</span>`,
      wand: `${kurve}<span>Layout <b>${lanes ? 'display: grid-lanes' : 'columns (Fallback, kein grid-lanes)'}</b></span><span>Karten <b>800 ms</b></span><span>Versatz <b>80 ms</b></span><span>Weg <b>24 px</b></span>`,
    }[variante];
  }
  // Variante aus der URL (?v=zitat|liste|wand), unbekannt oder leer: erste Variante
  const VARIANTEN = ['zitat', 'liste', 'wand', 'stimmen-reihe'];
  const ausUrl = new URLSearchParams(location.search).get('v');
  let variante = VARIANTEN.includes(ausUrl) ? ausUrl : VARIANTEN[0];
  html.dataset.v = variante;
  function schreibeUrl(v) {
    const u = new URL(location.href);
    u.searchParams.set('v', v);   // andere Parameter (reduziert, vorschau) bleiben stehen
    history.replaceState(history.state, '', u);
  }
  function setzeVariante(neu) {
    variante = neu; html.dataset.v = neu;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === neu)));
    document.querySelectorAll('[data-ansicht]').forEach(a => { a.hidden = a.dataset.ansicht !== neu; });
    zeigeWerte();
    if (neu === 'zitat') zeigeZitat(zIndex, true);
    if (neu === 'liste') aktiviere(lIndex < 0 ? 0 : lIndex, true);
    if (neu === 'wand') zeigeWand();
    if (neu === 'stimmen-reihe') { sr.spur.scrollLeft = 0; srAktiv = -1; srMarkieren(0); }
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-variante]');
    if (!b) return;
    stoppeVorfuehrung();
    setzeVariante(b.dataset.variante);
    schreibeUrl(b.dataset.variante);
  });

  /* Vorführung der aktuellen Variante: Zitat wechselt im Takt, in der Liste fährt ein gedachter Zeiger
     über die Namen, die Wand baut sich in Wellen neu auf */
  const zeiger = $('.zeiger');
  let schritt = null, aktiv = false;
  function zeigerAuf(i) {
    const r = knoepfe[i].querySelector('.s-name').getBoundingClientRect();
    const x = r.left + Math.min(r.width * 0.6, 220), y = r.top + r.height / 2;
    if (!zeiger.classList.contains('an')) { gsap.set(zeiger, { x: x - 60, y: y + 40 }); zeiger.classList.add('an'); }
    gsap.to(zeiger, { x, y, duration: reduziert() ? 0 : 0.7, ease: 'power3.out' });
    gsap.delayedCall(reduziert() ? 0 : 0.45, () => aktiviere(i));
  }
  const ABLAEUFE = {
    zitat: [
      [() => { zeiger.classList.remove('an'); setzeVariante('zitat'); zeigeZitat(0, true); }, 3.4],
      [() => zeigeZitat(1), 3.2],
      [() => zeigeZitat(2), 3.2],
      [() => zeigeZitat(3), 3.2],
      [() => zeigeZitat(4), 3.2],
    ],
    liste: [
      [() => { lIndex = -1; setzeVariante('liste'); aktiviere(0, true); zeigerAuf(0); }, 1.9],
      [() => zeigerAuf(1), 1.9],
      [() => zeigerAuf(2), 1.9],
      [() => zeigerAuf(3), 1.9],
      [() => zeigerAuf(4), 1.9],
      [() => zeigerAuf(5), 2.2],
    ],
    wand: [
      [() => { zeiger.classList.remove('an'); setzeVariante('wand'); }, 4.6],
    ],
    'stimmen-reihe': [
      [() => { zeiger.classList.remove('an'); setzeVariante('stimmen-reihe'); }, 2.6],
      [() => srGehe(1, false), 2.6],
      [() => srGehe(2, false), 2.6],
      [() => srGehe(3, false), 2.6],
      [() => srGehe(4, false), 2.6],
    ],
  };
  let ABLAUF = ABLAEUFE.zitat;
  function spiele(i) {
    if (!aktiv) return;
    const [tu, dauer] = ABLAUF[i % ABLAUF.length];
    tu();
    schritt = gsap.delayedCall(dauer, () => spiele(i + 1));
  }
  function vorfuehren() {
    stoppeVorfuehrung();
    gsap.globalTimeline.resume();
    aktiv = true;
    html.classList.add('vorfuehrung');
    ABLAUF = ABLAEUFE[variante];
    spiele(0);
  }
  function stoppeVorfuehrung() {
    aktiv = false;
    if (schritt) schritt.kill();
    schritt = null;
    html.classList.remove('vorfuehrung');
    zeiger.classList.remove('an');
  }
  function neustart() { stoppeVorfuehrung(); gsap.globalTimeline.resume(); scrollTo(0, 0); zIndex = 0; lIndex = -1; z.text.textContent = ''; setzeVariante(variante); }

  beobachter.observe(z.text); beobachter.observe(l.text);
  document.fonts.ready.then(() => { if (!aktiv) setzeVariante(variante); });   // erst nach den Schriften teilen, sonst falsche Umbrüche

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