Zum Inhalt
← Mikroanimationen

Like/Herz mit Partikel-Burst

Merk-Herz auf Objektkarten: Das Herz springt aus null mit Überschwinger, kleine Partikel und ein Ring zerstreuen sich, der Zähler der Merkliste rollt weiter.

Beim Aktivieren schrumpft das Herz auf 0 und springt mit Überschwinger über 1,2 bis 1,25 zurück auf 1. Dabei füllt es sich mit Farbe. Gleichzeitig fliegen 6 bis 12 kleine Partikel radial aus und verblassen, oft dehnt sich dazu ein Ring aus. Deaktivieren bleibt schlicht: Die Farbe geht, es gibt keinen Burst. Ein Zähler daneben rollt eine Ziffer weiter.

Der kurze Burst belohnt die Handlung. Die Bestätigung fühlt sich gut an und lädt dazu ein, wieder zu interagieren.

Wann einsetzen

Favoriten und Merklisten, Applaus oder Feedback unter Artikeln, Reaktionen.

Wann nicht

In sehr nüchternen Kontexten wie Banking und bei Massenaktionen, die man oft hintereinander ausführt. In seriösen Branchen nur dezent: Markenfarben, kurze Reichweite.

Varianten

  • Burst, Ring oder Wirbel als animiertes Icon (Prinzip der mo.js-Animocons)
  • Element zerfällt in Partikel und setzt sich wieder zusammen (Codrops ParticleEffectsButtons)
  • Konfetti beim Erfolg, sparsam (eigener Eintrag konfetti-erfolg)
  • Squash and Stretch des Icons (siehe icon-squash-stretch)

Umsetzung

Ohne Bibliothek: 8 bis 12 Partikel als Spans mit Winkel, per CSS-Keyframes oder Web Animations API von innen nach außen bewegen, dabei verkleinern und ausblenden (ease-out, 400 bis 600 ms, Reichweite 20 bis 40 px). Herz-Pop als Keyframes scale 0 → 1,25 → 1 in 300 bis 450 ms, etwa mit cubic-bezier(.2, 0, 0, 1). Den Zustand trägt aria-pressed. Den Burst nur beim Aktivieren abspielen. Sitzt das Herz in einem runden Knopf, die Reichweite ab dem Knopfrand rechnen und die Effektebene hinter den Knopf legen.

Robust umsetzen

Telefon. Identisch. Touch ist der Hauptanwendungsfall, Trefferfläche mindestens 44 px.

Ohne Bewegung. Nur die Farbfüllung wechseln, kein Pop und kein Burst.

Barrierefrei. Ein <button> mit aria-pressed="true|false" und eindeutigem Namen, etwa „Objekt merken“. Zählerstand sparsam über eine aria-live-Region ansagen. Partikel sind aria-hidden.

Code

<!DOCTYPE html>
<!--
  Like/Herz mit Partikel-Burst
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/like-herz-burst/
  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>Like/Herz mit Partikel-Burst · 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: 'Libre Caslon Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/libre-caslon-display@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; 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 {
  /* Rezept immobilien-diskret-hochwertig */
  --grund: #fcfaf5; --sand: #efe8df; --marke: #02385c; --text: #313131;
  --meta: #6f6b65; --linie: #dcd6cd; --cta: #ed7f1a;
  /* Partikel: auf hellen und dunklen Fotos sichtbar */
  --creme: #fcfaf5; --gold: #b8913e; --ton: #c0785a;
  --ui: cubic-bezier(0.165, 0.84, 0.44, 1);   /* easeOutQuart, UI 200/400 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Hanken Grotesk', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.versal { font-size: .75rem; font-weight: 500; letter-spacing: .09em; text-transform: uppercase; color: var(--meta); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Kopf */
