Zum Inhalt
← Hover

SVG-Filter-Verzerrung (Turbulenz und Gooey) für Bild und Text

Kategorienwörter und Stoffbilder wabern beim Hover kurz per feTurbulence und feDisplacementMap, die Kampagnenzeile verschmilzt per Gooey-Filter von Herbst zu Winter; am Ende ist alles wieder scharf.

Ein SVG-Filter liegt per CSS filter: url(#f) auf Text, Linien oder Bildern. Für Wabern: feTurbulence erzeugt Rauschen, feDisplacementMap verschiebt damit die Pixel. Für Gooey: Weichzeichnen und danach das Alpha hart schneiden, so verschmelzen Formen. Beim Hover animiert JS (z. B. GSAP) Filterattribute wie scale, baseFrequency oder die Unschärfe. Am Ende geht der Wert auf 0 zurück und der Filter wird entfernt.

Organische Bewegung ohne WebGL, als würde Stoff oder Wasser kurz in Bewegung geraten. Text bleibt echter, auswählbarer Text. Als einzelner Akzent wirkt der Effekt handgemacht und poetisch.

Wann einsetzen

Dekorative Links, Menüs, Wortmarken, kleine Vorschaubilder. Mode, Kunst, Kultur, Musik. Gooey-Morph für den Wechsel zwischen zwei Wörtern.

Wann nicht

Auf großen Flächen und viel Fließtext: Filter sind CPU-lastig, besonders in Safari. Wenn ohnehin WebGL läuft, gehört die Verzerrung in den Shader. Nicht bei sachlichen Marken und nicht auf Werkfotos, die der Kunde verkauft.

Varianten

  • Wabernde Link-Linien und Menüpunkte
  • Wabernder Text: Konturen verflüssigen sich beim Hover oder Scrollen
  • Bild-Verzerrung bei Vorschaubildern
  • Gooey-Text: Buchstaben verschmelzen
  • Gooey-Morph zwischen zwei Wörtern
  • Fadenkreuz-Cursor mit Verzerrung
  • Turbulenz-Verzerrung beim Scrollen

Umsetzung

<svg width="0" height="0"><filter id="d" x="-8%" y="-30%" width="116%" height="160%"><feTurbulence type="fractalNoise" baseFrequency="0.012 0.09" numOctaves="1"/><feDisplacementMap in="SourceGraphic" scale="0"/></filter></svg>. Hover: el.style.filter = 'url(#d)'; gsap.timeline({ onComplete: () => el.style.filter = '' }).to('#d feDisplacementMap', { attr: { scale: 40 }, duration: .45, ease: 'power2.inOut' }).to('#d feDisplacementMap', { attr: { scale: 0 }, duration: .75, ease: 'power2.inOut' }).

Gooey-Morph: zwei Wörter im selben Grid-Feld; je Wort CSS blur(8 / anteil − 8 px) und opacity anteil^0,4; der Container bekommt feColorMatrix mit values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140". Klassisches Gooey mit feGaussianBlur stdDeviation 8 und Alpha-Zeile 0 0 0 20 −8 eignet sich eher für Flächen und dicke Formen.

Fallstricke: Filterregion (x, y, width, height) großzügig setzen, sonst wird beschnitten. Jedes Element braucht eine eigene Filter-Instanz, wenn mehrere gleichzeitig laufen. Das SVG mit den Filtern nicht per display: none verstecken, sonst greifen die Filter nicht. Safari rendert SVG-Filter auf HTML langsamer und schneidet teils ab.

Robust umsetzen

Telefon. Weglassen. Höchstens auf einem einzelnen Wort, ohne Hover-Auslöser.

Ohne Bewegung. Kein Filter. Statt Verzerrung eine kurze Überblendung per Opacity.

Barrierefrei. Text bleibt echter Text. Im verzerrten Zustand nie Inhalt vermitteln, der Endzustand ist immer scharf. Bei Gooey-Wortwechseln das Wortpaar per aria-label vorlesen lassen.

Code

<!DOCTYPE html>
<!--
  SVG-Filter-Verzerrung (Turbulenz und Gooey) für Bild und Text
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/svg-filter-verzerrung/
  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>SVG-Filter-Verzerrung (feTurbulence, Gooey) für Bild und Text · 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: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #736d64; --signal: #a3402a;
  --ui: cubic-bezier(0.25, 1, 0.5, 1);   /* Rezept: UI 200–300 ms, ohne Überschwingen */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr; }
