Zum Inhalt
← Hover

Nachbarn reagieren auf Hover

Fährt der Zeiger über eine Kachel, treten die Nachbarn zurück, machen Platz oder reagieren nach Nähe.

Fährt der Zeiger über ein Element einer Gruppe, reagieren auch die anderen: Sie dimmen ab, werden unscharf, weichen aus oder machen Platz. Mit :has() geht das rein in CSS. Näherungsvarianten reagieren schon auf den Abstand zum Zeiger und verändern sich stufenlos.

Lenkt den Blick, ohne ein neues Element einzuführen. Das gezeigte Element tritt hervor, weil die anderen zurücktreten, und die ganze Gruppe atmet mit. Seit :has() ist das eine Zeile CSS und deshalb in Indexlisten und Menüs weit verbreitet.

Wann einsetzen

Projektlisten, Navigationen und Menü-Overlays, Logo-Wände, Kartenreihen, Kategorie-Kacheln im Shop.

Wann nicht

Wenn die Nachbarn Inhalte tragen, die beim Lesen sichtbar bleiben müssen, etwa Preise im direkten Vergleich. Zu starkes Dimmen drückt den Kontrast unter WCAG. Platz machen passt nicht zu Produktlisten, deren Kacheln einheitlich bleiben sollen.

Varianten

  • Dimmen: Geschwister per :has() auf Deckkraft 0.4
  • Unschärfe: Geschwister mit filter: blur(2px)
  • Platz machen: Das gezeigte Element wächst, die Nachbarn werden schmaler (flex-grow oder Spalten mit fr-Werten)
  • Wegschieben: Grid-Elemente weichen dem gezeigten Element aus
  • Nähe: Die Wirkung nimmt mit dem Abstand zum Zeiger ab

Umsetzung

Dimmen in CSS: .liste:has(> li:is(:hover, :focus-visible)) > li:not(:is(:hover, :focus-visible)) { opacity: .4 }. Die Hover-Regel gehört unter @media (hover: hover) and (pointer: fine). Platz machen: li { flex: 1 1 0; transition: flex-grow 400ms } und li:has(a:hover) { flex-grow: 2.4 }. Spalten mit fr-Werten lassen sich nicht über @property animieren, dafür JS oder Flip nehmen. Nähe: Bei pointermove für jedes Element den Abstand d zum Zeiger messen, p = clamp(1 − d / radius, 0, 1) als Custom Property --p setzen und in CSS mit calc() für opacity und scale nutzen.

Robust umsetzen

Telefon. Kein Hover. Erstes Tippen wählt ein Element aus und nutzt denselben Stil, zweites Tippen öffnet den Link. Auf dem Telefon wird die Reihe zur Spalte, flex-grow wirkt dann auf die Höhe.

Ohne Bewegung. Deckkraftwechsel bleiben, sie sind unkritisch. Breitenänderung, Heben und Skalieren entfallen, Platz machen wird zu Dimmen.

Barrierefrei. :focus-visible genauso behandeln wie :hover. Gedimmter Text muss lesbar bleiben, mindestens 3:1, für normale Schrift besser 4.5:1. Deshalb Bilder stärker dimmen als Text, in der Demo Bild 0.4 und Text 0.7.

Code

<!DOCTYPE html>
<!--
  Nachbarn reagieren auf Hover
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/nachbarn-reagieren-hover/
  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>Nachbarn reagieren auf Hover · 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: '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; }
@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: '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 „Produkt zuerst“: warme Neutraltöne, fast schwarze Tinte, ein Markenakzent */
  --grund: #f5f4f1; --kachel: #ece9e2; --tinte: #212121; --leise: #666666; --linie: rgba(33, 33, 33, .12);
  --gruen: #10714e; --akzent: #a8452f;   /* Backsteinrot statt Pink: Lübeck */
  --kurve: cubic-bezier(0, 0, 0.2, 1);   /* Hover-Kurve des Rezepts (Ausklang) */
  --dimm-an: 200ms; --dimm-aus: 150ms;   /* Nachbarn treten zurück / kommen wieder */
  --platz: 400ms;                         /* flex-grow beim Platzmachen */
  --bild-leise: .4;                       /* gemessen bei Collins */
  --text-leise: .7;                       /* Name bleibt bei 5,3:1, Preis bei 3:1 */
  --wachsen: 2.4;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: flex; flex-direction: column; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.huelle { width: 100%; padding-inline: clamp(16px, 3.4vw, 48px); }

