Zum Inhalt
← Mikroanimationen

In-den-Warenkorb-Flug

Salz-Manufaktur aus Lüneburg: Das Produktbild fliegt im Bogen zum Warenkorb, das Icon federt, der Zähler rollt hoch und ein Mini-Warenkorb zeigt den Weg zum Gratisversand.

Beim Hinzufügen fliegt eine Miniatur des Produkts in einem Bogen zum Warenkorb-Icon. Das Icon reagiert mit kurzem Pop oder Squash, der Zähler rollt hoch. Gleichzeitig zeigt der Button Erfolg, zum Beispiel mit Häkchen.

Räumliche Verknüpfung: Man sieht, wohin das Produkt gegangen ist, und weiß, wo man es wiederfindet.

Wann einsetzen

Shops, Merklisten und „Zur Auswahl hinzufügen“ in Konfiguratoren.

Wann nicht

Wenn der Warenkorb nicht sichtbar ist, das wäre ein Flug ins Nichts. Bei Massenaktionen.

Varianten

  • Bogen über zwei getrennte Achsen (x linear, y ease-in), wie im Motion-Beispiel „Add to basket“
  • Bogen per offset-path mit quadratischer Kurve
  • View Transition als Shared Element zwischen Produktbild und Warenkorb-Icon

Umsetzung

Klon des Produktbilds fixed an die Startposition setzen. Bogen über zwei getrennte Animationen: äußeres Element x linear, inneres y ease-in, dazu scale 1 → etwa .2, 500 bis 700 ms. Alternativ offset-path: path('M… Q… …') mit offset-distance 0 → 100 %. Danach Klon entfernen und am Warenkorb-Icon eine kurze @keyframes-Animation (pop) abspielen. View-Transition-Variante: view-transition-name am Klon, im Update auf die Icon-Position setzen; das ergibt eine gerade Bahn.

Robust umsetzen

Telefon. Identisch. Der Warenkorb sitzt oft unten oder im klebenden Kopf, Hauptsache sichtbar.

Ohne Bewegung. Nur Zähler und Bestätigung, ohne Flug. Pop höchstens ganz kurz oder weglassen.

Barrierefrei. Statusmeldung „Zum Warenkorb hinzugefügt“ per aria-live. Klon aria-hidden. Die Anzahl gehört auch ins aria-label des Warenkorb-Links.

Code

<!DOCTYPE html>
<!--
  In-den-Warenkorb-Flug
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/in-den-warenkorb-flug/
  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>In-den-Warenkorb-Flug · 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: 'Young Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/young-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }

:root {
  --grund: #f5f4f1; --kachel: #ece9e2; --tinte: #212121; --leise: #666666; --linie: rgba(33, 33, 33, .12);
  --gruen: #10714e; --akzent: #b4472b;   /* Backstein, Marken-Akzent (austauschbar) */
  --auf: cubic-bezier(0, 0, 0.2, 1);      /* Öffnen, Bestätigen */
  --zu: cubic-bezier(0.4, 0, 1, 1);       /* Schließen */
  --flug-y: cubic-bezier(0.42, 0, 1, 1);  /* ease-in, y-Achse des Bogens */
  --ui: 180ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.label { font-size: .75rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }
.preis, .zahl { font-variant-numeric: tabular-nums; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* Ankündigung und Kopf */
.ansage { background: var(--tinte); color: var(--grund); text-align: center; font-size: .8125rem; padding: 8px 16px; }
.kopf { position: sticky; top: 0; z-index: 30; background: var(--grund); border-bottom: 1px solid var(--linie); }
.kopf-innen { height: 64px; display: flex; align-items: center; gap: 32px; padding: 0 clamp(16px, 3vw, 40px); }
.marke { font: 400 1.5rem/1 'Young Serif', serif; letter-spacing: -.01em; display: flex; align-items: center; gap: 10px; }
.marke svg { width: 26px; height: 26px; }
.navi { display: flex; gap: 24px; font-size: .9375rem; font-weight: 500; }
.navi a { padding: 6px 0; border-bottom: 1px solid transparent; transition: border-color var(--ui) var(--auf); }
.navi a:hover { border-color: currentColor; }
.werkzeuge { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.icon { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 50%; transition: background-color var(--ui) var(--auf); position: relative; }
.icon:hover { background: var(--kachel); }
.icon svg { width: 22px; height: 22px; fill: none; stroke: var(--tinte); stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
.korb-wrap { position: relative; }
.korb svg { transform-origin: 50% 80%; }
.badge { position: absolute; right: 2px; top: 3px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px; background: var(--tinte); color: var(--grund); font-size: .6875rem; font-weight: 700; line-height: 18px; text-align: center; overflow: hidden; transition: background-color 300ms var(--auf); }
.badge.leer { opacity: 0; }
.badge .roll { display: block; height: 18px; }
.badge.blitz { background: var(--gruen); }

/* Mini-Warenkorb unter dem Icon */
.mini { position: absolute; right: 0; top: calc(100% + 10px); width: 320px; background: #fff; border: 1px solid var(--linie); border-radius: 14px; padding: 16px; box-shadow: 0 18px 40px rgba(33, 33, 33, .14);
  opacity: 0; translate: 0 -8px; visibility: hidden; transition: opacity 200ms var(--zu), translate 200ms var(--zu), visibility 0s linear 200ms; z-index: 40; }
.mini.offen { opacity: 1; translate: 0 0; visibility: visible; transition: opacity 280ms var(--auf), translate 280ms var(--auf), visibility 0s; }
.mini h3 { font-size: .9375rem; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.mini h3 svg { width: 18px; height: 18px; }
.mini .zeile { display: grid; grid-template-columns: 52px 1fr auto; gap: 12px; align-items: center; margin: 14px 0; }
.mini .zeile .bild { width: 52px; aspect-ratio: 4 / 5; border-radius: 8px; background: var(--kachel); overflow: hidden; }
.mini .zeile .bild svg { width: 100%; height: 100%; display: block; }
.mini .zeile b { display: block; font-weight: 600; font-size: .9375rem; }
.mini .zeile small { color: var(--leise); font-size: .8125rem; }
.mini .summe { display: flex; justify-content: space-between; font-size: .9375rem; border-top: 1px solid var(--linie); padding-top: 12px; }
.mini .versand { margin-top: 10px; font-size: .8125rem; color: var(--leise); }
.mini .balken { height: 4px; border-radius: 2px; background: var(--kachel); margin-top: 6px; overflow: hidden; }
.mini .balken i { display: block; height: 100%; background: var(--gruen); transform-origin: 0 50%; transform: scaleX(0); transition: transform 400ms var(--auf); }
.mini .kasse { display: block; text-align: center; margin-top: 14px; background: var(--tinte); color: var(--grund); border-radius: 999px; padding: 11px; font-weight: 600; font-size: .9375rem; }

/* Claim */
main { padding: 0 clamp(16px, 3vw, 40px) 40px; }
.claim { display: grid; grid-template-columns: 1.3fr 1fr; gap: 32px; align-items: end; padding: clamp(28px, 5vh, 56px) 0 clamp(24px, 4vh, 40px); }
h1 { font: 400 clamp(2.25rem, 1.4rem + 3.6vw, 4.5rem)/1.05 'Young Serif', serif; letter-spacing: -.01em; text-wrap: balance; }
.claim p { font-size: 1rem; line-height: 1.55; color: var(--leise); max-width: 42ch; }
.vertrauen { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-top: 14px; font-size: .8125rem; font-weight: 500; color: var(--tinte); }
.vertrauen span { display: inline-flex; align-items: center; gap: 6px; }
.vertrauen svg { width: 14px; height: 14px; stroke: var(--gruen); fill: none; stroke-width: 2; }

/* Bestseller */
.reihe-kopf { display: flex; align-items: baseline; justify-content: space-between; padding-bottom: 16px; }
.reihe-kopf h2 { font-size: clamp(1.5rem, 1.1rem + 1.4vw, 2.25rem); font-weight: 600; letter-spacing: -.015em; }
.reihe-kopf a { font-size: .9375rem; font-weight: 500; border-bottom: 1px solid currentColor; }
.raster { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(12px, 1.6vw, 24px); }
.produkt { display: flex; flex-direction: column; }
.produkt .bild { position: relative; aspect-ratio: 4 / 5; background: var(--kachel); border-radius: 12px; overflow: hidden; }
.produkt .bild > svg { width: 100%; height: 100%; display: block; transition: scale 400ms var(--auf); }
.produkt:hover .bild > svg { scale: 1.03; }
.produkt .marke-badge { position: absolute; left: 12px; top: 12px; background: var(--grund); padding: 5px 9px; border-radius: 999px; font-size: .6875rem; }
.produkt .marke-badge.neu { background: var(--akzent); color: #fff; }
.produkt .info { flex: 1; align-content: start; padding-top: 12px; display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; }
.produkt h3 { font-size: 1.0625rem; font-weight: 600; letter-spacing: -.005em; }
.produkt .preis { font-size: 1.0625rem; font-weight: 600; }
.produkt .detail { color: var(--leise); font-size: .875rem; }
.produkt .grundpreis { white-space: nowrap; }
.produkt .sterne { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; font-size: .8125rem; color: var(--leise); margin-top: 2px; }
.produkt .sterne svg { width: 64px; height: 12px; }
.kaufen { margin-top: 12px; height: 46px; border-radius: 999px; background: var(--tinte); color: var(--grund); font-weight: 600; font-size: .9375rem; display: grid; place-items: center; position: relative; overflow: hidden;
  transition: background-color 200ms var(--auf), scale 150ms var(--auf); }
.kaufen:hover { background: #000; }
.kaufen:active, .kaufen.druck { scale: .97; }
.kaufen:focus-visible { outline: 2px solid var(--tinte); outline-offset: 3px; }
.kaufen > span { grid-area: 1 / 1; display: inline-flex; align-items: center; gap: 8px; transition: opacity 200ms var(--auf), translate 200ms var(--auf); }
.kaufen .ok { opacity: 0; translate: 0 8px; }
.kaufen .ok svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.kaufen .ok path { stroke-dasharray: 20; stroke-dashoffset: 20; transition: stroke-dashoffset 250ms var(--auf) 120ms; }
.kaufen.erledigt { background: var(--gruen); }
.kaufen.erledigt .los { opacity: 0; translate: 0 -8px; }
.kaufen.erledigt .ok { opacity: 1; translate: 0 0; }
.kaufen.erledigt .ok path { stroke-dashoffset: 0; }

/* Der fliegende Klon */
.flug { position: fixed; left: 0; top: 0; z-index: 60; pointer-events: none; }
.flug .klon { border-radius: 12px; overflow: hidden; background: var(--kachel); box-shadow: 0 10px 30px rgba(33, 33, 33, .18); }
.flug .klon svg { width: 100%; height: 100%; display: block; }
::view-transition-group(flug) { animation-duration: 600ms; animation-timing-function: cubic-bezier(0.42, 0, 1, 1); }
::view-transition-old(flug), ::view-transition-new(flug) { width: 100%; height: 100%; animation: none; }
::view-transition-new(flug) { display: none; }
:root.vt-flug { view-transition-name: none; }
@keyframes pop { 0% { transform: scale(1, 1); } 35% { transform: scale(1.22, .82); } 65% { transform: scale(.92, 1.1); } 100% { transform: scale(1, 1); } }
.korb.pop svg { animation: pop 420ms var(--auf); }

/* Demo-Leiste im Fluss */
.leiste { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--linie); font: 500 11px/1.4 'Instrument Sans', sans-serif; letter-spacing: .06em; text-transform: uppercase; color: var(--leise); }
.leiste .wahl { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: var(--kachel); }
.leiste .wahl button { padding: 6px 11px; border-radius: 999px; font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); transition: background-color var(--ui) var(--auf), color var(--ui) var(--auf); }
.leiste .wahl button[aria-pressed="true"] { background: var(--tinte); color: var(--grund); }
.leiste .werte { text-transform: none; letter-spacing: .01em; font-size: 12px; }

.vorschau body { overflow: hidden; }
.vorschau .claim p, .vorschau .vertrauen, .vorschau .leiste, .vorschau .ansage, .vorschau .reihe-kopf a { display: none; }
.vorschau .claim { padding: 26px 0 22px; }

@media (max-width: 1080px) { .navi a:nth-child(n+3) { display: none; } }
@media (max-width: 900px) {
  .raster { grid-template-columns: repeat(2, 1fr); }
  .claim { grid-template-columns: 1fr; gap: 12px; }
}
@media (max-width: 640px) {
  .navi, .icon.suche, .ansage-zusatz { display: none; }
  .kopf-innen { gap: 12px; height: 58px; }
  .marke { font-size: 1.3125rem; }
  .produkt h3 { font-size: .9375rem; }
  .produkt .preis { font-size: .9375rem; }
  .produkt .detail { font-size: .8125rem; }
  .produkt .info { grid-template-columns: 1fr; }
  .produkt .sterne span { display: none; }
  .kaufen { height: 42px; font-size: .875rem; }
  .mini { width: min(320px, calc(100vw - 24px)); right: -8px; }
}
/* Touch: identisch, Tippen löst aus; kein Hover-Zoom */
@media (hover: none) { .produkt:hover .bild > svg { scale: 1; } }
/* Reduzierte Bewegung: kein Flug, kein Pop; Zähler und Mini-Warenkorb blenden nur */
.reduziert .korb.pop svg { animation: none; }
.reduziert .mini, .reduziert .mini.offen { translate: 0 0; }
.reduziert .kaufen > span, .reduziert .produkt .bild > svg { transition: opacity 150ms linear; translate: none !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="bogen">
<p class="ansage">Versandkostenfrei ab 39 €<span class="ansage-zusatz"> · Gesiedet aus Lüneburger Sole</span></p>
<header class="kopf">
  <div class="kopf-innen">
    <a class="marke" href="#start"><svg viewBox="0 0 26 26" aria-hidden="true"><path d="M13 2 22.5 7.5v11L13 24 3.5 18.5v-11Z" fill="none" stroke="#212121" stroke-width="1.6"/><path d="M13 7.5 17.8 10.3v5.4L13 18.5l-4.8-2.8v-5.4Z" fill="#b4472b"/></svg>Siedewerk</a>
    <nav class="navi" aria-label="Shop"><a href="#salze">Salze</a><a href="#sets">Geschenksets</a><a href="#rezepte">Rezepte</a><a href="#manufaktur">Manufaktur</a></nav>
    <div class="werkzeuge">
      <a class="icon suche" href="#suche" aria-label="Suche"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg></a>
      <a class="icon" href="#merkliste" aria-label="Merkliste"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z"/></svg></a>
      <div class="korb-wrap">
        <a class="icon korb" href="#warenkorb" data-korb aria-label="Warenkorb, leer"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 8h14l-1.2 11.2a1 1 0 0 1-1 .8H7.2a1 1 0 0 1-1-.8Z"/><path d="M9 10V6.5a3 3 0 0 1 6 0V10"/></svg><span class="badge leer zahl" data-badge aria-hidden="true"><span class="roll">0</span></span></a>
        <div class="mini" data-mini role="dialog" aria-label="Warenkorb-Vorschau">
          <h3><svg viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="8" fill="#10714e"/><path d="m5.5 9.2 2.3 2.3 4.7-4.8" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>Im Warenkorb</h3>
          <div class="zeile"><div class="bild" data-mini-bild></div><div><b data-mini-name>Siedesalz grob</b><small data-mini-menge>1 × 250 g</small></div><span class="preis" data-mini-preis>6,90 €</span></div>
          <div class="summe"><span>Zwischensumme (<span data-mini-anzahl>1</span> Artikel)</span><b class="preis" data-mini-summe>6,90 €</b></div>
          <p class="versand" data-mini-versand>Noch 32,10 € bis zum kostenlosen Versand</p>
          <div class="balken"><i data-mini-balken></i></div>
          <a class="kasse" href="#kasse">Zur Kasse</a>
        </div>
      </div>
    </div>
  </div>
</header>

<main id="start">
  <section class="claim">
    <h1>Gesiedet, nicht gemahlen.</h1>
    <div>
      <p>Unser Salz entsteht wie vor 800 Jahren: Lüneburger Sole wird in flachen Pfannen langsam verdampft, die Kristalle schöpfen wir von Hand ab. Ohne Rieselhilfe, ohne Jod.</p>
      <div class="vertrauen"><span><svg viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.3 3 3 6-6.3"/></svg>Versand in 1–2 Tagen</span><span><svg viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.3 3 3 6-6.3"/></svg>30 Tage Rückgabe</span><span><svg viewBox="0 0 14 14" aria-hidden="true"><path d="m2.5 7.3 3 3 6-6.3"/></svg>4,9 von 5 aus 1.840 Bewertungen</span></div>
    </div>
  </section>

  <section aria-labelledby="bestseller">
    <div class="reihe-kopf"><h2 id="bestseller">Unsere Bestseller</h2><a href="#salze">Alle Salze</a></div>
    <div class="raster" data-raster></div>
    <div class="leiste">
      <span>Flug</span>
      <div class="wahl" role="group" aria-label="Variante des Flugs"><button type="button" data-wahl="bogen" aria-pressed="true">Bogen, zwei Achsen</button><button type="button" data-wahl="pfad" aria-pressed="false">offset-path</button><button type="button" data-wahl="vt" aria-pressed="false">View Transition</button></div>
      <span class="werte" data-werte></span>
    </div>
  </section>
  <p class="nur-sr" aria-live="polite" data-status></p>
</main>

<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true" style="position:fixed;left:0;top:0;z-index:70;width:18px;height:18px;pointer-events:none;display:none"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#212121" stroke="#f5f4f1" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement, body = document.body;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const euro = v => v.toFixed(2).replace('.', ',') + ' €';
  const SCHWELLE = 39;
  const DAUER = 600, SKALA_ZIEL_PX = 26;   // 500–700 ms; Klon schrumpft auf etwa Icon-Größe

  // Eigene Produktbilder als SVG
  const KRISTALLE = (x, y, w, h, farben, n, saat) => {
    let s = saat, out = '';
    const r = () => (s = (s * 9301 + 49297) % 233280) / 233280;
    for (let i = 0; i < n; i++) {
      const cx = x + r() * w, cy = y + h - Math.pow(r(), .85) * h, g = 1.3 + r() * 2.6, a = r() * 6.28;
      const p = [0, 1, 2, 3].map(k => `${(cx + Math.cos(a + k * 1.57 + r() * .4) * g).toFixed(1)},${(cy + Math.sin(a + k * 1.57 + r() * .4) * g * .8).toFixed(1)}`).join(' ');
      out += `<polygon points="${p}" fill="${farben[Math.floor(r() * farben.length)]}"/>`;
    }
    return out;
  };
  const boden = '<ellipse cx="100" cy="214" rx="64" ry="7" fill="#212121" opacity=".09"/>';
  const BILDER = {
    grob: `<svg viewBox="0 0 200 250" aria-hidden="true">${boden}
      <rect x="56" y="84" width="88" height="128" rx="16" fill="#f8f7f3" opacity=".7" stroke="#cfc8bb" stroke-width="1.4"/>
      <clipPath id="g1"><rect x="58" y="86" width="84" height="124" rx="14"/></clipPath>
      <g clip-path="url(#g1)"><rect x="58" y="112" width="84" height="98" fill="#fbfaf7"/>${KRISTALLE(58, 108, 84, 102, ['#ffffff', '#e9e6df', '#d9d4ca', '#f2efe9', '#ffffff'], 320, 7)}</g>
      <rect x="62" y="58" width="76" height="30" rx="6" fill="#b98d5e"/><rect x="62" y="58" width="76" height="30" rx="6" fill="url(#kork)" opacity=".5"/>
      <rect x="66" y="128" width="68" height="58" rx="3" fill="#e2cfaa"/>
      <text x="100" y="152" text-anchor="middle" font-family="Young Serif" font-size="17" fill="#212121">Nº 1</text>
      <text x="100" y="168" text-anchor="middle" font-family="Instrument Sans" font-weight="700" font-size="7" letter-spacing="1.4" fill="#212121">SIEDESALZ</text>
      <text x="100" y="178" text-anchor="middle" font-family="Instrument Sans" font-size="5.5" letter-spacing="1" fill="#6b6b6b">GROB · 250 G</text>
      <path d="M62 96c12 3 64 3 76 0" stroke="#fff" stroke-width="3" opacity=".6" fill="none"/><rect x="128" y="96" width="5" height="100" rx="2.5" fill="#fff" opacity=".55"/>
      <defs><pattern id="kork" width="6" height="6" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r=".9" fill="#7d5a36"/><circle cx="5" cy="4.5" r=".6" fill="#7d5a36"/></pattern></defs></svg>`,
    fleur: `<svg viewBox="0 0 200 250" aria-hidden="true">${boden}
      <path d="M48 118h104l-6 74c-1 12-10 20-22 20H76c-12 0-21-8-22-20Z" fill="#f4f1ea" stroke="#d4cdbf" stroke-width="1.4"/>
      <rect x="51" y="140" width="98" height="12" fill="#2f4f7a"/><rect x="52" y="156" width="96" height="3" fill="#2f4f7a"/>
      <ellipse cx="100" cy="116" rx="56" ry="9" fill="#e9e4d8" stroke="#d4cdbf" stroke-width="1.4"/>
      <path d="M50 114c0-14 22-24 50-24s50 10 50 24" fill="#f4f1ea" stroke="#d4cdbf" stroke-width="1.4"/>
      <rect x="92" y="78" width="16" height="13" rx="6" fill="#2f4f7a"/>
      <text x="100" y="182" text-anchor="middle" font-family="Young Serif" font-size="12.5" fill="#212121">Fleur de Sole</text>
      <text x="100" y="194" text-anchor="middle" font-family="Instrument Sans" font-weight="700" font-size="5.5" letter-spacing="1.2" fill="#6b6b6b">STEINGUT · 120 G</text>
      <path d="M57 124c1 14 2 24 3 32" stroke="#fff" stroke-width="4" opacity=".7" fill="none" stroke-linecap="round"/></svg>`,
    rauch: `<svg viewBox="0 0 200 250" aria-hidden="true">${boden}
      <path d="M58 70h84l8 140H50Z" fill="#c4a073"/><path d="M58 70h84l2 14H56Z" fill="#b08a5c"/>
      <path d="M58 70l4-8h76l4 8" fill="#d2b083"/><rect x="66" y="60" width="68" height="4" fill="#9b774c"/>
      <clipPath id="g3"><ellipse cx="100" cy="162" rx="28" ry="30"/></clipPath>
      <ellipse cx="100" cy="162" rx="28" ry="30" fill="#3a2f27"/><g clip-path="url(#g3)">${KRISTALLE(72, 132, 56, 60, ['#6d5a4a', '#8a735e', '#4c3e33', '#a68d74'], 200, 31)}</g>
      <rect x="64" y="96" width="72" height="30" fill="#212121"/>
      <text x="100" y="110" text-anchor="middle" font-family="Instrument Sans" font-weight="700" font-size="8" letter-spacing="1.6" fill="#f5f4f1">RAUCHSALZ</text>
      <text x="100" y="120" text-anchor="middle" font-family="Instrument Sans" font-size="5.5" letter-spacing="1.2" fill="#c4a073">BUCHE · 200 G</text>
      <path d="M56 84 50 210" stroke="#000" opacity=".08" stroke-width="10"/></svg>`,
    heide: `<svg viewBox="0 0 200 250" aria-hidden="true">${boden}
      <rect x="52" y="104" width="96" height="108" rx="18" fill="#f8f7f3" opacity=".7" stroke="#cfc8bb" stroke-width="1.4"/>
      <clipPath id="g4"><rect x="54" y="106" width="92" height="104" rx="16"/></clipPath>
      <g clip-path="url(#g4)"><rect x="54" y="124" width="92" height="86" fill="#fbfaf7"/>${KRISTALLE(54, 120, 92, 90, ['#ffffff', '#ebe7e0', '#8b5a8c', '#5f7d4e', '#ffffff', '#e4e0d8', '#ffffff', '#a77aa6', '#ebe7e0'], 300, 53)}</g>
      <rect x="56" y="84" width="88" height="24" rx="5" fill="#8f9192"/><path d="M60 90h80M60 96h80M60 102h80" stroke="#7b7d7e" stroke-width="1"/>
      <rect x="64" y="140" width="72" height="46" rx="3" fill="#fff"/>
      <text x="100" y="161" text-anchor="middle" font-family="Young Serif" font-size="13" fill="#5b3a5c">Heide</text>
      <text x="100" y="174" text-anchor="middle" font-family="Instrument Sans" font-weight="700" font-size="5.5" letter-spacing="1.2" fill="#6b6b6b">KRÄUTER · 150 G</text>
      <rect x="136" y="116" width="5" height="84" rx="2.5" fill="#fff" opacity=".55"/></svg>`
  };
  const PRODUKTE = [
    { id: 'grob', name: 'Siedesalz grob', menge: '250 g', grund: '2,76 €/100 g', preis: 6.90, sterne: '4,9', n: 812, badge: 'Bestseller' },
    { id: 'fleur', name: 'Fleur de Sole', menge: '120 g, Steingut', grund: '12,08 €/100 g', preis: 14.50, sterne: '5,0', n: 236, badge: '' },
    { id: 'rauch', name: 'Rauchsalz Buche', menge: '200 g', grund: '3,95 €/100 g', preis: 7.90, sterne: '4,8', n: 401, badge: '' },
    { id: 'heide', name: 'Heidekräuter-Salz', menge: '150 g', grund: '5,60 €/100 g', preis: 8.40, sterne: '4,8', n: 127, badge: 'Neu' }
  ];
  const sterneSvg = '<svg viewBox="0 0 64 12" aria-hidden="true">' + [0, 1, 2, 3, 4].map(i => `<path transform="translate(${i * 13} 0)" d="M6 .6 7.6 4l3.7.4-2.8 2.5.8 3.7L6 8.7l-3.3 1.9.8-3.7L.7 4.4 4.4 4Z" fill="#212121"/>`).join('') + '</svg>';

  const raster = $('[data-raster]');
  PRODUKTE.forEach(p => {
    const el = document.createElement('article');
    el.className = 'produkt';
    el.innerHTML = `<a class="bild" href="#${p.id}" data-bild="${p.id}" aria-label="${p.name}">${BILDER[p.id]}${p.badge ? `<span class="marke-badge label${p.badge === 'Neu' ? ' neu' : ''}">${p.badge}</span>` : ''}</a>
      <div class="info"><h3>${p.name}</h3><span class="preis">${euro(p.preis)}</span><span class="detail">${p.menge} · <span class="grundpreis">${p.grund}</span></span>
      <span class="sterne">${sterneSvg}<span>${p.sterne} (${p.n})</span></span></div>
      <button class="kaufen" type="button" data-kaufen="${p.id}"><span class="los">In den Warenkorb</span><span class="ok" aria-hidden="true"><svg viewBox="0 0 18 18" aria-hidden="true"><path d="m3.5 9.5 3.6 3.6 7.4-8"/></svg>Hinzugefügt</span></button>`;
    raster.appendChild(el);
  });
  document.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', e => e.preventDefault()));

  const korb = $('[data-korb]'), badge = $('[data-badge]'), mini = $('[data-mini]'), status = $('[data-status]');
  let anzahl = 0, summe = 0, variante = 'bogen', miniUhr = null;
  const WERTE = {
    bogen: 'x linear, y ease-in cubic-bezier(0.42, 0, 1, 1), scale 1 → Icon, 600 ms · Pop 420 ms · Zähler 250 ms cubic-bezier(0, 0, 0.2, 1)',
    pfad: 'offset-path: path(M start Q kontrollpunkt ziel), Kontrollpunkt 160 px über der Mitte, offset-distance 0 → 100 % ease-in, 600 ms',
    vt: 'document.startViewTransition, view-transition-name: flug, 600 ms ease-in, gerade Linie; ohne Unterstützung Bogen'
  };

  // Zähler rollt: alte Zahl nach oben raus, neue von unten rein
  function zaehler(neu) {
    badge.classList.toggle('leer', neu === 0);
    korb.setAttribute('aria-label', neu ? `Warenkorb, ${neu} Artikel` : 'Warenkorb, leer');
    const alt = badge.querySelector('.roll');
    if (reduziert() || !alt) { badge.innerHTML = `<span class="roll">${neu}</span>`; return; }
    const n = document.createElement('span'); n.className = 'roll'; n.textContent = neu;
    badge.appendChild(n);
    const k = { duration: 250, easing: 'cubic-bezier(0, 0, 0.2, 1)', fill: 'forwards' };
    alt.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-100%)' }], k);
    n.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-100%)' }], k).finished.then(() => { badge.innerHTML = `<span class="roll">${neu}</span>`; });
  }
  function pop() {
    korb.classList.remove('pop'); void korb.offsetWidth; korb.classList.add('pop');
    badge.classList.add('blitz'); setTimeout(() => badge.classList.remove('blitz'), 700);
  }
  function miniZeigen(p) {
    $('[data-mini-bild]').innerHTML = BILDER[p.id].replace(/id="(g\d|kork)"/g, 'id="$1m"').replace(/url\(#(g\d|kork)\)/g, 'url(#$1m)');
    $('[data-mini-name]').textContent = p.name;
    $('[data-mini-menge]').textContent = '1 × ' + p.menge;
    $('[data-mini-preis]').textContent = euro(p.preis);
    $('[data-mini-anzahl]').textContent = anzahl;
    $('[data-mini-summe]').textContent = euro(summe);
    const rest = SCHWELLE - summe;
    $('[data-mini-versand]').textContent = rest > 0 ? `Noch ${euro(rest)} bis zum kostenlosen Versand` : 'Der Versand ist kostenlos.';
    mini.classList.add('offen');
    requestAnimationFrame(() => { $('[data-mini-balken]').style.transform = `scaleX(${Math.min(1, summe / SCHWELLE)})`; });
    clearTimeout(miniUhr); miniUhr = setTimeout(() => mini.classList.remove('offen'), 2600);
  }
  mini.addEventListener('pointerenter', () => clearTimeout(miniUhr));
  mini.addEventListener('pointerleave', () => { miniUhr = setTimeout(() => mini.classList.remove('offen'), 800); });

  const sichtbar = el => { const r = el.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; };

  // Klon des Produktbilds an der Startposition
  function klon(p, r) {
    const f = document.createElement('div'); f.className = 'flug'; f.setAttribute('aria-hidden', 'true');
    f.innerHTML = `<div class="achse-y"><div class="klon" style="width:${r.width}px;height:${r.height}px">${BILDER[p.id].replace(/id="(g\d|kork)"/g, 'id="$1f"').replace(/url\(#(g\d|kork)\)/g, 'url(#$1f)')}</div></div>`;
    f.style.transform = `translate(${r.left}px, ${r.top}px)`;
    document.body.appendChild(f);
    return f;
  }
  function fliegen(p, quelle) {
    const r = quelle.getBoundingClientRect(), z = korb.getBoundingClientRect();
    const dx = z.left + z.width / 2 - (r.left + r.width / 2), dy = z.top + z.height / 2 - (r.top + r.height / 2);
    const s = SKALA_ZIEL_PX / r.width;
    const f = klon(p, r), y = f.firstElementChild, k = y.firstElementChild;
    if (variante === 'vt' && document.startViewTransition) {
      k.style.viewTransitionName = 'flug';
      html.classList.add('vt-flug');
      const vt = document.startViewTransition(() => {
        const w = r.width * s, h = r.height * s;
        f.style.transform = `translate(${z.left + z.width / 2 - w / 2}px, ${z.top + z.height / 2 - h / 2}px)`;
        k.style.width = w + 'px'; k.style.height = h + 'px'; k.style.borderRadius = '4px';
      });
      return vt.finished.then(() => { f.remove(); html.classList.remove('vt-flug'); });
    }
    if (variante === 'pfad' && CSS.supports('offset-path', 'path("M0 0 L1 1")')) {
      const sx = r.left + r.width / 2, sy = r.top + r.height / 2, ex = sx + dx, ey = sy + dy;
      const cx = (sx + ex) / 2, cy = Math.min(sy, ey) - 160;
      f.style.transform = ''; f.style.left = 0; f.style.top = 0;
      Object.assign(y.style, { offsetPath: `path("M${sx} ${sy} Q${cx} ${cy} ${ex} ${ey}")`, offsetRotate: '0deg', offsetAnchor: 'center', position: 'absolute', left: 0, top: 0 });
      return y.animate([{ offsetDistance: '0%', transform: 'scale(1)' }, { offsetDistance: '100%', transform: `scale(${s})` }],
        { duration: DAUER, easing: 'cubic-bezier(0.42, 0, 1, 1)', fill: 'forwards' }).finished.then(() => f.remove());
    }
    // Bogen: zwei getrennte Achsen. Außen x linear, innen y ease-in plus Schrumpfen.
    const ax = f.animate([{ transform: `translate(${r.left}px, ${r.top}px)` }, { transform: `translate(${r.left + dx}px, ${r.top}px)` }], { duration: DAUER, easing: 'linear', fill: 'forwards' });
    y.animate([{ transform: 'translateY(0) scale(1)' }, { transform: `translateY(${dy}px) scale(${s})` }], { duration: DAUER, easing: 'cubic-bezier(0.42, 0, 1, 1)', fill: 'forwards' });
    return ax.finished.then(() => f.remove());
  }

  const laufend = new Set();
  function hinzufuegen(id, knopf) {
    if (laufend.has(knopf)) return Promise.resolve();
    laufend.add(knopf);
    const p = PRODUKTE.find(x => x.id === id);
    const quelle = document.querySelector(`[data-bild="${id}"]`);
    knopf.classList.add('erledigt');
    anzahl += 1; summe = Math.round((summe + p.preis) * 100) / 100;
    status.textContent = `${p.name} wurde in den Warenkorb gelegt. ${anzahl} Artikel, ${euro(summe)}.`;
    // Flug nur, wenn der Warenkorb sichtbar ist (sonst Flug ins Nichts)
    const flug = !reduziert() && sichtbar(korb) && sichtbar(quelle) ? fliegen(p, quelle) : Promise.resolve();
    return flug.then(() => {
      if (!reduziert()) pop();
      zaehler(anzahl);
      miniZeigen(p);
      setTimeout(() => { knopf.classList.remove('erledigt'); laufend.delete(knopf); }, 1600);
    });
  }
  raster.addEventListener('click', e => {
    const k = e.target.closest('[data-kaufen]');
    if (!k) return;
    if (e.isTrusted) stoppVorf();
    hinzufuegen(k.dataset.kaufen, k);
  });

  // Varianten
  const wahl = [...document.querySelectorAll('[data-wahl]')];
  function setzeVariante(v) {
    variante = v; body.dataset.variante = v;
    wahl.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.wahl === v)));
    $('[data-werte]').textContent = reduziert() ? 'Reduzierte Bewegung: kein Flug, kein Pop. Zähler und Mini-Warenkorb wechseln ohne Weg.' : WERTE[v];
  }
  wahl.forEach(b => b.addEventListener('click', () => { stoppVorf(); setzeVariante(b.dataset.wahl); }));
  setzeVariante('bogen');
  addEventListener('load', () => setzeVariante(variante));

  // Vorführung: Produkte nacheinander in den Warenkorb, Variante wechselt reihum
  let uhr = null, vorf = false, schritt = 0;
  const FOLGE = ['bogen', 'pfad', 'bogen', 'vt'], ABSTAND = 2800;
  const zeiger = $('[data-zeiger]');
  function takt() {
    const knoepfe = [...document.querySelectorAll('[data-kaufen]')];
    const i = schritt % knoepfe.length, k = knoepfe[i];
    if (i === 0 && schritt > 0) setzeVariante(FOLGE[(schritt / knoepfe.length) % FOLGE.length]);
    if (anzahl >= 8) { anzahl = 0; summe = 0; zaehler(0); }
    const r = k.getBoundingClientRect();
    zeiger.style.display = reduziert() ? 'none' : 'block';
    zeiger.style.transform = `translate(${r.left + r.width * .62}px, ${r.top + r.height * .55}px)`;
    k.classList.add('druck'); setTimeout(() => k.classList.remove('druck'), 150);
    hinzufuegen(k.dataset.kaufen, k);
    schritt++;
    uhr = setTimeout(takt, ABSTAND);
  }
  function vorfuehren() {
    pause(); vorf = true; html.classList.add('vorfuehrung');
    uhr = setTimeout(takt, 2200);
  }
  function pause() { clearTimeout(uhr); uhr = null; }
  function weiter() { if (vorf && !uhr) uhr = setTimeout(takt, 400); }
  function stoppVorf() { if (!vorf) return; pause(); vorf = false; html.classList.remove('vorfuehrung'); zeiger.style.display = 'none'; }
  function neustart() {
    stoppVorf(); schritt = 0; anzahl = 0; summe = 0; zaehler(0); mini.classList.remove('offen');
    document.querySelectorAll('.flug').forEach(f => f.remove());
    setzeVariante('bogen');
  }

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