.kopf { display: flex; align-items: center; gap: 32px; height: 72px; padding: 0 clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; flex-direction: column; gap: 3px; text-decoration: none; color: var(--marke); margin-right: auto; }
.marke b { font-size: 13px; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; white-space: nowrap; }
.marke span { font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--meta); }
.nav { display: flex; gap: 28px; list-style: none; padding: 0; }
.nav a { color: var(--text); text-decoration: none; font-size: 14px; }
.merkliste { display: flex; align-items: center; gap: 9px; font-size: 14px; color: var(--marke); text-decoration: none; padding: 8px 0; }
.merkliste svg { width: 18px; height: 18px; overflow: visible; }
.merkliste .umriss { fill: none; stroke: var(--marke); stroke-width: 1.6; }
.merkliste .fuell { fill: var(--marke); opacity: 0; transition: opacity .2s var(--ui); }
.merkliste.voll .fuell { opacity: 1; }
.zaehler { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; height: 22px; padding: 0 7px; border-radius: 11px; background: var(--sand); font-size: 12.5px; font-weight: 600; font-variant-numeric: tabular-nums; }
.ziffer { display: block; height: 1.2em; line-height: 1.2em; overflow: hidden; }
.ziffer span { display: block; transition: translate .4s var(--ui); }
.cta { display: inline-flex; align-items: center; height: 40px; padding: 0 18px; border-radius: 2px; background: var(--cta); color: #24180e; font-size: 14px; font-weight: 600; text-decoration: none; white-space: nowrap; }

/* Bühne */
.buehne { padding: clamp(28px, 5vw, 64px) clamp(16px, 4vw, 48px) clamp(28px, 4vw, 48px); max-width: 1360px; margin: 0 auto; }
.titelzeile { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); gap: 24px 48px; align-items: end; margin-bottom: clamp(24px, 3.4vw, 44px); }
h1 { font: 400 clamp(2.25rem, 1.4rem + 3.2vw, 4.5rem)/1.08 'Libre Caslon Display', Georgia, serif; letter-spacing: -.005em; color: var(--marke); margin-top: 14px; text-wrap: balance; }
.intro { font-size: 1.0625rem; line-height: 1.55; max-width: 34ch; text-wrap: pretty; }

.raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(16px, 2.2vw, 32px); list-style: none; padding: 0; }
.objekt { position: relative; }
.objekt a.flaeche { display: block; color: inherit; text-decoration: none; }
.foto { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--sand); border-radius: 2px; }
.foto svg { display: block; width: 100%; height: 100%; }
.marker { position: absolute; left: 14px; top: 14px; padding: 5px 9px 4px; background: var(--grund); color: var(--marke); }
.daten { padding-top: 14px; }
.daten h2 { font-size: 1.1875rem; font-weight: 500; letter-spacing: -.005em; color: var(--marke); margin: 6px 0 8px; }
.fakten { display: flex; flex-wrap: wrap; gap: 4px 0; font-size: .9375rem; font-variant-numeric: tabular-nums; color: var(--text); }
.fakten span + span::before { content: ''; display: inline-block; width: 1px; height: .8em; margin: 0 12px; background: var(--linie); vertical-align: -.05em; }

/* Das Herz: Effektebene hinter dem Knopf, damit Partikel unter dem Rand hervorkommen */
.merk { position: absolute; right: 12px; top: 12px; width: 48px; height: 48px; z-index: 2; }
.fx { position: absolute; left: 50%; top: 50%; width: 0; height: 0; pointer-events: none; z-index: 0; }
.fx i { position: absolute; display: block; border-radius: 50%; opacity: 0; will-change: transform, opacity; }
.fx i.strich { border-radius: 1px; }
.fx .ring { left: -24px; top: -24px; width: 48px; height: 48px; border: 2px solid var(--creme); background: none; box-shadow: 0 0 0 1px rgba(2, 56, 92, .22), inset 0 0 0 1px rgba(2, 56, 92, .22); }
.herz {
  position: relative; z-index: 1; display: grid; place-items: center; width: 48px; height: 48px; padding: 0;
  border: 0; border-radius: 50%; background: rgba(252, 250, 245, .94); cursor: pointer;
  box-shadow: 0 1px 2px rgba(2, 30, 50, .12), 0 6px 18px -8px rgba(2, 30, 50, .35);
  transition: scale .2s var(--ui); -webkit-tap-highlight-color: transparent;
}
.herz svg { width: 22px; height: 22px; overflow: visible; }
.herz .umriss { fill: none; stroke: var(--marke); stroke-width: 1.6; stroke-linejoin: round; }
.herz .fuell { fill: var(--marke); opacity: 0; transition: opacity .2s var(--ui); }
.herz[aria-pressed='true'] .fuell { opacity: 1; }
.herz:active, .herz.gedrueckt { scale: .92; transition-duration: .15s; }
.herz:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
@media (hover: hover) { .herz:hover { scale: 1.06; } .herz:hover:active { scale: .92; } }