/* Ankündigungsleiste und Kopf */
.leiste { background: var(--tinte); color: #f5f4f1; font-size: 12.5px; text-align: center; padding: 8px 16px; letter-spacing: .01em; }
.leiste span + span::before { content: '·'; margin: 0 .7em; opacity: .5; }
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding-block: 18px; border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; color: inherit; text-decoration: none; }
.marke b { font: 400 25px/1 'Young Serif', Georgia, serif; letter-spacing: -.01em; }
.marke small { font: 500 9.5px/1.25 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; gap: 26px; list-style: none; padding: 0; font-size: 15px; font-weight: 500; }
.nav a { color: inherit; text-decoration: none; }
.nav a:hover { text-decoration: underline; text-underline-offset: .3em; text-decoration-thickness: 1px; }
.icons { display: flex; align-items: center; gap: 16px; }
.icons svg { width: 21px; height: 21px; display: block; fill: none; stroke: currentColor; stroke-width: 1.6; }
.korb { position: relative; }
.korb i { position: absolute; right: -7px; top: -6px; min-width: 16px; height: 16px; border-radius: 8px; background: var(--tinte); color: var(--grund); font: 600 10px/16px 'Instrument Sans', sans-serif; font-style: normal; text-align: center; }
.icons .burger { display: none; }

/* Claim */
.claim { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 24px 48px; align-items: end; padding-block: clamp(26px, 4.4vh, 56px) clamp(22px, 3.6vh, 44px); }
h1 { font: 400 clamp(2.25rem, 1.4rem + 3.6vw, 4.5rem)/1.05 'Young Serif', Georgia, serif; letter-spacing: -.01em; max-width: 17ch; text-wrap: balance; }
h1 em { font-style: normal; color: var(--akzent); }
.claim p { font-size: 16px; line-height: 1.55; color: var(--leise); max-width: 40ch; }
.sterne { display: flex; align-items: center; gap: 8px; margin-top: 14px; font-size: 14px; color: var(--tinte); font-variant-numeric: tabular-nums; }
.sterne svg { width: 78px; height: 14px; fill: var(--tinte); }

.abschnitt { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding-bottom: 14px; }
h2 { font-size: clamp(1.25rem, 1rem + .9vw, 1.75rem); font-weight: 600; letter-spacing: -.015em; }
.abschnitt a { font-size: 14px; font-weight: 500; color: inherit; text-underline-offset: .3em; }

