Zum Inhalt
← Animationsstile

Animationsstil „Swiss knackig“

Programmseite eines Luzerner Kulturhauses: harte Masken, nur senkrechte oder waagerechte Wege, eine Kurve und vier Dauern als Token.

Sachliche, typografische Bewegungssprache: kurze Dauern, Ausklang-Kurven ohne Überschwingen, harte Masken statt Überblendungen. Bewegung läuft nur entlang des Rasters, also waagerecht oder senkrecht, ohne Diagonalen und ohne Rotation. Elemente schnappen in Position, der Versatz ist kurz und gleichmäßig. Zahlen zählen in Stufen hoch, Listen bauen sich zeilenweise auf.

Die Bewegung folgt der Rasterlogik, statt mit ihr zu konkurrieren. Kurze Wege und klare Kanten wirken präzise und selbstbewusst. Weil Austritte schneller sind als Eintritte, fühlt sich jeder Wechsel entschieden an.

Wann einsetzen

Typografisch getriebene Portfolios, Architektur, Designbüros, Kulturhäuser, Indexlisten, Programme mit Filtern.

Wann nicht

Bei verspielten, warmen Marken und bei Kinder- oder Lifestyle-Themen.

Varianten

  • Maske: clip-path inset statt Verschiebung
  • Versatz: 100 % Weg innerhalb einer Maske (overflow: hidden)
  • Stufen: nur Deckkraft mit steps(), passt vor allem zu Ziffern

Umsetzung

Alle Werte als CSS-Token: --ease-swiss: cubic-bezier(0.25, 1, 0.5, 1) (power3.out), --ease-swiss-exit: cubic-bezier(0.5, 0, 0.75, 0) (power3.in), --dur-1: 200ms, --dur-2: 350ms, --dur-3: 500ms, --dur-4: 700ms, --stagger: 40ms. Austritte dauern etwa 70 % des Eintritts. Hover und Indikator per CSS-Transition, Sequenzen per Web Animations API oder GSAP mit denselben Token.

Robust umsetzen

Telefon. Bewegung unverändert. Gleitende Indikatoren nur, solange die Elemente in einer Zeile stehen.

Ohne Bewegung. Masken sofort offen, nur Deckkraft in 150 ms. Zähler zeigen direkt den Endwert.

Barrierefrei. Kurze Dauern sind auch für vestibulär empfindliche Menschen gut verträglich. Zähler haben den Endwert im DOM, Filter melden ihren Zustand über aria-pressed, Spartenfarben stehen nie ohne Text-Label.

Code

<!DOCTYPE html>
<!--
  Animationsstil „Swiss knackig“
  Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-swiss-knackig/
  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>Animationsstil „Swiss knackig“ · 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: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Rezept „Programm und Haltung“: Schwarz auf warmem Off-White, eine Farbe je Sparte */
  --grund: #efede9; --tinte: #0b0b0b; --leise: rgba(11, 11, 11, .62); --linie: rgba(11, 11, 11, .16);
  --musik: #c4421a; --theater: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;

  /* Die Token des Animationsstils: eine Kurve, eine Austrittskurve, vier Dauern, ein Versatz */
  --ease-swiss: cubic-bezier(0.25, 1, 0.5, 1);       /* power3.out */
  --ease-swiss-exit: cubic-bezier(0.5, 0, 0.75, 0);  /* power3.in */
  --dur-1: 200ms;   /* UI, Hover, Indikator */
  --dur-2: 350ms;   /* Listenzeilen */
  --dur-3: 500ms;   /* Flächen-Wipe, Zähler */
  --dur-4: 700ms;   /* Headline */
  --stagger: 40ms;
  --exit: .7;       /* Austritt = 70 % des Eintritts */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; overflow-x: clip; }