/* Demo-Bedienung */
.steuerung { display: flex; flex-direction: column; align-items: flex-start; gap: 18px; }
.modi { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.modi .mono { margin-right: 6px; }
.modi button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background .15s, border-color .15s; }
.modi button:hover { color: var(--marke); border-color: #b5b2af; }
.modi button[aria-pressed='true'] { background: var(--marke); color: var(--grund); border-color: var(--marke); }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: clamp(20px, 2.6vw, 32px); padding-top: 14px; border-top: 1px solid var(--linie); }
.werte b { color: var(--marke); font-weight: 500; }
.fuss { margin-top: 12px; font-size: .8125rem; color: var(--meta); }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .9s var(--ui), scale .15s var(--ui); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueck { scale: .82; }

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .fuss, .vorschau .nav li:nth-child(n+3) { display: none; }

/* Reduzierte Bewegung: nur Farbfüllung, kein Pop, kein Burst, Zähler ohne Rollen */
.reduziert .herz, .reduziert .ziffer span, .reduziert .zeiger { transition: none; }
.reduziert .herz .fuell, .reduziert .merkliste .fuell { transition: opacity .15s linear; }
.reduziert .fx { display: none; }

@media (max-width: 1020px) { .nav { display: none; } }
@media (max-width: 760px) {
  .cta { display: none; }
  .titelzeile { grid-template-columns: 1fr; }
  .raster { grid-template-columns: 1fr; gap: 36px; }
}
@media (hover: none) { .zeiger { display: none !important; } }
/* 320 px (WCAG 1.4.10): Kopf enger */
@media (max-width: 360px) { .kopf { gap: 14px; } .marke b { letter-spacing: .12em; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#start"><b>Seeli &amp; Brunnmatt</b><span>Immobilien · Luzern</span></a>
  <ul class="nav">
    <li><a href="#kaufen">Kaufen</a></li><li><a href="#mieten">Mieten</a></li>
    <li><a href="#verkaufen">Verkaufen</a></li><li><a href="#magazin">Magazin</a></li>
  </ul>
  <a class="merkliste" href="#merkliste" data-merkliste>
    <svg viewBox="0 0 24 24" aria-hidden="true"><path class="fuell" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/><path class="umriss" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/></svg>
    Merkliste <span class="zaehler"><span class="ziffer" aria-hidden="true"><span data-ziffern>0<br>1<br>2<br>3<br>4<br>5<br>6<br>7<br>8<br>9</span></span><span class="sr" data-zahl>0</span></span>
  </a>
  <a class="cta" href="#bewertung">Bewertung anfragen</a>
</header>

<main class="buehne" id="start">
  <div class="titelzeile">
    <div>
      <p class="versal">Neu im Angebot · 3 Objekte</p>
      <h1>Wohnen zwischen Reuss, See und Pilatus.</h1>
    </div>
    <div class="steuerung">
      <p class="intro">Merken Sie sich Objekte für die nächste Besichtigung vor. Wir senden Ihnen das vollständige Exposé, sobald Sie bereit sind.</p>
      <div class="modi" role="group" aria-label="Variante">
        <span class="mono">Variante</span>
        <button type="button" data-variante="punkte" aria-pressed="true">Punkte</button>
        <button type="button" data-variante="funken" aria-pressed="false">Funken</button>
        <button type="button" data-variante="wirbel" aria-pressed="false">Wirbel</button>
      </div>
    </div>
  </div>

  <ul class="raster">
    <li class="objekt">
      <a class="flaeche" href="#meggen">
        <div class="foto">
          <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Flach gedeckte Villa am Seeufer in der Abenddämmerung">
            <defs>
              <linearGradient id="m-himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#aebac4"/><stop offset=".55" stop-color="#e2d5c4"/><stop offset="1" stop-color="#f0caa0"/></linearGradient>
              <linearGradient id="m-see" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a7a9a6"/><stop offset=".25" stop-color="#7f8c92"/><stop offset="1" stop-color="#3f4f5a"/></linearGradient>
            </defs>
            <rect width="400" height="192" fill="url(#m-himmel)"/>
            <path d="M0 170 38 152l24 5 34-36 15 8 17-24 12 11 12-6 24 27 30 8 34 14 50-2 40 8 70-6v34H0Z" fill="#a6aeb5"/>
            <path d="M0 184c60-12 120-6 170-2 60 4 130-10 230-4v16H0Z" fill="#75827f"/>
            <rect y="192" width="400" height="108" fill="url(#m-see)"/>
            <g stroke="#f3d7b0" stroke-linecap="round" opacity=".55"><path d="M70 214h46M88 226h28M60 238h40M96 252h18"/></g>
            <path d="M170 207 400 189v30l-236-5Z" fill="#37443c"/>
            <g fill="#2f3b33"><ellipse cx="190" cy="196" rx="16" ry="20"/><ellipse cx="206" cy="190" rx="12" ry="24"/><ellipse cx="384" cy="170" rx="18" ry="30"/><ellipse cx="368" cy="182" rx="12" ry="18"/></g>
            <g>
              <rect x="218" y="160" width="132" height="38" fill="#e9e2d6"/>
              <rect x="226" y="168" width="116" height="24" fill="#29343a"/>
              <rect x="232" y="171" width="24" height="18" fill="#f2c88c"/><rect x="292" y="171" width="44" height="18" fill="#f5d39f"/>
              <rect x="242" y="134" width="158" height="5" fill="#d6cfc2"/>
              <rect x="246" y="139" width="150" height="22" fill="#f4efe6"/>
              <rect x="253" y="144" width="136" height="12" fill="#34424a"/>
              <rect x="302" y="145" width="38" height="10" fill="#f6d6a4"/>
              <rect x="350" y="161" width="46" height="4" fill="#000" opacity=".12"/>
              <rect x="224" y="206" width="78" height="3" fill="#5f5040"/>
              <g fill="#4d4034"><rect x="234" y="209" width="2" height="7"/><rect x="262" y="209" width="2" height="7"/><rect x="290" y="209" width="2" height="7"/></g>
            </g>
            <g opacity=".1" transform="translate(0 414) scale(1 -1)">
              <rect x="218" y="160" width="132" height="38" fill="#e9e2d6"/><rect x="292" y="171" width="44" height="18" fill="#f5d39f"/>
              <rect x="246" y="139" width="150" height="22" fill="#f4efe6"/>
            </g>
            <g stroke="#cfd6d8" stroke-linecap="round" opacity=".35"><path d="M232 232h60M256 244h48M300 238h36"/></g>
          </svg>
        </div>
        <div class="daten">
          <p class="versal">Meggen · Seeufer</p>
          <h2>Seevilla mit Bootssteg</h2>
          <p class="fakten"><span>7½ Zi.</span><span>312 m²</span><span>Preis auf Anfrage</span></p>
        </div>
      </a>
      <div class="merk"><span class="fx" aria-hidden="true"></span><button class="herz" type="button" aria-pressed="false" aria-label="Seevilla mit Bootssteg merken"><svg viewBox="0 0 24 24" aria-hidden="true"><path class="fuell" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/><path class="umriss" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/></svg></button></div>
    </li>

    <li class="objekt">
      <a class="flaeche" href="#altstadt">
        <div class="foto">
          <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Altbaufassade mit Erker über dem Fluss">
            <defs>
              <linearGradient id="a-himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cdd8dc"/><stop offset="1" stop-color="#efe9df"/></linearGradient>
              <linearGradient id="a-fluss" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7d9496"/><stop offset="1" stop-color="#40585c"/></linearGradient>
              <linearGradient id="a-licht" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff6e6" stop-opacity=".35"/><stop offset=".6" stop-color="#fff6e6" stop-opacity="0"/></linearGradient>
              <g id="a-fenster"><rect x="-1" y="-6" width="28" height="4" fill="#cdb894"/><rect width="26" height="40" fill="#33414a" stroke="#f4ede2" stroke-width="2.4"/><path d="M13 0v40M0 17h26" stroke="#f4ede2" stroke-width="1.6"/></g>
            </defs>
            <rect width="400" height="300" fill="url(#a-himmel)"/>
            <rect x="-10" y="44" width="56" height="200" fill="#cbbca6"/>
            <rect x="354" y="30" width="60" height="214" fill="#d6c3b8"/>
            <path d="M40 34 60 6h280l20 28Z" fill="#5a6167"/>
            <g fill="#4a5056"><rect x="104" y="12" width="22" height="18"/><rect x="190" y="12" width="22" height="18"/><rect x="276" y="12" width="22" height="18"/></g>
            <rect x="36" y="32" width="328" height="8" fill="#c7b391"/>
            <rect x="42" y="40" width="316" height="198" fill="#e3d1b4"/>
            <g>
              <use href="#a-fenster" x="62" y="60"/><use href="#a-fenster" x="112" y="60"/><use href="#a-fenster" x="262" y="60"/><use href="#a-fenster" x="312" y="60"/>
              <use href="#a-fenster" x="62" y="118"/><use href="#a-fenster" x="112" y="118"/><use href="#a-fenster" x="262" y="118"/><use href="#a-fenster" x="312" y="118"/>
            </g>
            <rect x="160" y="52" width="80" height="128" fill="#ead9bd"/>
            <rect x="160" y="176" width="80" height="8" fill="#c7b391"/>
            <rect x="240" y="56" width="6" height="128" fill="#000" opacity=".08"/>
            <use href="#a-fenster" x="168" y="66"/><use href="#a-fenster" x="206" y="66"/>
            <use href="#a-fenster" x="168" y="124"/><use href="#a-fenster" x="206" y="124"/>
            <g stroke="#3b3f42" stroke-width="1.4"><path d="M58 164h84M258 164h84"/><path d="M62 164v-6M80 164v-6M98 164v-6M116 164v-6M134 164v-6M262 164v-6M280 164v-6M298 164v-6M316 164v-6M334 164v-6"/></g>
            <rect x="42" y="192" width="316" height="3" fill="#cdb894"/>
            <g fill="#2c363b"><path d="M64 238v-26a16 16 0 0 1 32 0v26Z"/><path d="M124 238v-26a16 16 0 0 1 32 0v26Z"/><path d="M184 238v-26a16 16 0 0 1 32 0v26Z"/><path d="M244 238v-26a16 16 0 0 1 32 0v26Z"/><path d="M304 238v-26a16 16 0 0 1 32 0v26Z"/></g>
            <rect y="236" width="400" height="7" fill="#9a8e7c"/>
            <rect y="243" width="400" height="57" fill="url(#a-fluss)"/>
            <rect x="42" y="243" width="316" height="34" fill="#e3d1b4" opacity=".18"/>
            <g stroke="#d9e2e1" stroke-linecap="round" opacity=".4"><path d="M60 256h70M170 262h56M260 254h80M110 274h44M300 280h40"/></g>
            <rect width="400" height="300" fill="url(#a-licht)"/>
          </svg>
        </div>
        <div class="daten">
          <p class="versal">Luzern · Altstadt</p>
          <h2>Altbauwohnung an der Reuss</h2>
          <p class="fakten"><span>4½ Zi.</span><span>138 m²</span><span>CHF 2’380’000</span></p>
        </div>
      </a>
      <div class="merk"><span class="fx" aria-hidden="true"></span><button class="herz" type="button" aria-pressed="false" aria-label="Altbauwohnung an der Reuss merken"><svg viewBox="0 0 24 24" aria-hidden="true"><path class="fuell" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/><path class="umriss" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/></svg></button></div>
    </li>

    <li class="objekt">
      <a class="flaeche" href="#horw">
        <div class="foto">
          <svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Wohnraum einer Attikawohnung mit Fensterfront zum See und Berg">
            <defs>
              <linearGradient id="h-aussen" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c3d1d9"/><stop offset="1" stop-color="#ebe5d9"/></linearGradient>
              <linearGradient id="h-boden" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c7a982"/><stop offset="1" stop-color="#b08f68"/></linearGradient>
            </defs>
            <rect width="400" height="300" fill="#ebe3d7"/>
            <rect width="400" height="26" fill="#f3eee6"/>
            <path d="M0 0h96v232L0 300Z" fill="#ddd3c5"/>
            <rect x="120" y="44" width="262" height="174" fill="url(#h-aussen)"/>
            <path d="M120 170 166 142l18 8 34-46 14 12 16-20 22 30 28 14 30-6 54 24v32H120Z" fill="#8e99a1"/>
            <path d="M120 176c60-6 130-4 262 0v14H120Z" fill="#6f7f78"/>
            <rect x="120" y="188" width="262" height="30" fill="#7d939d"/>
            <g stroke="#e6ecec" stroke-linecap="round" opacity=".45"><path d="M150 198h40M230 204h60M300 196h40"/></g>
            <g fill="#2b2e30"><rect x="116" y="40" width="270" height="5"/><rect x="116" y="216" width="270" height="5"/><rect x="116" y="40" width="5" height="181"/><rect x="381" y="40" width="5" height="181"/><rect x="204" y="44" width="3" height="174"/><rect x="292" y="44" width="3" height="174"/></g>
            <path d="M96 232 116 221h270l14 6v73H0Z" fill="url(#h-boden)"/>
            <g stroke="#a88a64" stroke-width="1" opacity=".7"><path d="M70 300 150 221M150 300 200 221M240 300 252 221M330 300 304 221M400 286 356 221"/></g>
            <path d="M150 232h150l40 46H120Z" fill="#ead2ad" opacity=".55"/>
            <g>
              <path d="M178 250c0-14 8-20 22-20h22c10 0 14 6 14 14v8h-58Z" fill="#a8704f"/>
              <path d="M172 252h72v12h-72Z" fill="#94603f"/>
              <path d="M176 264l-6 18M240 264l6 18" stroke="#2b2e30" stroke-width="2.4" stroke-linecap="round"/>
              <circle cx="276" cy="262" r="13" fill="#2b2e30"/><rect x="274.5" y="262" width="3" height="22" fill="#2b2e30"/><ellipse cx="276" cy="285" rx="10" ry="2.5" fill="#2b2e30"/>
            </g>
            <path d="M64 0v78" stroke="#2b2e30" stroke-width="1.2"/>
            <path d="M46 92a18 14 0 0 1 36 0Z" fill="#2b2e30"/>
            <ellipse cx="64" cy="96" rx="22" ry="5" fill="#fff3d6" opacity=".7"/>
          </svg>
          <span class="marker versal">Neu</span>
        </div>
        <div class="daten">
          <p class="versal">Horw · Winkel</p>
          <h2>Attika mit Pilatusblick</h2>
          <p class="fakten"><span>5½ Zi.</span><span>186 m²</span><span>CHF 3’140’000</span></p>
        </div>
      </a>
      <div class="merk"><span class="fx" aria-hidden="true"></span><button class="herz" type="button" aria-pressed="false" aria-label="Attika mit Pilatusblick merken"><svg viewBox="0 0 24 24" aria-hidden="true"><path class="fuell" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/><path class="umriss" d="M12 20.4s-7.6-4.6-9.4-9.3C1.3 7.8 3.4 4.4 6.9 4.4c2.1 0 3.6 1.2 5.1 3 1.5-1.8 3-3 5.1-3 3.5 0 5.6 3.4 4.3 6.7-1.8 4.7-9.4 9.3-9.4 9.3Z"/></svg></button></div>
    </li>
  </ul>

  <p class="werte mono" data-werte></p>
  <p class="fuss">Alle Preise in Schweizer Franken. Für Käuferinnen und Käufer fällt keine Provision an.</p>
  <p class="sr" aria-live="polite" data-ansage></p>
</main>

<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.5 17 10l-6.6 1.4-3.2 6.1Z" fill="#02385c" stroke="#fcfaf5" stroke-width="1.3" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');

  /* Varianten: Werte aus den Messwerten (Every, nerdy.dev, Codrops ParticleEffectsButtons) */
  const VARIANTEN = {
    punkte: {
      pop:  { dauer: 350, kurve: 'cubic-bezier(0.2, 0, 0, 1)', spitze: 1.25, bei: .4 },
      ring: { dauer: 400, kurve: 'cubic-bezier(0, 0, 0.58, 1)', von: 1, bis: 1.6 },
      burst: { art: 'punkte', richtungen: 8, groesse: [5, 3], start: 20, reichweite: 28, dauer: 500, verzoegerung: 60, kurve: 'cubic-bezier(0, 0, 0.58, 1)' },
      text: ['Herz-Pop <b>350 ms</b> cubic-bezier(0.2, 0, 0, 1), Skala <b>0 → 1,25 → 1</b>', 'Ring <b>400 ms</b> 1 → 1,6', 'Burst <b>8 × 2 Punkte</b> 5/3 px, <b>500 ms</b> ease-out', 'Reichweite <b>28 px</b> ab Knopfrand']
    },
    funken: {
      pop:  { dauer: 400, kurve: 'cubic-bezier(0.2, 0, 0, 1)', spitze: 1.2, bei: .6 },
      ring: { dauer: 450, kurve: 'cubic-bezier(0, 0, 0.58, 1)', von: 1, bis: 1.45 },
      burst: { art: 'funken', richtungen: 12, laenge: 10, start: 22, reichweite: 22, dauer: 450, verzoegerung: 40, kurve: 'cubic-bezier(0, 0, 0.58, 1)' },
      text: ['Herz-Pop <b>400 ms</b> cubic-bezier(0.2, 0, 0, 1), Skala <b>0 → 1,2 → 1</b>', 'Ring <b>450 ms</b> 1 → 1,45', 'Burst <b>12 Funken</b> 10 × 2 px, <b>450 ms</b> ease-out', 'Reichweite <b>22 px</b> ab Knopfrand']
    },
    wirbel: {
      pop:  { dauer: 450, kurve: 'cubic-bezier(0.2, 0, 0, 1)', spitze: 1.25, bei: .4 },
      ring: null,
      burst: { art: 'wirbel', richtungen: 10, groesse: [5, 3], start: 18, reichweite: 36, drehung: 70, dauer: 1000, verzoegerung: 0, kurve: 'cubic-bezier(0.65, 0, 0.35, 1)' },
      text: ['Herz-Pop <b>450 ms</b> cubic-bezier(0.2, 0, 0, 1), Skala <b>0 → 1,25 → 1</b>', 'Wirbel <b>10 Punkte</b> 5/3 px, <b>1000 ms</b> easeInOutCubic', 'Drehung <b>70°</b>', 'Reichweite <b>36 px</b> ab Knopfrand']
    }
  };
  const FARBEN = ['var(--marke)', 'var(--gold)', 'var(--ton)'];
  let variante = 'punkte';

  const herzen = [...document.querySelectorAll('.herz')];
  const werte = document.querySelector('[data-werte]');
  const ziffern = document.querySelector('[data-ziffern]');
  const zahl = document.querySelector('[data-zahl]');
  const merkliste = document.querySelector('[data-merkliste]');
  const ansage = document.querySelector('[data-ansage]');
  const zeiger = document.querySelector('[data-zeiger]');

  function zeigeWerte() {
    werte.innerHTML = reduziert()
      ? '<span>Reduziert: <b>nur Farbfüllung</b>, 150 ms linear, kein Pop, kein Burst</span>'
      : VARIANTEN[variante].text.map(t => `<span>${t}</span>`).join('') + '<span>Zähler <b>400 ms</b> easeOutQuart</span>';
  }
  function setzeVariante(v) {
    variante = v;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    zeigeWerte();
  }

  /* Burst: Partikel entstehen pro Klick und werden nach dem Ende entfernt */
  function teilchen(fx, cls, w, h, farbe) {
    const el = document.createElement('i');
    if (cls) el.className = cls;
    el.style.cssText = `width:${w}px;height:${h}px;left:${-w / 2}px;top:${-h / 2}px;background:${farbe}`;
    fx.appendChild(el);
    return el;
  }
  function burst(knopf) {
    const V = VARIANTEN[variante], fx = knopf.previousElementSibling, b = V.burst;
    const rand = knopf.offsetWidth / 2;   // Reichweite zählt ab Knopfrand, nicht ab Mitte
    const icon = knopf.querySelector('svg');
    icon.animate([{ scale: 0 }, { scale: V.pop.spitze, offset: V.pop.bei }, { scale: 1 }], { duration: V.pop.dauer, easing: V.pop.kurve });
    const animationen = [];
    if (V.ring) {
      const r = document.createElement('i'); r.className = 'ring'; fx.appendChild(r);
      animationen.push(r.animate([
        { transform: `scale(${V.ring.von})`, opacity: .9, borderWidth: '2px' },
        { transform: `scale(${V.ring.bis})`, opacity: 0, borderWidth: '.5px' }
      ], { duration: V.ring.dauer, easing: V.ring.kurve, fill: 'both' }));
    }
    const opt = { duration: b.dauer, easing: b.kurve, delay: b.verzoegerung, fill: 'both' };
    for (let i = 0; i < b.richtungen; i++) {
      const a = -90 + i * 360 / b.richtungen + (b.art === 'punkte' ? 22.5 : 0);
      if (b.art === 'punkte') {
        // Hauptpunkt und kleiner Begleiter, leicht versetzt (Animocons-Prinzip)
        [[a, b.groesse[0], rand + b.reichweite, FARBEN[i % 2]], [a + 13, b.groesse[1], rand + b.reichweite * .7, FARBEN[2]]].forEach(([w, s, R, f]) => {
          const el = teilchen(fx, '', s, s, f);
          animationen.push(el.animate([
            { transform: `rotate(${w}deg) translateX(${b.start}px) scale(1)`, opacity: 0 },
            { transform: `rotate(${w}deg) translateX(${b.start + (R - b.start) * .35}px) scale(1)`, opacity: 1, offset: .15 },
            { transform: `rotate(${w}deg) translateX(${R}px) scale(.2)`, opacity: 0 }
          ], opt));
        });
      } else if (b.art === 'funken') {
        const el = teilchen(fx, 'strich', b.laenge, 2, FARBEN[i % 2]);
        animationen.push(el.animate([
          { transform: `rotate(${a}deg) translateX(${b.start}px) scaleX(1)`, opacity: 1 },
          { transform: `rotate(${a}deg) translateX(${rand + b.reichweite}px) scaleX(0)`, opacity: .6 }
        ], opt));
      } else {
        // Wirbel: Bahn als Spirale, Gesamtkurve easeInOutCubic über alle Stützpunkte
        const s = i % 2 ? b.groesse[1] : b.groesse[0], el = teilchen(fx, '', s, s, FARBEN[i % 3]), k = [];
        for (let n = 0; n <= 12; n++) {
          const t = n / 12, w = a + b.drehung * t, R = b.start + (rand + b.reichweite - b.start) * t;
          k.push({ transform: `rotate(${w}deg) translateX(${R}px) scale(${1 - .65 * t})`, opacity: t < .1 ? t * 10 : t > .6 ? (1 - t) / .4 : 1 });
        }
        animationen.push(el.animate(k, opt));
      }
    }
    Promise.all(animationen.map(x => x.finished.catch(() => {}))).then(() => { animationen.forEach(x => x.effect.target.remove()); });
  }

  /* Zähler in der Kopfzeile rollt um eine Ziffer */
  function zaehlen(an) {
    const n = herzen.filter(h => h.getAttribute('aria-pressed') === 'true').length;
    ziffern.style.translate = `0 ${-n * 1.2}em`;
    zahl.textContent = n;
    merkliste.classList.toggle('voll', n > 0);
    if (an && !reduziert()) merkliste.querySelector('svg').animate([{ scale: 1 }, { scale: 1.2, offset: .4 }, { scale: 1 }], { duration: 350, easing: 'cubic-bezier(0.2, 0, 0, 1)', delay: 180 });
    return n;
  }
  function umschalten(knopf) {
    const an = knopf.getAttribute('aria-pressed') !== 'true';
    knopf.setAttribute('aria-pressed', String(an));
    if (an && !reduziert()) burst(knopf);   // Deaktivieren bleibt schlicht
    const n = zaehlen(an);
    const titel = knopf.getAttribute('aria-label').replace(/ merken$/, '');
    ansage.textContent = `${titel} ${an ? 'zur Merkliste hinzugefügt' : 'von der Merkliste entfernt'}. ${n} ${n === 1 ? 'Objekt' : 'Objekte'} gemerkt.`;
  }
  herzen.forEach(h => h.addEventListener('click', () => umschalten(h)));
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.variante)));
  document.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', e => e.preventDefault()));
  new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });

  /* Vorführung: gedachter Zeiger merkt zwei Objekte, nimmt sie wieder heraus, nächste Variante */
  const REIHE = ['punkte', 'funken', 'wirbel'];
  let uhren = [], runde = 0, laeuft = false;
  const spaeter = (fn, ms) => uhren.push(setTimeout(fn, ms));
  function zeigerAuf(knopf, sofort) {
    const r = knopf.getBoundingClientRect();
    zeiger.style.transition = sofort ? 'none' : '';
    zeiger.style.transform = `translate(${r.left + r.width * .55}px, ${r.top + r.height * .58}px)`;
  }
  function drueck(knopf) {
    zeiger.classList.add('drueck'); knopf.classList.add('gedrueckt');
    setTimeout(() => { zeiger.classList.remove('drueck'); knopf.classList.remove('gedrueckt'); umschalten(knopf); }, 150);
  }
  function zuruecksetzen() {
    herzen.forEach(h => { h.setAttribute('aria-pressed', 'false'); h.previousElementSibling.replaceChildren(); });
    zaehlen(false);
  }
  function runde_spielen() {
    setzeVariante(REIHE[runde % 3]);
    const [a, b] = runde % 2 ? [herzen[2], herzen[0]] : [herzen[1], herzen[2]];
    let t = 0;
    spaeter(() => zeigerAuf(a), t);
    spaeter(() => drueck(a), t += 950);
    spaeter(() => zeigerAuf(b), t += 1500);
    spaeter(() => drueck(b), t += 950);
    spaeter(() => zeigerAuf(a), t += 1700);
    spaeter(() => drueck(a), t += 900);
    spaeter(() => zeigerAuf(b), t += 500);
    spaeter(() => drueck(b), t += 900);
    spaeter(() => { runde++; runde_spielen(); }, t += 900);
  }
  function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; }
  function vorfuehren() {
    pause(); zuruecksetzen();
    html.classList.add('vorfuehrung'); laeuft = true;
    const r = herzen[1].getBoundingClientRect();
    zeiger.style.transition = 'none';
    zeiger.style.transform = `translate(${r.left - 60}px, ${r.bottom + 120}px)`;
    spaeter(runde_spielen, 300);
  }
  function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde_spielen(); } }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung'); runde = 0;
    setzeVariante(variante); zuruecksetzen();
    spaeter(() => umschalten(herzen[0]), 400);
  }
  zeigeWerte();

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