a { color: inherit; text-decoration: none; }
.mono { font: 400 11px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--meta); }
.ziffern { font-variant-numeric: tabular-nums; }
.filter-defs { position: absolute; width: 0; height: 0; overflow: hidden; }

/* Kopfzeile */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 16px clamp(16px, 3vw, 40px); border-bottom: 1px solid var(--linie); font-size: 13.5px; }
.kopf nav { display: flex; gap: 26px; }
.kopf nav:last-child { justify-content: flex-end; }
.kopf nav a { transition: text-decoration-color 100ms var(--ui); text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 5px; text-decoration-thickness: 1px; }
.kopf nav a:hover, .kopf nav a[aria-current] { text-decoration-color: currentColor; }
.wortmarke { font-size: 17px; font-weight: 450; font-stretch: 125%; letter-spacing: .2em; line-height: 1; text-align: center; }
.wortmarke small { display: block; font: 400 9.5px/1 'Fragment Mono', monospace; letter-spacing: .32em; color: var(--meta); margin-top: 6px; }

/* Bühne: Kampagnenbild links, Kategorien und Neuheiten rechts */
.buehne { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); min-height: 0; }
.kampagne { position: relative; overflow: hidden; background: #2d3740; min-height: 560px; }
.kampagne img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
.kampagne::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(18, 22, 26, .55) 0%, rgba(18, 22, 26, 0) 18%, rgba(18, 22, 26, 0) 52%, rgba(18, 22, 26, .5) 100%), radial-gradient(70% 40% at 20% 78%, rgba(220, 226, 230, .16), transparent 70%); }
.kampagne .ecke-o, .kampagne .ecke-u { z-index: 1; }
.kampagne .ecke-o { position: absolute; left: clamp(16px, 2.4vw, 32px); top: clamp(16px, 2.2vw, 28px); right: clamp(16px, 2.4vw, 32px); display: flex; justify-content: space-between; color: rgba(246, 243, 238, .9); }
.kampagne .ecke-u { position: absolute; left: clamp(16px, 2.4vw, 32px); bottom: clamp(18px, 2.6vw, 34px); right: clamp(16px, 2.4vw, 32px); color: #f6f3ee; }
.kampagne .ecke-u .mono { color: rgba(246, 243, 238, .78); }
.kampagne .ecke-o .mono { color: rgba(246, 243, 238, .78); }
.morph { display: grid; font-weight: 400; font-stretch: 125%; font-size: clamp(3rem, 9vw, 8.5rem); line-height: .95; letter-spacing: -.01em; margin: 10px 0 18px; cursor: default; width: max-content; padding: .06em .1em .1em 0; }
.morph span { grid-area: 1 / 1; }
.morph .w2 { opacity: 0; }
.kampagne .zeile { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
.kampagne .zeile a { font-size: 14px; border-bottom: 1px solid rgba(246, 243, 238, .6); padding-bottom: 3px; }

.seite { min-width: 0; display: grid; grid-template-rows: auto 1fr; gap: clamp(20px, 3vw, 40px); padding: clamp(20px, 2.6vw, 36px) clamp(16px, 3vw, 40px) clamp(20px, 2.6vw, 32px); align-content: start; }
.kategorien ol { list-style: none; padding: 0; margin-top: 10px; }
.kategorien li a { display: flex; align-items: baseline; gap: 14px; padding: 2px 0; }
.kategorien .wort { min-width: 0; overflow-wrap: anywhere; display: inline-block; font-size: clamp(2.25rem, 4.6vw, 4.25rem); font-weight: 400; font-stretch: 125%; line-height: 1.08; letter-spacing: -.01em; padding: 0 .08em 0 0; }
.kategorien .mono { transition: color 200ms var(--ui); }
.kategorien li a:hover .mono, .kategorien li a.zeigt .mono { color: var(--text); }
.kategorien li a:focus-visible { outline: 1px solid var(--text); outline-offset: 4px; }

.neu .reihe { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(10px, 1.2vw, 16px); margin-top: 12px; }
.karte { display: block; }
.karte .bild { position: relative; aspect-ratio: 3 / 4; background: var(--flaeche); overflow: hidden; }
.karte img { display: block; width: 100%; height: 100%; object-fit: cover; }
.karte .groessen { position: absolute; left: 8px; right: 8px; bottom: 8px; display: flex; justify-content: space-between; padding: 7px 9px; background: rgba(246, 243, 238, .92); opacity: 0; transition: opacity 300ms var(--ui); }
.karte:hover .groessen, .karte.zeigt .groessen, .karte:focus-visible .groessen { opacity: 1; }
.karte .groessen .mono { color: var(--text); font-size: 10px; }
.karte .name { margin-top: 10px; font-size: 13.5px; font-weight: 500; line-height: 1.3; }
.karte .preis { font-size: 13px; margin-top: 2px; }
.karte .stoff { margin-top: 3px; font-size: 10.5px; }
.karte:focus-visible { outline: 1px solid var(--text); outline-offset: 4px; }
.kopfzeile-klein { display: flex; justify-content: space-between; }

/* Pult */
.pult { position: fixed; right: 14px; bottom: 14px; z-index: 20; display: grid; gap: 8px; padding: 11px 13px; background: rgba(246, 243, 238, .94); border: 1px solid var(--linie); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.pult .m, .pult button { font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.pult .knoepfe { display: flex; gap: 4px; align-items: center; }
.pult button { padding: 7px 9px; border: 1px solid var(--linie); background: none; cursor: pointer; color: var(--text); transition: background-color 200ms var(--ui), color 200ms var(--ui); }
.pult button[aria-pressed="true"] { background: var(--text); color: var(--grund); border-color: var(--text); }
.pult .werte { text-transform: none; letter-spacing: .04em; font-size: 10.5px; }

/* Gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.vorschau body { overflow: hidden; }

@media (max-width: 1080px) {
  .kopf nav:first-child a:nth-child(n+3), .kopf nav:last-child a:nth-child(-n+1) { display: none; }
}
@media (max-width: 760px) {
  .buehne { grid-template-columns: 1fr; }
  .kampagne { min-height: 0; height: 68svh; }
  .kopf { grid-template-columns: auto 1fr auto; gap: 14px; }
  .kopf nav:first-child a:nth-child(n+2) { display: none; }
  .kopf nav:last-child a:nth-child(-n+2) { display: none; }
  .wortmarke { font-size: 15px; }
  .neu .reihe { grid-template-columns: repeat(3, 72%); overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; margin-right: calc(-1 * clamp(16px, 3vw, 40px)); padding-right: 16px; }
  .neu .reihe .karte { scroll-snap-align: start; }
  .pult { bottom: 12px; }
}
@media (max-width: 360px) {
  .kopf { gap: 8px; padding-inline: 12px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
/* Touch: keine Verzerrung (Steckbrief: mobil weglassen), Größen sichtbar */
@media (hover: none) {
  .karte .groessen { opacity: 1; }
}
/* Reduziert: kein Filter, nur Überblendung */
.reduziert .morph .w1, .reduziert .morph .w2 { transition: opacity 300ms var(--ui); }
.reduziert .morph:hover .w1, .reduziert .morph.zeigt .w1 { opacity: 0; }
.reduziert .morph:hover .w2, .reduziert .morph.zeigt .w2 { opacity: 1; }
.reduziert .kategorien .wort { text-decoration: underline; text-decoration-color: transparent; text-underline-offset: .12em; text-decoration-thickness: 2px; transition: text-decoration-color 200ms var(--ui); }
.reduziert .kategorien a:hover .wort, .reduziert .kategorien a.zeigt .wort { text-decoration-color: currentColor; }
.reduziert .karte img { transition: opacity 300ms var(--ui); }
.reduziert .karte:hover img, .reduziert .karte.zeigt img { opacity: .82; }
</style>
</head>
<body>
<svg class="filter-defs" aria-hidden="true" focusable="false">
  <defs>
    <!-- Gooey: beide Wörter werden weichgezeichnet (CSS blur), der Filter schneidet das Alpha hart:
         wo sich die Unschärfen überlagern, verschmelzen die Buchstaben -->
    <filter id="gooey" x="-10%" y="-20%" width="120%" height="140%" color-interpolation-filters="sRGB">
      <feColorMatrix in="SourceGraphic" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 255 -140"/>
    </filter>
  </defs>
  <defs data-defs></defs>
</svg>

<header class="kopf">
  <nav aria-label="Hauptmenü"><a href="#" aria-current="page">Damen</a><a href="#">Herren</a><a href="#">Kollektion</a><a href="#">Journal</a></nav>
  <a class="wortmarke" href="#" aria-label="Venneker Düsseldorf, Startseite">VENNEKER<small>DÜSSELDORF</small></a>
  <nav aria-label="Service"><a href="#">Suche</a><a href="#">Konto</a><a href="#" class="ziffern">Tasche (0)</a></nav>
</header>

<main class="buehne">
  <section class="kampagne" aria-label="Kampagne">
    <img data-stoff="kampagne" alt="Plissierter Satin in Rheingrau, im Licht schräg gefaltet">
    <div class="ecke-o"><span class="mono">Kampagne Rheinnebel</span><span class="mono ziffern">HW 2026</span></div>
    <div class="ecke-u">
      <p class="mono">Kollektion Herbst und Winter</p>
      <h1 class="morph" data-morph aria-label="Herbst und Winter"><span class="w1" aria-hidden="true">Herbst</span><span class="w2" aria-hidden="true">Winter</span></h1>
      <p class="zeile"><a href="#">Kollektion entdecken</a><a href="#">Lookbook</a></p>
    </div>
  </section>

  <div class="seite">
    <nav class="kategorien" aria-label="Kategorien">
      <div class="kopfzeile-klein"><span class="mono">Kategorien</span><span class="mono">Damen</span></div>
      <ol>
        <li><a href="#" data-wabern><span class="wort">Mäntel</span><span class="mono ziffern">(12)</span></a></li>
        <li><a href="#" data-wabern><span class="wort">Strick</span><span class="mono ziffern">(18)</span></a></li>
        <li><a href="#" data-wabern><span class="wort">Plissee</span><span class="mono ziffern">(09)</span></a></li>
        <li><a href="#" data-wabern><span class="wort">Accessoires</span><span class="mono ziffern">(21)</span></a></li>
      </ol>
    </nav>
    <section class="neu" aria-label="Neu eingetroffen">
      <div class="kopfzeile-klein"><span class="mono">Neu eingetroffen</span><a class="mono" href="#">Alle 60</a></div>
      <div class="reihe">
        <a class="karte" href="#" data-bild>
          <div class="bild"><img data-stoff="boucle" alt="Walkloden in Kamel, Nahaufnahme"><span class="groessen"><span class="mono">34–44</span><span class="mono">Ansehen</span></span></div>
          <p class="name">Mantel Lörick</p><p class="preis ziffern">690 €</p>
          <p class="mono stoff">Walkloden · Schurwolle</p>
        </a>
        <a class="karte" href="#" data-bild>
          <div class="bild"><img data-stoff="strick" alt="Grobstrick in Moosgrün, Nahaufnahme"><span class="groessen"><span class="mono">XS–L</span><span class="mono">Ansehen</span></span></div>
          <p class="name">Pullover Golzheim</p><p class="preis ziffern">240 €</p>
          <p class="mono stoff">Grobstrick · 70 % Mohair</p>
        </a>
        <a class="karte" href="#" data-bild>
          <div class="bild"><img data-stoff="plissee" alt="Plissierter Satin in Rauchblau, Nahaufnahme"><span class="groessen"><span class="mono">34–42</span><span class="mono">Ansehen</span></span></div>
          <p class="name">Rock Volmerswerth</p><p class="preis ziffern">320 €</p>
          <p class="mono stoff">Plissee · Satin</p>
        </a>
      </div>
    </section>
  </div>
</main>

<div class="pult">
  <div class="knoepfe" role="group" aria-label="Stärke der Verzerrung"><span class="m">Stärke</span>
    <button type="button" data-staerke="20" aria-pressed="false">20</button>
    <button type="button" data-staerke="40" aria-pressed="true">40</button>
    <button type="button" data-staerke="60" aria-pressed="false">60</button>
  </div>
  <span class="m werte" data-werte>scale 0 → 40 → 0 · 450 + 750 ms power2.inOut</span>
</div>

<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2 1.5 16.5 10l-6.6 1.4-3.2 6.1Z" fill="#121010" stroke="#f6f3ee" stroke-width="1.3" stroke-linejoin="round"/></svg>

<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 touch = matchMedia('(hover: none)');
  const NS = 'http://www.w3.org/2000/svg';

  /* ---------- Stoffbilder: eigene SVG-Texturen, einmal gerastert als <img> ---------- */
  const svg = (inhalt, w = 800, h = 1000) => 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(`<svg xmlns="${NS}" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${inhalt}</svg>`);
  const plissee = (a, b, c, w, h, seed, licht, falte = 18) => `
    <defs>
      <linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="${a}"/><stop offset=".42" stop-color="${b}"/><stop offset=".55" stop-color="${c}"/><stop offset=".62" stop-color="${b}"/><stop offset="1" stop-color="${a}"/></linearGradient>
      <pattern id="p" width="${falte}" height="${h}" patternUnits="userSpaceOnUse"><rect width="${falte}" height="${h}" fill="url(#g)"/></pattern>
      <filter id="f" x="-10%" y="-10%" width="120%" height="120%"><feTurbulence type="fractalNoise" baseFrequency="0.0028 0.0012" numOctaves="1" seed="${seed}"/><feDisplacementMap in="SourceGraphic" scale="150" xChannelSelector="R" yChannelSelector="G"/></filter>
      <radialGradient id="l" cx="${licht}" cy=".22" r=".95"><stop offset="0" stop-color="#fff" stop-opacity=".26"/><stop offset=".55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".5"/></radialGradient>
      <filter id="k"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" seed="4"/><feColorMatrix values="0 0 0 0 .5  0 0 0 0 .5  0 0 0 0 .5  0 0 0 .07 0"/></filter>
    </defs>
    <rect width="${w}" height="${h}" fill="${a}"/>
    <rect x="-80" y="-80" width="${w + 160}" height="${h + 160}" fill="url(#p)" filter="url(#f)"/>
    <rect width="${w}" height="${h}" fill="url(#l)"/>
    <rect width="${w}" height="${h}" filter="url(#k)"/>`;
  const STOFF = {
    kampagne: () => svg(plissee('#232c34', '#55636f', '#9aa6ae', 1400, 1100, 7, '.68', 30), 1400, 1100),
    plissee: () => svg(plissee('#2e3540', '#5e6878', '#a3abb8', 800, 1000, 11, '.3')),
    boucle: () => svg(`
      <defs><filter id="w" x="0" y="0" width="100%" height="100%">
        <feTurbulence type="fractalNoise" baseFrequency=".055" numOctaves="4" seed="9" result="n"/>
        <feDiffuseLighting in="n" surfaceScale="6" lighting-color="#f2d9b8" result="l"><feDistantLight azimuth="235" elevation="38"/></feDiffuseLighting>
        <feComposite in="l" in2="SourceGraphic" operator="arithmetic" k1="1.25" k2="0" k3="0" k4="0"/>
      </filter>
      <radialGradient id="v" cx=".4" cy=".3" r=".9"><stop offset="0" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-color="#2a1a0c" stop-opacity=".45"/></radialGradient></defs>
      <rect width="800" height="1000" fill="#b88c5f" filter="url(#w)"/><rect width="800" height="1000" fill="url(#v)"/>`),
    strick: () => svg(`
      <defs>
        <pattern id="m" width="26" height="22" patternUnits="userSpaceOnUse"><rect width="26" height="22" fill="#3e4024"/>
          <path d="M1 0 L12 15 M25 0 L14 15" stroke="#7b7c48" stroke-width="9" stroke-linecap="round"/>
          <path d="M3 1 L11 12 M23 1 L15 12" stroke="#a3a46a" stroke-width="2.4" stroke-linecap="round" opacity=".7"/></pattern>
        <filter id="d" x="-5%" y="-5%" width="110%" height="110%"><feTurbulence type="fractalNoise" baseFrequency=".004 .009" numOctaves="2" seed="2"/><feDisplacementMap in="SourceGraphic" scale="46" xChannelSelector="R" yChannelSelector="G"/></filter>
        <filter id="h"><feTurbulence type="fractalNoise" baseFrequency=".6" numOctaves="2" seed="5"/><feColorMatrix values="0 0 0 0 .9  0 0 0 0 .9  0 0 0 0 .75  0 0 0 .12 0"/></filter>
        <radialGradient id="v" cx=".55" cy=".25" r=".95"><stop offset="0" stop-color="#fff" stop-opacity=".16"/><stop offset="1" stop-color="#10110a" stop-opacity=".55"/></radialGradient>
      </defs>
      <g transform="rotate(-6 400 500)"><rect x="-120" y="-120" width="1040" height="1240" fill="url(#m)" filter="url(#d)"/></g>
      <rect width="800" height="1000" filter="url(#h)"/><rect width="800" height="1000" fill="url(#v)"/>`)
  };
  document.querySelectorAll('[data-stoff]').forEach(img => { img.src = STOFF[img.dataset.stoff](); });

  /* ---------- Filter je Element anlegen (eigene Instanz, damit mehrere gleichzeitig laufen können) ---------- */
  const defs = document.querySelector('[data-defs]');
  let nr = 0;
  function filterFuer(el, frequenz, region) {
    const id = 'verz-' + (++nr);
    const f = document.createElementNS(NS, 'filter');
    f.setAttribute('id', id);
    Object.entries(region).forEach(([k, v]) => f.setAttribute(k, v));
    f.setAttribute('color-interpolation-filters', 'sRGB');
    f.innerHTML = `<feTurbulence type="fractalNoise" baseFrequency="${frequenz}" numOctaves="1" seed="${nr * 3}" result="rauschen"/>
      <feDisplacementMap in="SourceGraphic" in2="rauschen" scale="0" xChannelSelector="R" yChannelSelector="G"/>`;
    defs.appendChild(f);
    return { id, turb: f.querySelector('feTurbulence'), disp: f.querySelector('feDisplacementMap'), el, tl: null };
  }

  let STAERKE = 40;
  const EIN = .45, AUS = .75, KURVE = 'power2.inOut';   // 1200 ms gesamt, Endzustand scharf
  function wabern(z, faktor = 1) {
    if (reduziert() || touch.matches || !window.gsap) return;
    if (z.tl && z.tl.isActive()) return;
    const [fx, fy] = z.basis;
    z.tl = gsap.timeline({ onComplete: () => { z.el.style.filter = ''; } })
      .set(z.el, { filter: `url(#${z.id})` })
      .set(z.turb, { attr: { baseFrequency: `${fx} ${fy}` } })
      .to(z.disp, { attr: { scale: STAERKE * faktor }, duration: EIN, ease: KURVE })
      .to(z.disp, { attr: { scale: 0 }, duration: AUS, ease: KURVE })
      .to(z.turb, { attr: { baseFrequency: `${fx * 1.6} ${fy * .45}` }, duration: EIN + AUS, ease: KURVE }, '<-' + EIN);
  }

  /* Text: Kategorien wabern (Wort bleibt echter, auswählbarer Text) */
  const woerter = [...document.querySelectorAll('[data-wabern]')].map(a => {
    const z = filterFuer(a.querySelector('.wort'), '0.012 0.09', { x: '-8%', y: '-30%', width: '116%', height: '160%' });
    z.basis = [0.012, 0.09]; z.a = a;
    a.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') wabern(z, .8); });
    a.addEventListener('focus', () => wabern(z, .8));
    return z;
  });
  /* Bild: Stoff weht kurz */
  const bilder = [...document.querySelectorAll('[data-bild]')].map(a => {
    const z = filterFuer(a.querySelector('img'), '0.006 0.018', { x: '-5%', y: '-5%', width: '110%', height: '110%' });
    z.basis = [0.006, 0.018]; z.a = a;
    a.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') wabern(z, 1); });
    a.addEventListener('focus', () => wabern(z, 1));
    return z;
  });

  /* Gooey: Herbst verschmilzt zu Winter und zurück */
  const morph = document.querySelector('[data-morph]');
  const [w1, w2] = morph.querySelectorAll('span');
  const BLUR = 8;                                   // px Unschärfe-Faktor: blur = 8 / anteil − 8
  let morphTl = null, winter = false;
  const stand = { f: 0 };                           // 0 = Herbst, 1 = Winter
  function malMorph() {
    const f = stand.f, g = 1 - f;
    w2.style.filter = `blur(${Math.min(BLUR / Math.max(f, .001) - BLUR, 60).toFixed(2)}px)`;
    w1.style.filter = `blur(${Math.min(BLUR / Math.max(g, .001) - BLUR, 60).toFixed(2)}px)`;
    w2.style.opacity = Math.pow(f, .4).toFixed(3);
    w1.style.opacity = Math.pow(g, .4).toFixed(3);
  }
  function morphen(zu) {
    if (zu === winter) return;
    winter = zu;
    if (reduziert() || touch.matches || !window.gsap) { morph.classList.toggle('zeigt', zu); return; }
    if (morphTl) morphTl.kill();
    morphTl = gsap.timeline({ onComplete: () => { morph.style.filter = ''; w1.style.filter = w2.style.filter = ''; } })
      .set(morph, { filter: 'url(#gooey)' })
      .to(stand, { f: zu ? 1 : 0, duration: 1.2, ease: KURVE, onUpdate: malMorph });
  }
  morph.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') morphen(true); });
  morph.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') morphen(false); });
  /* Touch: Tippen auf die Überschrift wechselt das Wort ohne Filter */
  morph.addEventListener('click', () => { if (touch.matches) morphen(!winter); });

  /* Pult */
  const werte = document.querySelector('[data-werte]');
  document.querySelectorAll('[data-staerke]').forEach(b => b.addEventListener('click', () => {
    STAERKE = +b.dataset.staerke;
    document.querySelectorAll('[data-staerke]').forEach(x => x.setAttribute('aria-pressed', x === b));
    werte.textContent = `scale 0 → ${STAERKE} → 0 · 450 + 750 ms power2.inOut`;
  }));
  document.querySelectorAll('.kampagne a, .kategorien a, .karte, .kopf a, .neu .kopfzeile-klein a').forEach(a => a.addEventListener('click', e => e.preventDefault()));

  /* ---------- Vorführung: gedachter Zeiger fährt Überschrift, Kategorien und Stoffe ab ---------- */
  const zeiger = document.querySelector('[data-zeiger]');
  let uhr = null, S = [], i = 0, ab = 0, p = { x: 0, y: 0 }, von = { x: 0, y: 0 }, unterAlt = null;
  const mitte = (el, fx = .5, fy = .5) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width * fx, y: r.top + r.height * fy }; };
  function stationen() {
    const k = document.querySelector('.kampagne').getBoundingClientRect();
    return [
      { x: k.left + k.width * .7, y: k.top + k.height * .35, ms: 700 },
      { ...mitte(w1, .45, .55), ms: 1900 },
      { x: k.left + k.width * .72, y: k.top + k.height * .4, ms: 900 },
      ...woerter.map((z, n) => ({ ...mitte(z.el, .35 + n * .08, .55), ms: 1000 })),
      ...bilder.map(z => ({ ...mitte(z.el, .5, .45), ms: 1300 })),
    ];
  }
  function schritt(t) {
    if (t - ab > S[i].ms) { von = { ...p }; i = (i + 1) % S.length; ab = t; if (i === 0) S = stationen(); }
    const q = Math.min(1, (t - ab) / 600), e = q < .5 ? 2 * q * q : 1 - Math.pow(-2 * q + 2, 2) / 2;
    p.x = von.x + (S[i].x - von.x) * e; p.y = von.y + (S[i].y - von.y) * e;
    zeiger.style.transform = `translate(${p.x - 2}px, ${p.y - 1}px)`;
    const el = document.elementFromPoint(p.x, p.y);
    const ziel = el && (el.closest('[data-wabern], [data-bild], [data-morph]'));
    if (ziel !== unterAlt) {
      if (unterAlt) { unterAlt.classList.remove('zeigt'); if (unterAlt === morph) morphen(false); }
      if (ziel) {
        ziel.classList.add('zeigt');
        if (ziel === morph) morphen(true);
        else (woerter.find(z => z.a === ziel) || bilder.find(z => z.a === ziel)) && wabern(woerter.find(z => z.a === ziel) || bilder.find(z => z.a === ziel), ziel.hasAttribute('data-wabern') ? .8 : 1);
      }
      unterAlt = ziel;
    }
    uhr = requestAnimationFrame(schritt);
  }
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    S = stationen(); i = 0; ab = performance.now(); p = { ...S[0] }; von = { ...p };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung');
    if (unterAlt) unterAlt.classList.remove('zeigt'); unterAlt = null;
    [...woerter, ...bilder].forEach(z => { if (z.tl) z.tl.progress(1); });
    morphen(false); if (morphTl) morphTl.progress(1);
  }

})();
</script>