body { min-height: 100svh; display: flex; flex-direction: column; overflow-x: clip; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
:focus-visible { outline: 2px solid var(--tinte); outline-offset: 3px; }
.mono { font: 500 12px/1.3 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ——— Kopf: klare Unterkante, Tickets und Vorlesen immer erreichbar ——— */
.seite { flex: 1; display: flex; flex-direction: column; padding-inline: clamp(16px, 3.4vw, 48px); }
.kopf { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 72px; background: var(--grund); }
.kopf .unterkante { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tinte); }
.marke { display: flex; align-items: center; gap: 12px; font: 800 22px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.03em; }
.marke svg { width: 26px; height: 26px; flex: none; }
.marke small { font: 500 11px/1.2 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; gap: clamp(16px, 2.2vw, 30px); list-style: none; padding: 0; font: 600 16px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.nav a, .vorlesen { position: relative; padding-block: 6px; }
.nav a::after, .vorlesen::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: currentColor; clip-path: inset(0 100% 0 0); transition: clip-path var(--dur-1) var(--ease-swiss-exit); }
.nav a[aria-current]::after { clip-path: inset(0); }
@media (hover: hover) { .nav a:hover::after, .vorlesen:hover::after { clip-path: inset(0); transition: clip-path var(--dur-1) var(--ease-swiss); } }
.kopf-rechts { display: flex; align-items: center; gap: clamp(14px, 1.8vw, 24px); }
.vorlesen { display: inline-flex; align-items: center; gap: 7px; font: 500 12px/1 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; }
.vorlesen svg { width: 15px; height: 15px; }
.tickets { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 16px; background: var(--tinte); color: var(--grund); font: 700 15px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.005em; }
.tickets svg { width: 12px; height: 12px; transition: translate var(--dur-1) var(--ease-swiss); }
@media (hover: hover) { .tickets:hover svg { translate: 4px 0; } }

/* ——— Held ——— */
.held { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px clamp(20px, 3vw, 48px); align-items: end; padding-top: clamp(26px, 4.4vw, 64px); padding-bottom: clamp(22px, 3vw, 40px); }
.saison { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-bottom: clamp(12px, 1.6vw, 20px); }
.saison span + span { color: var(--leise); }
.titel { font: 900 clamp(2.75rem, 7vw, 7rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
/* Jede Zeile hat ihre Maske; Polster unten, damit Unterlängen nicht abgeschnitten werden */
.titel .z { display: block; overflow: hidden; padding-bottom: .06em; margin-bottom: -.06em; }
.titel .z > span { display: block; }
.titel em { font-style: normal; color: var(--leise); }
.zahlen { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; border-top: 1px solid var(--tinte); }
.zahlen > div { padding: 12px clamp(14px, 2vw, 32px) 0 0; }
.zahlen dt { white-space: nowrap; }
.zahlen > div + div { padding-left: 14px; border-left: 1px solid var(--linie); }
.zahlen dt { color: var(--leise); font-size: 11px; }
.zahlen dd { margin-top: 8px; font: 800 clamp(1.75rem, 2.9vw, 2.6rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.zahlen dd small { font-size: .5em; letter-spacing: 0; font-weight: 700; margin-right: .2em; }

/* ——— Programm ——— */
.programm { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 300px); grid-template-rows: auto 1fr; column-gap: clamp(20px, 3vw, 40px); padding-bottom: clamp(24px, 4vw, 56px); }
.filter-zeile { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-bottom: 14px; }
.filter { position: relative; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--tinte); overflow-x: auto; scrollbar-width: none; }
.filter::-webkit-scrollbar { display: none; }
.filter .indikator { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 0; width: 0; background: var(--tinte); transition: translate var(--dur-1) var(--ease-swiss), width var(--dur-1) var(--ease-swiss); }
.filter button { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 13px; white-space: nowrap; font: 600 15px/1 'Schibsted Grotesk', sans-serif; transition: color var(--dur-1) var(--ease-swiss); }
.filter button i { width: 9px; height: 9px; background: var(--farbe, var(--tinte)); flex: none; }
.filter button[aria-pressed="true"] { color: var(--grund); }
.filter button[aria-pressed="true"] i { box-shadow: 0 0 0 1px var(--grund); }
.filter-zeile .stand { color: var(--leise); white-space: nowrap; }

.liste { list-style: none; padding: 0; border-top: 1px solid var(--tinte); min-height: calc(6 * 53px); align-self: start; }
.liste li { overflow: hidden; border-bottom: 1px solid var(--linie); }
.liste a { position: relative; display: grid; grid-template-columns: 6.2em 3.6em 9.4em minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 52px; padding-block: 8px; }
@media (max-width: 1200px) {
  .liste a { grid-template-columns: 5.4em 8.6em minmax(0, 1fr) auto; grid-template-areas: "datum sparte titel pfeil" "zeit sparte titel pfeil"; row-gap: 3px; }
  .liste .datum { grid-area: datum; align-self: end; }
  .liste .zeit { grid-area: zeit; align-self: start; font-size: 13px; }
  .liste .sparte { grid-area: sparte; }
  .liste .titel-z { grid-area: titel; }
  .liste .pfeil { grid-area: pfeil; }
  .liste .pfeil span { display: none; }
}
.liste .datum, .liste .zeit { font: 500 14px/1.2 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; }
.liste .zeit { color: var(--leise); }
.sparte { display: inline-flex; align-items: center; gap: 8px; font: 500 12px/1.2 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; }
.sparte i { width: 10px; height: 10px; background: var(--farbe); flex: none; }
.liste .titel-z { font: 700 18px/1.2 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.liste .titel-z small { display: block; margin-top: 3px; font: 400 14px/1.3 'Atkinson Hyperlegible Next', sans-serif; letter-spacing: 0; color: var(--leise); }
.liste .pfeil { display: inline-flex; align-items: center; gap: 8px; font: 500 12px/1 'IBM Plex Mono', monospace; letter-spacing: .06em; text-transform: uppercase; }
.liste .pfeil svg { width: 14px; height: 14px; transition: translate var(--dur-1) var(--ease-swiss-exit); }
.liste a::before { content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--farbe); clip-path: inset(0 100% 0 0); transition: clip-path var(--dur-1) var(--ease-swiss-exit); }
@media (hover: hover) {
  .liste a:hover::before { clip-path: inset(0); transition: clip-path var(--dur-2) var(--ease-swiss); }
  .liste a:hover .pfeil svg { translate: 8px 0; transition: translate var(--dur-1) var(--ease-swiss); }
}

/* Plakat der gewählten Sparte: neue Fläche wischt horizontal über die alte */
.plakat { position: relative; grid-row: 2; grid-column: 2; align-self: start; aspect-ratio: 5 / 6; max-height: calc(6 * 53px + 1px); width: 100%; justify-self: end; overflow: hidden; }
.plakat .flaeche { position: absolute; inset: 0; display: grid; grid-template-rows: auto 1fr auto; padding: 16px; background: var(--farbe); color: #fff; }
.plakat .kopfzeile { display: flex; justify-content: space-between; gap: 8px; }
.plakat svg { width: 100%; height: 100%; align-self: center; }
.plakat .z { display: block; overflow: hidden; }
.plakat .z > span { display: block; }
.plakat .gross { font: 900 clamp(2.4rem, 4.6vw, 3.6rem)/.95 'Schibsted Grotesk', sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.plakat .name { margin-top: 6px; font: 700 18px/1.15 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.plakat .info { margin-top: 10px; display: flex; justify-content: space-between; gap: 8px; opacity: .86; }

/* ——— Pult: Varianten und Token (liegt im Fluss, nicht über der Seite) ——— */
.pult { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 28px; padding: 14px clamp(16px, 3.4vw, 48px) 18px; background: var(--tinte); color: rgba(239, 237, 233, .62); }
.pult-links { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.varianten { display: flex; gap: 2px; padding: 3px; border: 1px solid rgba(239, 237, 233, .22); }
.varianten button { height: 30px; padding: 0 11px; color: rgba(239, 237, 233, .7); transition: color var(--dur-1) var(--ease-swiss), background-color var(--dur-1) var(--ease-swiss); }
.varianten button:hover { color: #efede9; }
.varianten button[aria-pressed="true"] { background: #efede9; color: var(--tinte); }
.varianten button:focus-visible, .auftakt:focus-visible { outline-color: #efede9; }
.auftakt { height: 38px; padding: 0 12px; border: 1px solid rgba(239, 237, 233, .22); color: #efede9; }
.token { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 11.5px; letter-spacing: .02em; text-transform: none; }
.token span { transition: color var(--dur-1) var(--ease-swiss); }
.token b { font-weight: 500; color: #efede9; }
.token span.an { color: #fff; }
.token span.an b { color: #f0b49d; }

/* ——— Vorschau: nur die Seite ——— */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .pult { display: none; }

/* ——— Reduzierte Bewegung: Masken sofort offen, nur Deckkraft 150 ms ——— */
:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }

/* ——— Breiten ——— */
@media (max-width: 1080px) {
  .nav { display: none; }
}
@media (max-width: 860px) {
  .held { grid-template-columns: 1fr; }
  .programm { grid-template-columns: 1fr; }
  .plakat { grid-row: auto; grid-column: 1; aspect-ratio: auto; height: 220px; margin-top: 20px; max-height: none; }
  .liste { min-height: 0; }
}
@media (max-width: 640px) {
  .kopf { height: 60px; }
  .marke small, .vorlesen span { display: none; }
  .tickets { height: 38px; padding: 0 12px; font-size: 14px; }
  .filter-zeile { flex-direction: column; align-items: stretch; }
  .filter-zeile .stand { order: -1; }
  /* Schmal: Chips umbrechen; der Indikator entfällt, weil er sonst diagonal springen müsste */
  .filter { flex-wrap: wrap; gap: 6px; padding: 0; border: 0; overflow: visible; }
  .filter .indikator { display: none; }
  .filter button { border: 1px solid var(--tinte); }
  .filter button[aria-pressed="true"] { background: var(--tinte); }
  .zahlen { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .zahlen > div { padding-right: 8px; }
  .zahlen > div + div { padding-left: 10px; }
  .zahlen dt { font-size: 10px; letter-spacing: .02em; }
  .liste a { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "datum sparte pfeil" "titel titel titel"; gap: 6px 12px; padding-block: 12px; }
  .liste .datum { grid-area: datum; }
  .liste .zeit { display: none; }
  .liste .sparte { grid-area: sparte; }
  .liste .titel-z { grid-area: titel; }
  .liste .pfeil { grid-area: pfeil; }
  .liste .pfeil span { display: none; }
  .zahlen dd { font-size: 1.75rem; }
  .pult { padding-inline: 16px; }
  .varianten { width: 100%; display: grid; grid-template-columns: repeat(3, 1fr); }
  .varianten button { padding: 0 4px; letter-spacing: .02em; }
}
/* Touch: kein Hover, Ticket-Pfeil bleibt sichtbar, größere Ziele */
@media (hover: none) {
  .filter button { height: 40px; }
  .varianten button { height: 38px; }
}
</style>
</head>
<body>
<div class="seite">
  <header class="kopf">
    <a class="marke" href="#" aria-label="Halle Rotsee, Startseite">
      <svg viewBox="0 0 26 26" aria-hidden="true"><rect width="26" height="26" fill="#0b0b0b"/><path d="M5 9h16M5 13h11M5 17h16" stroke="#efede9" stroke-width="2"/></svg>
      Halle Rotsee <small>Kulturhaus Luzern</small>
    </a>
    <ul class="nav" aria-label="Hauptnavigation">
      <li><a href="#" aria-current="page">Programm</a></li>
      <li><a href="#">Ausstellungen</a></li>
      <li><a href="#">Besuch</a></li>
      <li><a href="#">Haus</a></li>
    </ul>
    <div class="kopf-rechts">
      <a class="vorlesen" href="#"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 6h3l4-3v10l-4-3H2zM11.5 5.5a3.5 3.5 0 0 1 0 5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg><span>Vorlesen</span></a>
      <a class="tickets" href="#">Tickets <svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6h9M6.5 2.5 10 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></a>
    </div>
    <span class="unterkante" data-unterkante aria-hidden="true"></span>
  </header>

  <main>
    <section class="held">
      <div>
        <p class="saison mono" data-zeile><span>Programm Winter 26/27</span><span>14.11.2026 – 28.02.2027</span></p>
        <h1 class="titel" data-titel><span class="z"><span>Zwischen</span></span><span class="z"><span>den Ufern.</span></span></h1>
      </div>
      <dl class="zahlen mono">
        <div><dt>Termine</dt><dd><span data-zahl="termine" aria-hidden="true">47</span><span class="nur-sr" data-zahl-sr="termine">47</span></dd></div>
        <div><dt>Uraufführungen</dt><dd><span data-zahl="urauf" aria-hidden="true">9</span><span class="nur-sr" data-zahl-sr="urauf">9</span></dd></div>
        <div><dt>Tickets ab</dt><dd><small>CHF</small><span data-zahl="preis" aria-hidden="true">12</span><span class="nur-sr" data-zahl-sr="preis">12</span></dd></div>
      </dl>
    </section>

    <section class="programm" aria-label="Programm">
      <div class="filter-zeile">
        <div class="filter mono" role="group" aria-label="Sparte filtern" data-filter>
          <span class="indikator" data-indikator aria-hidden="true"></span>
          <button type="button" data-sparte="alle" aria-pressed="true">Alle</button>
          <button type="button" data-sparte="musik" aria-pressed="false" style="--farbe: var(--musik)"><i aria-hidden="true"></i>Musik</button>
          <button type="button" data-sparte="theater" aria-pressed="false" style="--farbe: var(--theater)"><i aria-hidden="true"></i>Theater &amp; Tanz</button>
          <button type="button" data-sparte="kunst" aria-pressed="false" style="--farbe: var(--kunst)"><i aria-hidden="true"></i>Kunst</button>
          <button type="button" data-sparte="film" aria-pressed="false" style="--farbe: var(--film)"><i aria-hidden="true"></i>Film</button>
        </div>
        <p class="stand mono" aria-live="polite" data-stand>November · 6 von 47</p>
      </div>
      <ol class="liste" data-liste aria-label="Termine"></ol>
      <div class="plakat" data-plakat aria-live="polite"></div>
    </section>
  </main>
</div>

<footer class="pult mono">
  <div class="pult-links">
    <div class="varianten" role="group" aria-label="Auftritt" data-varianten>
      <button type="button" data-variante="maske" aria-pressed="true">Maske</button>
      <button type="button" data-variante="versatz" aria-pressed="false">Versatz</button>
      <button type="button" data-variante="stufen" aria-pressed="false">Stufen</button>
    </div>
    <button type="button" class="auftakt" data-auftakt>↻ Auftakt</button>
  </div>
  <p class="token" data-token></p>
</footer>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = (s, el = document) => el.querySelector(s);
  const $$ = (s, el = document) => [...el.querySelectorAll(s)];

  // Token aus dem CSS lesen: eine Quelle für CSS und JS
  const css = getComputedStyle(html);
  const T = {
    ease: css.getPropertyValue('--ease-swiss').trim(),
    exit: css.getPropertyValue('--ease-swiss-exit').trim(),
    d1: parseFloat(css.getPropertyValue('--dur-1')),
    d2: parseFloat(css.getPropertyValue('--dur-2')),
    d3: parseFloat(css.getPropertyValue('--dur-3')),
    d4: parseFloat(css.getPropertyValue('--dur-4')),
    stagger: parseFloat(css.getPropertyValue('--stagger')),
    exitFaktor: parseFloat(css.getPropertyValue('--exit')),
  };

  const SPARTEN = {
    musik:   { name: 'Musik',          farbe: 'var(--musik)' },
    theater: { name: 'Theater & Tanz', farbe: 'var(--theater)' },
    kunst:   { name: 'Kunst',          farbe: 'var(--kunst)' },
    film:    { name: 'Film',           farbe: 'var(--film)' },
  };
  const TERMINE = [
    ['Fr 14.11.', '19:30', 'musik',   'Ufermusik I: Saiten über dem See', 'Grosse Halle · Streichquartett Fräkmünt'],
    ['Sa 15.11.', '11:00', 'kunst',   'Vernissage „Treibgut“', 'Ausstellungsraum · Skulptur aus dem Vierwaldstättersee'],
    ['Di 18.11.', '20:00', 'film',    'Kino am Kai: Die letzte Fähre', 'Saal 2 · CH 2026, 94 Min., Dialekt mit UT'],
    ['Do 20.11.', '19:30', 'theater', 'Gegenstrom', 'Grosse Halle · Kompanie Seewind, Uraufführung'],
    ['Sa 22.11.', '20:00', 'musik',   'Jodel neu gesetzt', 'Saal 2 · Chor und Elektronik aus dem Entlebuch'],
    ['So 23.11.', '15:00', 'theater', 'Der Drache vom Pilatus', 'Studio · Familienstück ab 6 Jahren'],
    ['Mi 26.11.', '18:30', 'kunst',   'Führung: Treibgut mit der Kuratorin', 'Ausstellungsraum · Eintritt frei'],
    ['Fr 28.11.', '20:30', 'film',    'Kurzfilmnacht: Ufer, Brücken, Fähren', 'Saal 2 · neun Filme, eine Pause'],
    ['Sa 29.11.', '19:30', 'musik',   'Orgel und Elektronik', 'Grosse Halle · Uraufführung im Auftrag des Hauses'],
    ['Do 27.11.', '20:00', 'theater', 'Leise Leute', 'Studio · Tanzsolo, 55 Min.'],
    ['Sa 29.11.', '14:00', 'kunst',   'Werkstatt: Holz aus dem See', 'Atelier · ab 10 Jahren, Anmeldung'],
    ['So 30.11.', '17:00', 'film',    'Stummfilm mit Live-Orgel', 'Grosse Halle · Programm Rotsee 1926'],
  ];
  const ZAHLEN = {
    alle:    { termine: 47, urauf: 9, preis: 12 },
    musik:   { termine: 14, urauf: 3, preis: 25 },
    theater: { termine: 12, urauf: 4, preis: 18 },
    kunst:   { termine: 9,  urauf: 1, preis: 12 },
    film:    { termine: 12, urauf: 1, preis: 15 },
  };
  const PLAKATE = {
    alle:    { farbe: '#0b0b0b', kopf: ['Saisonauftakt', 'Nr. 01'], gross: '14.11.', name: 'Zwischen den Ufern', info: ['Fr 19:30', 'Eintritt frei'] },
    musik:   { farbe: 'var(--musik)',   kopf: ['Musik', 'Nr. 02'],   gross: '29.11.', name: 'Orgel und Elektronik', info: ['Sa 19:30', 'ab CHF 25'] },
    theater: { farbe: 'var(--theater)', kopf: ['Theater & Tanz', 'Nr. 03'], gross: '20.11.', name: 'Gegenstrom', info: ['Do 19:30', 'ab CHF 18'] },
    kunst:   { farbe: 'var(--kunst)',   kopf: ['Kunst', 'Nr. 04'],   gross: '15.11.', name: 'Treibgut', info: ['bis 22.02.', 'ab CHF 12'] },
    film:    { farbe: 'var(--film)',    kopf: ['Film', 'Nr. 05'],    gross: '18.11.', name: 'Die letzte Fähre', info: ['Di 20:00', 'ab CHF 15'] },
  };
  // Uferlinien: waagerechte Bänder, nach unten kräftiger, durchbrochen von einer Rinne zwischen zwei Ufern (Lage je Sparte aus dem zweiten Wert)
  const UFER = {
    alle:    [.9, .62, .78, .4, .7, .5, .86],
    musik:   [.3, .75, .45, .9, .55, .8, .35],
    theater: [.8, .3, .65, .45, .9, .4, .7],
    kunst:   [.5, .9, .35, .7, .3, .85, .6],
    film:    [.7, .45, .9, .3, .6, .75, .4],
  };

  const liste = $('[data-liste]');
  const plakat = $('[data-plakat]');
  const stand = $('[data-stand]');
  const indikator = $('[data-indikator]');
  const filterKnoepfe = $$('[data-filter] button');
  const variantenKnoepfe = $$('[data-varianten] button');
  const tokenAnzeige = $('[data-token]');

  let variante = 'maske', sparte = 'alle', laufend = [];

  /* ——— Token-Anzeige ——— */
  const TOKEN = [
    ['ease', 'power3.out', 'cubic-bezier(0.25, 1, 0.5, 1)'],
    ['exit', 'power3.in', 'cubic-bezier(0.5, 0, 0.75, 0)'],
    ['d1', 'dur-1', '200'], ['d2', 'dur-2', '350'], ['d3', 'dur-3', '500'], ['d4', 'dur-4', '700'],
    ['stagger', 'stagger', '40'],
  ];
  tokenAnzeige.innerHTML = TOKEN.map(([k, n, w]) => `<span data-t="${k}">${n} <b>${w}</b></span>`).join('');
  const tokenUhren = {};
  function markiere(...keys) {
    keys.forEach(k => {
      const el = tokenAnzeige.querySelector(`[data-t="${k}"]`);
      if (!el) return;
      el.classList.add('an');
      clearTimeout(tokenUhren[k]);
      tokenUhren[k] = setTimeout(() => el.classList.remove('an'), 700);
    });
  }

  /* ——— Auftritt und Abgang je Variante: nur y, keine Diagonale, keine Rotation ——— */
  const AUF = {
    maske:   [{ clipPath: 'inset(100% 0 0 0)' }, { clipPath: 'inset(0% 0 0 0)' }],
    versatz: [{ transform: 'translateY(100%)' }, { transform: 'translateY(0%)' }],
    stufen:  [{ opacity: 0 }, { opacity: 1 }],
  };
  const AB = {
    maske:   [{ clipPath: 'inset(0 0 0% 0)' }, { clipPath: 'inset(0 0 100% 0)' }],
    versatz: [{ transform: 'translateY(0%)' }, { transform: 'translateY(-100%)' }],
    stufen:  [{ opacity: 1 }, { opacity: 0 }],
  };
  function merke(a) { laufend.push(a); a.finished.then(() => { laufend = laufend.filter(x => x !== a); }, () => {}); return a; }

  function auftritt(els, dauer, verzug = 0) {
    return els.map((el, i) => {
      if (reduziert()) return merke(el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, delay: verzug, easing: 'linear', fill: 'backwards' }));
      return merke(el.animate(AUF[variante], {
        duration: dauer,
        delay: verzug + i * T.stagger,
        easing: variante === 'stufen' ? 'steps(4, end)' : T.ease,
        fill: 'backwards',
      }));
    });
  }
  function abgang(els, dauer) {
    if (reduziert() || !els.length) return Promise.resolve();
    const d = Math.round(dauer * T.exitFaktor);
    return Promise.all(els.map(el => merke(el.animate(AB[variante], {
      duration: d, easing: variante === 'stufen' ? 'steps(3, end)' : T.exit, fill: 'forwards',
    })).finished.catch(() => {})));
  }

  /* ——— Zähler: von alt nach neu in Stufen, Endwert steht sofort im DOM ——— */
  function bezier(x1, y1, x2, y2) {
    const a = (p1, p2) => 1 - 3 * p2 + 3 * p1, b = (p1, p2) => 3 * p2 - 6 * p1, c = p1 => 3 * p1;
    const f = (t, p1, p2) => ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;
    const df = (t, p1, p2) => 3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);
    return x => { let t = x; for (let i = 0; i < 8; i++) { const d = df(t, x1, x2); if (Math.abs(d) < 1e-6) break; t -= (f(t, x1, x2) - x) / d; } return f(Math.min(1, Math.max(0, t)), y1, y2); };
  }
  const swissKurve = bezier(0.25, 1, 0.5, 1);
  const zahlUhren = {};
  function zaehle(key, ziel, von) {
    const el = $(`[data-zahl="${key}"]`);
    $(`[data-zahl-sr="${key}"]`).textContent = ziel;
    cancelAnimationFrame(zahlUhren[key]);
    const start = von ?? parseInt(el.textContent, 10) ?? 0;
    if (reduziert() || start === ziel) { el.textContent = ziel; return; }
    const t0 = performance.now(), STUFEN = 10;
    const schritt = jetzt => {
      if (pausiert) { zahlUhren[key] = requestAnimationFrame(schritt); return; }
      const p = Math.min(1, (jetzt - t0) / T.d3);
      const q = Math.ceil(p * STUFEN) / STUFEN;            // Treppenstufen statt fließend
      el.textContent = Math.round(start + (ziel - start) * swissKurve(q));
      if (p < 1) zahlUhren[key] = requestAnimationFrame(schritt);
    };
    zahlUhren[key] = requestAnimationFrame(schritt);
  }

  /* ——— Liste ——— */
  function termineFuer(s) {
    return (s === 'alle' ? TERMINE.slice(0, 6) : TERMINE.filter(t => t[2] === s)).slice(0, 6);
  }
  function zeileHTML([datum, zeit, sp, titel, ort]) {
    return `<li><a href="#" style="--farbe: ${SPARTEN[sp].farbe}">
      <span class="datum">${datum}</span><span class="zeit">${zeit}</span>
      <span class="sparte"><i aria-hidden="true"></i>${SPARTEN[sp].name}</span>
      <span class="titel-z">${titel}<small>${ort}</small></span>
      <span class="pfeil"><span>Tickets</span><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7h11M8 3l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></span>
    </a></li>`;
  }
  function fuelleListe(s) {
    const t = termineFuer(s);
    liste.innerHTML = t.map(zeileHTML).join('');
    const gesamt = ZAHLEN[s].termine;
    stand.textContent = `November · ${t.length} von ${gesamt}`;
  }

  /* ——— Plakat ——— */
  function plakatHTML(s) {
    const p = PLAKATE[s], u = UFER[s];
    const mitte = 60 + u[1] * 80;
    let y = 6, streifen = '';
    for (let i = 0; i < 8; i++) {
      const h = 1.5 + i * 1.3, rinne = 8 + i * 4, l = mitte - rinne / 2, r = mitte + rinne / 2;
      streifen += `<rect x="0" y="${y.toFixed(1)}" width="${l.toFixed(1)}" height="${h.toFixed(1)}" fill="#fff"/>`
        + `<rect x="${r.toFixed(1)}" y="${y.toFixed(1)}" width="${(200 - r).toFixed(1)}" height="${h.toFixed(1)}" fill="#fff" opacity=".9"/>`;
      y += h + 6;
    }
    return `<div class="flaeche" style="--farbe: ${p.farbe}">
      <div class="kopfzeile mono"><span class="z"><span>${p.kopf[0]}</span></span><span class="z"><span>${p.kopf[1]}</span></span></div>
      <svg viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">${streifen}</svg>
      <div>
        <p class="gross"><span class="z"><span>${p.gross}</span></span></p>
        <p class="name"><span class="z"><span>${p.name}</span></span></p>
        <p class="info mono"><span class="z"><span>${p.info[0]}</span></span><span class="z"><span>${p.info[1]}</span></span></p>
      </div>
    </div>`;
  }
  function zeigePlakat(s, mitWipe) {
    const alt = $$('.flaeche', plakat);
    plakat.insertAdjacentHTML('beforeend', plakatHTML(s));
    const neu = plakat.lastElementChild;
    const fertig = () => alt.forEach(el => el.remove());
    if (!mitWipe) { fertig(); return; }
    if (reduziert()) {
      merke(neu.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150, easing: 'linear', fill: 'backwards' })).finished.then(fertig, fertig);
      return;
    }
    // Harte Maske entlang der x-Achse statt Überblendung
    merke(neu.animate([{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }], { duration: T.d3, easing: T.ease, fill: 'backwards' })).finished.then(fertig, fertig);
    auftritt($$('.z > span', neu), T.d2, 120);
    markiere('d3');
  }

  /* ——— Indikator unter dem aktiven Filter ——— */
  function setzeIndikator(sofort) {
    const k = filterKnoepfe.find(b => b.dataset.sparte === sparte);
    if (!k) return;
    if (sofort) indikator.style.transition = 'none';
    indikator.style.width = k.offsetWidth + 'px';
    indikator.style.translate = k.offsetLeft - 3 + 'px 0';
    if (sofort) { indikator.offsetWidth; indikator.style.transition = ''; }
  }

  /* ——— Filter wechseln: alte Zeilen schnell raus, neue gestaffelt rein ——— */
  let wechselNr = 0;
  async function waehleSparte(s) {
    if (s === sparte) return;
    const nr = ++wechselNr;
    sparte = s;
    filterKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.sparte === s)));
    setzeIndikator(false);
    markiere('d1', 'exit');
    Object.entries(ZAHLEN[s]).forEach(([k, v]) => zaehle(k, v));
    zeigePlakat(s, true);
    await abgang($$('li > a', liste), T.d2);
    if (nr !== wechselNr) return;
    fuelleListe(s);
    auftritt($$('li > a', liste), T.d2);
    markiere('ease', 'd2', 'stagger');
  }
  filterKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); waehleSparte(b.dataset.sparte); }));

  /* ——— Auftakt: Linie, Headline, Zahlen, Liste, Plakat ——— */
  function auftakt() {
    laufend.forEach(a => a.cancel());
    laufend = [];
    sparte = 'alle';
    filterKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.sparte === 'alle')));
    setzeIndikator(true);
    fuelleListe('alle');
    plakat.innerHTML = '';
    zeigePlakat('alle', false);
    const linie = $('[data-unterkante]');
    if (reduziert()) {
      auftritt([linie, $('.held'), $('.programm')], 150);
      Object.entries(ZAHLEN.alle).forEach(([k, v]) => zaehle(k, v, 0));
      return;
    }
    merke(linie.animate([{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }], { duration: T.d3, easing: T.ease, fill: 'backwards' }));
    auftritt([$('.saison')], T.d2, 60);
    auftritt($$('[data-titel] .z > span'), T.d4, 100);
    Object.entries(ZAHLEN.alle).forEach(([k, v]) => setTimeout(() => zaehle(k, v, 0), 260));
    merke($('.zahlen').animate([{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }], { duration: T.d3, easing: T.ease, delay: 200, fill: 'backwards' }));
    merke($('.filter-zeile').animate([{ clipPath: 'inset(100% 0 0 0)' }, { clipPath: 'inset(0% 0 0 0)' }], { duration: T.d2, easing: T.ease, delay: 280, fill: 'backwards' }));
    auftritt($$('li > a', liste), T.d2, 340);
    const fl = plakat.querySelector('.flaeche');
    merke(fl.animate([{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }], { duration: T.d3, easing: T.ease, delay: 300, fill: 'backwards' }));
    auftritt($$('.z > span', fl), T.d2, 420);
    markiere('ease', 'd2', 'd3', 'd4', 'stagger');
  }

  /* ——— Varianten ——— */
  function setzeVariante(v) {
    variante = v;
    variantenKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
  }
  variantenKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.variante); auftakt(); }));
  $('[data-auftakt]').addEventListener('click', () => { stopVorfuehrung(); auftakt(); });
  addEventListener('resize', () => setzeIndikator(true));

  /* ——— Vorführung: Filter durchschalten, nach jeder Runde die nächste Variante ——— */
  const FOLGE = ['musik', 'film', 'theater', 'kunst', 'alle'];
  const VARIANTEN = ['maske', 'versatz', 'stufen'];
  let uhr = null, schritt = 0, runde = 0, pausiert = false, vorf = false;
  function takt() {
    uhr = setTimeout(() => {
      if (schritt < FOLGE.length) { waehleSparte(FOLGE[schritt++]); takt(); return; }
      schritt = 0; runde++;
      setzeVariante(VARIANTEN[runde % VARIANTEN.length]);
      auftakt();
      takt();
    }, schritt === 0 ? 2200 : 2000);
  }
  function stopVorfuehrung() { clearTimeout(uhr); uhr = null; vorf = false; html.classList.remove('vorfuehrung'); }
  function vorfuehren() {
    stopVorfuehrung();
    pausiert = false;
    vorf = true;
    html.classList.add('vorfuehrung');
    schritt = 0; runde = 0;
    setzeVariante('maske');
    auftakt();
    takt();
  }
  function neustart() { stopVorfuehrung(); pausiert = false; scrollTo(0, 0); auftakt(); }
  function pause() { clearTimeout(uhr); uhr = null; pausiert = true; document.getAnimations().forEach(a => a.pause()); }
  function weiter() { pausiert = false; document.getAnimations().forEach(a => { if (a.playState === 'paused') a.play(); }); if (vorf && !uhr) takt(); }

  (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { if (!vorf) auftakt(); else setzeIndikator(true); });
  fuelleListe('alle');
  zeigePlakat('alle', false);

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