/* Die Reihe der Kategorie-Kacheln */
.reihe { display: flex; gap: 10px; height: clamp(270px, 44vh, 430px); list-style: none; padding: 0; }
.reihe > li { flex: 1 1 0; min-width: 0; display: flex; transition: flex-grow var(--platz) var(--kurve); }
.kachel {
  container-type: inline-size;
  position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden;
  background: var(--kachel); border-radius: 10px; color: inherit; text-decoration: none; outline: none;
}
.kachel:focus-visible { box-shadow: 0 0 0 2px var(--grund), 0 0 0 4px var(--tinte); }
.k-oben { display: flex; justify-content: space-between; align-items: center; min-height: 37px; padding: 14px 14px 0; position: relative; z-index: 1; }
.badge { font: 600 10.5px/1 'Instrument Sans', sans-serif; letter-spacing: .06em; text-transform: uppercase; padding: 5px 8px; border-radius: 999px; background: var(--grund); white-space: nowrap; }
.badge.neu { background: var(--akzent); color: #fff; }
.badge.gruen { background: var(--gruen); color: #fff; }
.bild { flex: 1; min-height: 0; position: relative; }
.bild svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; transform-origin: 50% 85%; }
.text { padding: 0 14px 14px; display: grid; gap: 4px; }
.name { font-size: clamp(16px, 1.1vw, 18px); font-weight: 600; letter-spacing: -.01em; white-space: nowrap; }
.preis { font-size: 14px; color: #595959; font-variant-numeric: tabular-nums; white-space: nowrap; }
.preis b { color: var(--tinte); font-weight: 500; }
@container (max-width: 175px) { .anzahl { display: none; } }
.glasuren { display: flex; gap: 4px; margin-top: 4px; }
.glasuren span { width: 11px; height: 11px; border-radius: 50%; background: var(--c); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); }
.mehr { display: flex; align-items: center; gap: 12px; margin-top: 8px; width: 290px; font-size: 13.5px; line-height: 1.35; color: var(--leise); opacity: 0; height: 0; overflow: hidden; transition: opacity 100ms var(--kurve); }
.mehr span { flex: 1; }
.mehr em { font-style: normal; flex: none; background: var(--tinte); color: var(--grund); font-size: 12.5px; font-weight: 500; padding: 7px 12px; border-radius: 999px; }
.seite { opacity: 0; transition: opacity 120ms var(--kurve); }

/* Gemeinsame Übergänge: zurück auf voll schnell (150 ms), ins Gedimmte etwas länger (200 ms) */
.bild, .text { transition: opacity var(--dimm-aus) var(--kurve), transform var(--dimm-aus) var(--kurve); }

/* 1 · Dimmen: reines CSS mit :has(). .an setzt die Vorführung bzw. das Tippen auf Touch. */
@media (hover: hover) and (pointer: fine) {
  .reihe[data-variante='dimmen']:has(.kachel:hover) .kachel:not(:hover) :is(.bild, .text) { transition-duration: var(--dimm-an); }
  .reihe[data-variante='dimmen']:has(.kachel:hover) .kachel:not(:hover) .bild { opacity: var(--bild-leise); }
  .reihe[data-variante='dimmen']:has(.kachel:hover) .kachel:not(:hover) .text { opacity: var(--text-leise); }
}
.reihe[data-variante='dimmen']:has(.kachel:is(:focus-visible, .an)) .kachel:not(:focus-visible, .an) :is(.bild, .text) { transition-duration: var(--dimm-an); }
.reihe[data-variante='dimmen']:has(.kachel:is(:focus-visible, .an)) .kachel:not(:focus-visible, .an) .bild { opacity: var(--bild-leise); }
.reihe[data-variante='dimmen']:has(.kachel:is(:focus-visible, .an)) .kachel:not(:focus-visible, .an) .text { opacity: var(--text-leise); }

/* 2 · Platz machen: das gezeigte Element wächst (flex-grow), die Nachbarn werden schmaler */
@media (hover: hover) and (pointer: fine) {
  .reihe[data-variante='platz'] > li:has(.kachel:hover) { flex-grow: var(--wachsen); }
  .reihe[data-variante='platz'] .kachel:hover :is(.mehr, .seite) { opacity: 1; transition: opacity 200ms var(--kurve) 120ms; }
}
.reihe[data-variante='platz'] > li:has(.kachel:is(:focus-visible, .an)) { flex-grow: var(--wachsen); }
.reihe[data-variante='platz'] .kachel:is(:focus-visible, .an) :is(.mehr, .seite) { opacity: 1; transition: opacity 200ms var(--kurve) 120ms; }
.reihe[data-variante='platz'] .mehr { height: auto; }

/* 3 · Nähe: JS setzt --p (0 … 1) je Kachel aus dem Abstand zum Zeiger */
.reihe[data-variante='naehe'].nah .bild { opacity: calc(var(--bild-leise) + (1 - var(--bild-leise)) * var(--p, 0)); transition-duration: 200ms; }
.reihe[data-variante='naehe'].nah .text { opacity: calc(var(--text-leise) + (1 - var(--text-leise)) * var(--p, 0)); transition-duration: 200ms; }
.reihe[data-variante='naehe'].nah .bild svg { transform: translateY(calc(var(--p, 0) * -12px)) scale(calc(1 + var(--p, 0) * .06)); transition: transform 200ms var(--kurve); }
.reihe[data-variante='naehe'] .bild svg { transition: transform var(--dimm-aus) var(--kurve); }

/* Bedienung: Varianten und tatsächlich verwendete Werte */
.steuer { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding-block: 16px 22px; margin-top: auto; }
.modi { display: flex; gap: 4px; }
.modi button { letter-spacing: inherit; text-transform: inherit; font: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: color 150ms var(--kurve), background 150ms var(--kurve), border-color 150ms var(--kurve); }
.modi button:hover { color: var(--tinte); border-color: rgba(33, 33, 33, .3); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; }
.werte b { color: var(--tinte); font-weight: 500; text-transform: none; letter-spacing: .02em; }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.vorfuehrung .reihe { pointer-events: none; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .claim p, .vorschau .sterne { display: none; }
.vorschau .claim { padding-block: 24px 20px; }
.vorschau h1 { font-size: 50px; }
.vorschau .reihe { height: 300px; }
.vorschau .steuer { padding-block: 14px 0; }
.vorschau .werte { display: none; }

/* Reduzierte Bewegung: nur Deckkraft. Platz machen wird zu Dimmen, Nähe ohne Heben. */
.reduziert .reihe > li { transition: none; }
.reduziert .reihe[data-variante] > li { flex-grow: 1; }
.reduziert .reihe[data-variante='platz'] .mehr { display: none; }
.reduziert .reihe[data-variante='platz']:has(.kachel:is(:hover, :focus-visible, .an)) .kachel:not(:hover, :focus-visible, .an) .bild { opacity: var(--bild-leise); }
.reduziert .reihe[data-variante='platz']:has(.kachel:is(:hover, :focus-visible, .an)) .kachel:not(:hover, :focus-visible, .an) .text { opacity: var(--text-leise); }
.reduziert .reihe .bild svg { transform: none !important; }

/* Kleiner: Reihe wird zur Spalte, flex-grow wirkt jetzt auf die Höhe */
@media (max-width: 900px) {
  .nav { display: none; }
  .icons .burger { display: block; }
  .claim { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  .leiste span:nth-child(n+2) { display: none; }
  .leiste span + span::before { content: none; }
  .marke small { display: none; }
  .claim { padding-block: 24px 22px; }
  .reihe { flex-direction: column; height: 680px; gap: 8px; }
  .reihe > li { min-height: 0; }
  .seite { display: none; }
  .reihe[data-variante='platz'] .mehr { height: 0; }
  .reihe[data-variante='platz'] .kachel:is(:hover, :focus-visible, .an) .mehr { height: auto; }
  .kachel { display: grid; grid-template-columns: minmax(0, 1fr) 44%; grid-template-rows: auto 1fr; }
  .k-oben { grid-column: 1; padding: 12px 12px 0; justify-content: flex-start; gap: 8px; }
  .bild { grid-column: 2; grid-row: 1 / 3; }
  .text { grid-column: 1; align-self: end; padding: 0 12px 12px; gap: 2px; }
  .mehr { width: auto; }
  .mehr em { display: none; }
  .steuer { flex-direction: column; align-items: flex-start; }
  .werte { justify-content: flex-start; }
}
@media (max-width: 360px) {
  .kopf { gap: 12px; }
  .icons { gap: 10px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
  <defs>
    <linearGradient id="glanz" x1="0" x2="1" y1="0" y2="0">
      <stop offset="0" stop-color="#000" stop-opacity=".16"/>
      <stop offset=".22" stop-color="#fff" stop-opacity=".06"/>
      <stop offset=".34" stop-color="#fff" stop-opacity=".34"/>
      <stop offset=".46" stop-color="#fff" stop-opacity="0"/>
      <stop offset=".8" stop-color="#000" stop-opacity=".1"/>
      <stop offset="1" stop-color="#000" stop-opacity=".26"/>
    </linearGradient>
    <radialGradient id="glanzRund" cx=".36" cy=".32" r=".75">
      <stop offset="0" stop-color="#fff" stop-opacity=".26"/>
      <stop offset=".35" stop-color="#fff" stop-opacity=".04"/>
      <stop offset=".8" stop-color="#000" stop-opacity=".08"/>
      <stop offset="1" stop-color="#000" stop-opacity=".22"/>
    </radialGradient>
    <radialGradient id="schatten">
      <stop offset="0" stop-color="#2a2218" stop-opacity=".26"/>
      <stop offset="1" stop-color="#2a2218" stop-opacity="0"/>
    </radialGradient>
    <pattern id="sprenkel" width="23" height="19" patternUnits="userSpaceOnUse">
      <circle cx="3" cy="4" r=".9" fill="#2b2015" fill-opacity=".45"/>
      <circle cx="14" cy="2" r=".6" fill="#2b2015" fill-opacity=".35"/>
      <circle cx="9" cy="12" r="1.1" fill="#2b2015" fill-opacity=".3"/>
      <circle cx="19" cy="14" r=".7" fill="#2b2015" fill-opacity=".45"/>
      <circle cx="21" cy="7" r=".5" fill="#2b2015" fill-opacity=".3"/>
    </pattern>
  </defs>
</svg>

<p class="leiste"><span>Gratisversand ab 60 €</span><span>Bruchsicher verpackt</span><span>30 Tage Rückgabe</span></p>
<header class="kopf huelle">
  <a class="marke" href="#start">
    <svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><path d="M5 6h13l-.7 14.2a2 2 0 0 1-2 1.8H7.7a2 2 0 0 1-2-1.8Z" fill="#212121"/><path d="M18 9.5c4.4 0 4.4 7-.4 7" fill="none" stroke="#212121" stroke-width="2.2"/><path d="M5.3 12.5c2.4 1 4.6-1 6.4 0s4 .9 6.1 0" fill="none" stroke="#f5f4f1" stroke-width="1.2"/></svg>
    <span><b>Hafenton</b><br><small>Steinzeug · Lübeck</small></span>
  </a>
  <ul class="nav">
    <li><a href="#becher">Becher</a></li><li><a href="#schalen">Schalen</a></li><li><a href="#teller">Teller</a></li>
    <li><a href="#kannen">Kannen</a></li><li><a href="#vasen">Vasen</a></li><li><a href="#werkstatt">Werkstatt</a></li>
  </ul>
  <div class="icons">
    <svg viewBox="0 0 24 24" aria-label="Suche" role="img"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>
    <svg viewBox="0 0 24 24" aria-label="Merkliste" role="img"><path d="M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z"/></svg>
    <span class="korb"><svg viewBox="0 0 24 24" aria-label="Warenkorb, 2 Artikel" role="img"><path d="M5 8h14l-1.2 12H6.2Z"/><path d="M9 8V6.5a3 3 0 0 1 6 0V8"/></svg><i aria-hidden="true">2</i></span>
    <svg class="burger" viewBox="0 0 24 24" aria-label="Menü" role="img"><path d="M4 8h16M4 16h16"/></svg>
  </div>
</header>

<main class="huelle" id="start">
  <section class="claim">
    <h1>Steinzeug, das jeden Morgen <em>mitmacht.</em></h1>
    <div>
      <p>Gedreht und doppelt gebrannt in unserer Werkstatt an der Trave. Spülmaschinenfest, stapelbar und jederzeit nachkaufbar.</p>
      <p class="sterne"><svg viewBox="0 0 78 14" aria-hidden="true"><g id="stern"><path d="m7 0 2 4.6 5 .5-3.8 3.3 1.1 4.9L7 10.8 2.7 13.3l1.1-4.9L0 5.1l5-.5Z"/></g><use href="#stern" x="16"/><use href="#stern" x="32"/><use href="#stern" x="48"/><use href="#stern" x="64"/></svg><span><b>4,8</b> aus 2.316 Bewertungen</span></p>
    </div>
  </section>

  <div class="abschnitt"><h2>Nach Kategorie einkaufen</h2><a href="#alle">Alle 46 Stücke</a></div>
  <ul class="reihe" data-reihe data-variante="dimmen">
    <li><a class="kachel" href="#becher">
      <div class="k-oben"><span class="mono">01</span><span class="badge">Bestseller</span></div>
      <div class="bild" data-motiv="becher"></div>
      <div class="text"><h3 class="name">Becher</h3><p class="preis"><b>ab 24 €</b><span class="anzahl"> · 14 Stücke</span></p>
        <p class="glasuren"><span style="--c:#4d6b78"></span><span style="--c:#a8452f"></span><span style="--c:#e7e1d3"></span><span style="--c:#34362f"></span></p>
        <p class="mehr"><span>Der Hafenbecher fasst 0,3 l und liegt warm in der Hand.</span><em>Ansehen</em></p></div>
    </a></li>
    <li><a class="kachel" href="#schalen">
      <div class="k-oben"><span class="mono">02</span></div>
      <div class="bild" data-motiv="schalen"></div>
      <div class="text"><h3 class="name">Schalen</h3><p class="preis"><b>ab 19 €</b><span class="anzahl"> · 11 Stücke</span></p>
        <p class="glasuren"><span style="--c:#e7e1d3"></span><span style="--c:#6a4a5f"></span><span style="--c:#34362f"></span></p>
        <p class="mehr"><span>Für Müsli, Ramen und alles dazwischen. Stapelbar.</span><em>Ansehen</em></p></div>
    </a></li>
    <li><a class="kachel" href="#teller">
      <div class="k-oben"><span class="mono">03</span></div>
      <div class="bild" data-motiv="teller"></div>
      <div class="text"><h3 class="name">Teller</h3><p class="preis"><b>ab 22 €</b><span class="anzahl"> · 9 Stücke</span></p>
        <p class="glasuren"><span style="--c:#34362f"></span><span style="--c:#e7e1d3"></span><span style="--c:#4d6b78"></span><span style="--c:#a8452f"></span></p>
        <p class="mehr"><span>Mit rohem Rand, den jeder Teller ein wenig anders zeigt.</span><em>Ansehen</em></p></div>
    </a></li>
    <li><a class="kachel" href="#kannen">
      <div class="k-oben"><span class="mono">04</span><span class="badge gruen">Wieder da</span></div>
      <div class="bild" data-motiv="kannen"></div>
      <div class="text"><h3 class="name">Kannen</h3><p class="preis"><b>ab 58 €</b><span class="anzahl"> · 5 Stücke</span></p>
        <p class="glasuren"><span style="--c:#6a4a5f"></span><span style="--c:#e7e1d3"></span></p>
        <p class="mehr"><span>Die Kanne Wakenitz gießt, ohne zu tropfen.</span><em>Ansehen</em></p></div>
    </a></li>
    <li><a class="kachel" href="#vasen">
      <div class="k-oben"><span class="mono">05</span><span class="badge neu">Neu</span></div>
      <div class="bild" data-motiv="vasen"></div>
      <div class="text"><h3 class="name">Vasen</h3><p class="preis"><b>ab 32 €</b><span class="anzahl"> · 7 Stücke</span></p>
        <p class="glasuren"><span style="--c:#a8452f"></span><span style="--c:#4d6b78"></span><span style="--c:#e7e1d3"></span></p>
        <p class="mehr"><span>Flaschenvase in Backstein, wie die Giebel der Altstadt.</span><em>Ansehen</em></p></div>
    </a></li>
  </ul>

  <div class="steuer">
    <div class="modi mono" role="group" aria-label="Variante">
      <button type="button" data-modus="dimmen" aria-pressed="true">Dimmen</button>
      <button type="button" data-modus="platz" aria-pressed="false">Platz machen</button>
      <button type="button" data-modus="naehe" aria-pressed="false">Nähe</button>
    </div>
    <p class="werte mono" data-werte aria-live="polite"></p>
  </div>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><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;
  const reduziert = () => html.classList.contains('reduziert');
  const reihe = document.querySelector('[data-reihe]');
  const kacheln = [...reihe.querySelectorAll('.kachel')];
  const werte = document.querySelector('[data-werte]');
  const knoepfe = [...document.querySelectorAll('[data-modus]')];
  const zeiger = document.querySelector('[data-zeiger]');

  /* ---------- Bildwelt: Steinzeug als SVG (Ton, getauchte Glasur, Sprenkel, Glanz) ---------- */
  const TON = '#c8b08f';
  const G = { ostsee: '#4d6b78', backstein: '#a8452f', salz: '#e7e1d3', moor: '#34362f', holunder: '#6a4a5f' };
  // Formen in lokalen Koordinaten: Mitte x = 0, Standfläche y = 0
  const FORM = {
    becher:  { d: 'M-40,-110 L40,-110 L37,-8 Q36,0 28,0 L-28,0 Q-36,0 -37,-8 Z', w: 80, oben: -110, rand: [40, 6], fuss: -22, henkel: 'M37,-92 C72,-94 74,-36 35,-32' },
    schale:  { d: 'M-72,-64 L72,-64 Q68,-16 28,-7 L24,0 L-24,0 L-28,-7 Q-68,-16 -72,-64 Z', w: 144, oben: -64, rand: [72, 9], fuss: -10 },
    platte:  { d: 'M-62,-11 L62,-11 Q55,0 42,0 L-42,0 Q-55,0 -62,-11 Z', w: 124, oben: -11, fuss: -4 },
    teller:  { d: 'M-72,-74 a72,72 0 1,0 144,0 a72,72 0 1,0 -144,0 Z', w: 144, oben: -146, glasur: 'M-64,-74 a64,64 0 1,0 128,0 a64,64 0 1,0 -128,0 Z', spiegel: 40 },
    kanne:   { d: 'M-27,-130 L27,-130 L30,-112 C60,-96 66,-42 50,-13 Q44,0 31,0 L-31,0 Q-44,0 -50,-13 C-66,-42 -60,-96 -30,-112 Z', w: 132, oben: -130, rand: [27, 4], fuss: -18, henkel: 'M48,-98 C88,-98 88,-34 52,-30', tuelle: 'M-29,-121 L-60,-140 Q-63,-134 -57,-128 L-34,-104 Z' },
    flasche: { d: 'M-12,-172 L12,-172 L12,-130 C12,-112 60,-106 60,-58 C60,-18 40,0 20,0 L-20,0 C-40,0 -60,-18 -60,-58 C-60,-106 -12,-112 -12,-130 Z', w: 120, oben: -172, rand: [12, 3], fuss: -14 },
    knospe:  { d: 'M-6,-72 L6,-72 L6,-54 C30,-48 30,0 14,0 L-14,0 C-30,0 -30,-48 -6,-54 Z', w: 52, oben: -72, rand: [6, 2], fuss: -8, zweig: true },
    zylinder:{ d: 'M-28,-118 L28,-118 L28,-6 Q28,0 22,0 L-22,0 Q-28,0 -28,-6 Z', w: 56, oben: -118, rand: [28, 5], fuss: -14 },
  };
  let nr = 0;
  function stueck(f, glasur, x, s = 1, y = 210, seite = false) {
    const F = FORM[f], id = 'k' + (++nr), u = F.fuss;
    const tauch = F.glasur || `M-110,-260 L110,-260 L110,${u} C70,${u + 5} 40,${u - 3} 10,${u + 2} C-25,${u + 7} -60,${u - 2} -110,${u + 1} Z`;
    const tropfen = F.glasur ? '' : `<ellipse cx="${F.w * .18}" cy="${u + 3}" rx="3.2" ry="5" fill="${glasur}"/><ellipse cx="${-F.w * .27}" cy="${u + 2}" rx="2.6" ry="3.6" fill="${glasur}"/>`;
    return `<g class="${seite ? 'seite' : ''}" transform="translate(${x} ${y}) scale(${s})">
      ${y === 210 || !seite ? `<ellipse cx="4" cy="0" rx="${F.w * .66}" ry="8" fill="url(#schatten)"/>` : ''}
      ${F.henkel ? `<path d="${F.henkel}" fill="none" stroke="${glasur}" stroke-width="11" stroke-linecap="round"/><path d="${F.henkel}" fill="none" stroke="#000" stroke-opacity=".14" stroke-width="11" stroke-linecap="round"/>` : ''}
      ${F.tuelle ? `<path d="${F.tuelle}" fill="${glasur}"/>` : ''}
      <clipPath id="${id}"><path d="${F.d}"/></clipPath>
      <g clip-path="url(#${id})">
        <rect x="-110" y="-260" width="220" height="270" fill="${TON}"/>
        <path d="${tauch}" fill="${glasur}"/>${tropfen}
        <rect x="-110" y="-260" width="220" height="270" fill="url(#sprenkel)"/>
        <rect x="${-F.w / 2}" y="${F.oben}" width="${F.w}" height="${-F.oben}" fill="url(#${F.spiegel ? 'glanzRund' : 'glanz'})"/>
      </g>
      ${F.spiegel ? `<circle cx="0" cy="-74" r="${F.spiegel}" fill="none" stroke="#000" stroke-opacity=".18" stroke-width="1.5"/><circle cx="-6" cy="-80" r="${F.spiegel - 6}" fill="#fff" fill-opacity=".05"/>` : ''}
      ${F.rand ? `<ellipse cx="0" cy="${F.oben}" rx="${F.rand[0]}" ry="${F.rand[1]}" fill="#000" fill-opacity=".3"/><ellipse cx="0" cy="${F.oben}" rx="${F.rand[0]}" ry="${F.rand[1]}" fill="none" stroke="#fff" stroke-opacity=".28" stroke-width="1.4"/>` : ''}
      ${F.zweig ? `<path d="M0,-72 C-2,-110 -10,-140 -22,-176 M-3,-104 C6,-122 14,-132 26,-142" fill="none" stroke="#6f6a45" stroke-width="1.8" stroke-linecap="round"/><ellipse cx="-23" cy="-182" rx="4" ry="9" fill="#9b8a55" transform="rotate(-16 -23 -182)"/><ellipse cx="28" cy="-146" rx="3.4" ry="7.5" fill="#9b8a55" transform="rotate(38 28 -146)"/>` : ''}
    </g>`;
  }
  const MOTIVE = {
    becher:  () => stueck('becher', G.backstein, 62, .58, 210, true) + stueck('becher', G.salz, 338, .8, 210, true) + stueck('becher', G.ostsee, 196),
    schalen: () => stueck('schale', G.holunder, 60, .5, 210, true) + stueck('schale', G.moor, 342, .7, 210, true) + stueck('schale', G.salz, 200),
    teller:  () => stueck('platte', G.backstein, 62, .9, 210, true) + stueck('platte', G.ostsee, 62, .9, 199, true) + stueck('platte', G.salz, 62, .9, 188, true)
                 + stueck('teller', G.backstein, 345, .55, 210, true) + stueck('teller', G.moor, 200),
    kannen:  () => stueck('becher', G.salz, 50, .5, 210, true) + stueck('becher', G.holunder, 350, .5, 210, true) + stueck('kanne', G.holunder, 196),
    vasen:   () => stueck('knospe', G.salz, 66, 1, 210, true) + stueck('zylinder', G.ostsee, 336, .9, 210, true) + stueck('flasche', G.backstein, 200),
  };
  document.querySelectorAll('[data-motiv]').forEach(el => {
    // slice: Höhe bestimmt den Maßstab, Breite zeigt mehr oder weniger vom Tisch
    el.innerHTML = `<svg viewBox="0 0 400 240" preserveAspectRatio="xMidYMid slice" aria-hidden="true">${MOTIVE[el.dataset.motiv]()}</svg>`;
  });

  /* ---------- Varianten ---------- */
  const WERTE = {
    dimmen: '<span>Auslöser <b>:has(:hover)</b></span><span>Bild <b>0.4</b> · Text <b>0.7</b></span><span>An <b>200 ms</b> · Aus <b>150 ms</b></span><span>Kurve <b>cubic-bezier(0, 0, 0.2, 1)</b></span>',
    platz:  '<span>flex-grow <b>1 → 2.4</b></span><span>Dauer <b>400 ms</b></span><span>Zusatz <b>200 ms</b>, <b>120 ms</b> später</span><span>Kurve <b>cubic-bezier(0, 0, 0.2, 1)</b></span>',
    naehe:  '<span>Radius <b>320 px</b></span><span>p = <b>clamp(1 − d / r, 0, 1)</b></span><span>Bild <b>0.4 + 0.6 p</b> · Text <b>0.7 + 0.3 p</b></span><span>hebt <b>12 p px</b> · scale <b>1 + 0.06 p</b></span><span>Glättung <b>200 ms</b></span>',
  };
  const WERTE_RED = {
    dimmen: '<span>Reduzierte Bewegung: <b>nur Deckkraft</b> · Bild <b>0.4</b> · Text <b>0.7</b> · <b>150–200 ms</b></span>',
    platz:  '<span>Reduzierte Bewegung: <b>keine Breitenänderung</b>, Nachbarn dimmen auf <b>0.4</b></span>',
    naehe:  '<span>Reduzierte Bewegung: <b>nur Deckkraft</b>, kein Heben</span>',
  };
  let gewaehlt = 'dimmen', variante = 'dimmen';
  function setzeVariante(v) {
    variante = v;
    reihe.dataset.variante = v;
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === v)));
    werte.innerHTML = (reduziert() ? WERTE_RED : WERTE)[v];
    if (v !== 'naehe') reihe.classList.remove('nah');
  }
  knoepfe.forEach(b => b.addEventListener('click', () => { neustart(); gewaehlt = b.dataset.modus; setzeVariante(gewaehlt); }));

  /* ---------- Gemeinsamer Zustand für Tippen und Vorführung ---------- */
  function markiere(i) { kacheln.forEach((k, j) => k.classList.toggle('an', j === i)); }

  /* ---------- Nähe: Abstand des Zeigers zur Kachel (0 innerhalb) ---------- */
  const RADIUS = 320;
  function naehe(x, y) {
    reihe.classList.add('nah');
    kacheln.forEach(k => {
      const r = k.getBoundingClientRect();
      const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);
      const p = Math.max(0, Math.min(1, 1 - Math.hypot(dx, dy) / RADIUS));
      k.style.setProperty('--p', p.toFixed(3));
    });
  }
  function naeheAus() { reihe.classList.remove('nah'); }
  // Wirkzone: die Reihe plus ein Rand, damit die Gruppe schon vor dem Betreten reagiert
  addEventListener('pointermove', e => {
    if (e.pointerType !== 'mouse' || variante !== 'naehe' || html.classList.contains('vorfuehrung')) return;
    const r = reihe.getBoundingClientRect(), rand = 60;
    if (e.clientX > r.left - rand && e.clientX < r.right + rand && e.clientY > r.top - rand && e.clientY < r.bottom + rand) naehe(e.clientX, e.clientY);
    else naeheAus();
  });
  document.addEventListener('pointerleave', naeheAus);
  // Tastatur: fokussierte Kachel ist „ganz nah“
  reihe.addEventListener('focusin', e => {
    if (variante !== 'naehe') return;
    reihe.classList.add('nah');
    kacheln.forEach(k => k.style.setProperty('--p', k === e.target ? 1 : 0));
  });
  reihe.addEventListener('focusout', () => { if (variante === 'naehe') naeheAus(); });

  // Touch: erstes Tippen wählt aus (gleicher Stil wie Hover), zweites würde öffnen
  kacheln.forEach((k, i) => k.addEventListener('click', e => {
    e.preventDefault();
    if (!matchMedia('(hover: none)').matches) return;
    const neu = !k.classList.contains('an');
    markiere(neu ? i : -1);
    if (variante === 'naehe') {
      if (neu) { reihe.classList.add('nah'); kacheln.forEach(n => n.style.setProperty('--p', n === k ? 1 : 0)); }
      else naeheAus();
    }
  }));
  document.addEventListener('click', e => { if (!e.target.closest('.reihe') && !e.target.closest('.modi')) { markiere(-1); if (variante === 'naehe') naeheAus(); } });

  /* ---------- Vorführung: gedachter Zeiger, je Runde eine Variante ---------- */
  const RUNDE = 7200, FOLGE = ['dimmen', 'platz', 'naehe'];
  let uhr = null, start = 0, letzteRunde = -1;
  const sanft = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  const mitte = i => { const r = kacheln[i].getBoundingClientRect(); return r.left + r.width / 2; };
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    start = performance.now(); letzteRunde = -1;
    const STOPPS = [0, 1, 2, 3, 4, 2];
    const schritt = t => {
      const zeit = t - start, runde = Math.floor(zeit / RUNDE), p = (zeit % RUNDE) / RUNDE;
      if (runde !== letzteRunde) { letzteRunde = runde; markiere(-1); naeheAus(); setzeVariante(FOLGE[runde % FOLGE.length]); }
      const r = reihe.getBoundingClientRect();
      const yMitte = r.top + r.height * .55, yAus = Math.min(innerHeight - 24, r.bottom + 40);
      let x, y, drin = true;
      if (p < .07 || p > .9) {                                 // außerhalb: alle Kacheln in Ruhe
        x = p < .07 ? mitte(0) : mitte(2) + 40; y = yAus; drin = false;
      } else if (p < .13) {                                    // hinein
        const q = sanft((p - .07) / .06); x = mitte(0); y = yAus + (yMitte - yAus) * q;
      } else if (p > .84) {                                    // hinaus
        const q = sanft((p - .84) / .06); x = mitte(2) + 40 * q; y = yMitte + (yAus - yMitte) * q;
      } else {
        const q = (p - .13) / .71;
        if (variante === 'naehe') {                            // gleitet frei über die Reihe
          x = q < .625 ? r.left + r.width * (.08 + .84 * sanft(q / .625))        // links → rechts
                       : r.left + r.width * (.92 - .42 * sanft((q - .625) / .375)); // zurück zur Mitte
          y = yMitte + Math.sin(q * Math.PI * 3) * r.height * .12;
        } else {                                               // springt von Kachel zu Kachel und verweilt
          const k = q * STOPPS.length, i = Math.min(STOPPS.length - 1, Math.floor(k)), f = k - i;
          const von = i === 0 ? mitte(STOPPS[0]) : mitte(STOPPS[i - 1]), nach = mitte(STOPPS[i]);
          x = von + (nach - von) * sanft(Math.min(1, f / .35));
          y = yMitte + Math.sin(q * Math.PI * 4) * 6;
        }
      }
      zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
      if (variante === 'naehe') { drin ? naehe(x, y) : naeheAus(); }
      else {
        const i = drin ? kacheln.findIndex(k => { const b = k.getBoundingClientRect(); return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom; }) : -1;
        markiere(i);
      }
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() { pause(); html.classList.remove('vorfuehrung'); markiere(-1); naeheAus(); setzeVariante(gewaehlt); }

  setzeVariante(gewaehlt);
  // .reduziert kann später kommen (Schalter der Detailseite): Werte-Zeile nachziehen
  new MutationObserver(() => setzeVariante(variante)).observe(html, { attributes: true, attributeFilter: ['class'] });

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