Zum Inhalt
← Hover

Bildvorschau beim Listen-Hover

Die Liste bleibt typografisch ruhig; das Bild kommt erst, wenn der Blick auf einer Zeile ruht, und folgt der Maus mit leichter Trägheit.

  1. Eine typografische Liste (Projekte, Leistungen, Menü) ist der eigentliche Inhalt.
  2. Beim Hover über eine Zeile erscheint neben dem Zeiger ein Bild in einem Rahmen, der aus dem Nichts aufskaliert; das Bild darin zoomt gegenläufig zurück.
  3. Der Rahmen folgt der Maus mit Verzögerung (Lerp) und kippt leicht in Laufrichtung.
  4. Beim Wechsel zur nächsten Zeile bleibt der Rahmen stehen; nur der Bildstreifen darin fährt zum nächsten Bild (top: Index × −100 %).
  5. Beim Verlassen der Liste schrumpft der Rahmen weg. Die übrigen Zeilen treten zurück, solange eine gezeigt wird.

Die Seite bleibt ruhig und typografisch, liefert Bilder aber genau dann, wenn Interesse da ist. Weil der Rahmen beim Zeilenwechsel stehen bleibt, entsteht Kontinuität statt Flackern; die Trägheit macht ihn körperlich. Das wirkt wie ein Werkverzeichnis: sachlich, editorial, selbstbewusst.

Wann einsetzen

Projektindex, Arbeitenliste, Leistungen, Team, Menü-Overlays, Presse- und Award-Listen. Portfolios, Studios, Architektur, Fotografie, Archive.

Wann nicht

Wenn das Bild die wichtigste Information ist, etwa im Shop (dann Raster). Bei weniger als fünf Einträgen. Wenn die Zielgruppe fast nur auf dem Telefon kommt.

Varianten

  • Bild folgt dem Zeiger mit Kippen nach Laufrichtung (diese Demo)
  • Fester Vorschaurahmen, nur der Bildstreifen wechselt (ruhiger)
  • Mit Cursor-Kreis und Label „Ansehen“, unterschiedlich schnell nachlaufend
  • Vollflächiges Hintergrundbild wechselt je Zeile
  • Bild öffnet sich per clip-path in der Zeile selbst statt am Zeiger
  • Als Hauptnavigation im Vollbild-Menü
  • Mit Ansichtsumschalter Liste/Raster (Flip oder View Transition)

Umsetzung

Ein einziger Rahmen für alle Zeilen, darin ein Streifen mit allen Bildern (vorgeladen). Pro Bild: ist += (ziel − ist) × k mit k = 1 − (1 − 0,1)^(dt/16,7), so bleibt die Trägheit bei 120 Hz gleich. Drehung = clamp(Abstand × 0,06, −8°, 8°), ebenfalls gelerpt. Beim ersten Eintritt Ist auf Ziel setzen, sonst fliegt der Rahmen von der alten Stelle herein. Ein- und Ausblenden über scale mit verschiedenen Kurven: hinein power3.inOut 450 ms, hinaus power2.in 300 ms.

Fallstricke: Das Bild nie über die Zeile legen, die gerade gelesen wird (rechts unter den Zeiger, an den Rändern auf die andere Seite). Videos erst beim ersten Hover laden. Die Schleife mit requestAnimationFrame anhalten, sobald der Rahmen zur Ruhe gekommen ist.

Robust umsetzen

Telefon. Ohne Hover entfällt die schwebende Vorschau. Daumenbild fest in der Zeile; ein Tipp führt direkt zum Projekt.

Ohne Bewegung. Kein Folgen, kein Kippen: das Bild steht an fester Stelle neben der Zeile und wechselt per Überblendung (150 ms).

Barrierefrei. Jede Zeile ist ein echter Link mit aussagekräftigem Namen. Die Vorschau ist dekorativ (aria-hidden, alt leer). Tastaturfokus auf einer Zeile zeigt das Bild ebenfalls, an fester Stelle. Zusatzinfos (Art, Jahr) stehen als Text in der Zeile, nicht nur im Bild.

Code

<!DOCTYPE html>
<!--
  Bildvorschau beim Listen-Hover
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/listen-hover-bildvorschau/
  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>Bildvorschau beim Listen-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: '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 {
  --papier: #eeebe4; --tinte: #141413; --leise: rgba(20, 20, 19, .64); --linie: rgba(20, 20, 19, .16);
  --ein: cubic-bezier(0.76, 0, 0.24, 1);   /* Vorschau erscheint: power3.inOut, 450 ms */
  --aus: cubic-bezier(0.32, 0, 0.67, 0);   /* Vorschau geht: power2.in, 300 ms */
  --zeile: cubic-bezier(0.16, 1, 0.3, 1);  /* Zeile rückt ein: expo.out, 600 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(20px, 4vw, 48px); display: grid; align-content: center; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.kopf { display: flex; justify-content: space-between; padding-bottom: 14px; }

.liste { list-style: none; padding: 0; border-bottom: 1px solid var(--linie); }
.zeile a {
  display: grid; grid-template-columns: 3.2em 1fr auto 4.2em; align-items: baseline; gap: 16px;
  padding: clamp(10px, 1.6vw, 18px) 0; border-top: 1px solid var(--linie);
  color: inherit; text-decoration: none; outline: none;
}
.zeile .name { font-size: clamp(26px, 5.2vw, 68px); font-weight: 500; letter-spacing: -.04em; line-height: 1; transition: translate .6s var(--zeile), opacity .4s var(--zeile); }
.zeile .art { font-size: 15px; color: var(--leise); }
.zeile .jahr { text-align: right; }
.zeile .daumen { display: none; }
/* Nachbarn treten zurück, die gezeigte Zeile rückt ein */
.liste.aktiv .zeile .name { opacity: .28; }
.liste.aktiv .zeile.an .name { opacity: 1; translate: .25em 0; }
.zeile a:focus-visible .name { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .12em; }

/* Die schwebende Vorschau: ein Rahmen, darin ein Streifen mit allen Bildern */
.bild {
  position: fixed; left: 0; top: 0; z-index: 5; pointer-events: none;
  width: clamp(200px, 22vw, 320px); aspect-ratio: 4 / 5; border-radius: 6px; overflow: hidden;
  scale: 0; transition: scale .3s var(--aus);
  will-change: transform;
}
.bild.an { scale: 1; transition: scale .45s var(--ein); }
.streifen { position: absolute; inset: 0; transition: translate .7s var(--ein); }
.streifen img { display: block; width: 100%; height: 100%; object-fit: cover; scale: 1.15; transition: scale .9s var(--ein); }
.bild.an .streifen img { scale: 1; }

/* Vorführung: ein gezeichneter Zeiger zeigt, wo die Maus wäre */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* Touch: kein Hover – Daumenbild fest in der Zeile */
@media (hover: none) {
  .bild { display: none; }
  .zeile a { grid-template-columns: 2.4em 1fr auto; }
  .zeile .art { display: none; }
  .zeile .daumen { display: block; grid-row: 1; grid-column: 3; width: 52px; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 4px; align-self: center; }
  .zeile .jahr { display: none; }
}
/* Reduzierte Bewegung: kein Folgen, kein Kippen, nur Überblenden an fester Stelle */
.reduziert .bild, .reduziert .bild.an { scale: 1; opacity: 0; transition: opacity .15s linear; }
.reduziert .bild.an { opacity: 1; }
.reduziert .streifen, .reduziert .streifen img, .reduziert .zeile .name { transition: none; scale: 1; }
.reduziert .liste.aktiv .zeile.an .name { translate: none; }
@media (max-width: 560px) { .zeile .art { display: none; } .zeile a { grid-template-columns: 2.4em 1fr auto; } }
</style>
</head>
<body>
<div class="kopf"><p class="mono">Ausgewählte Arbeiten</p><p class="mono">(06)</p></div>
<ul class="liste" data-liste>
  <li class="zeile"><a href="#gefuege"><span class="mono">01</span><span class="name">Gefüge</span><span class="art">Editorial</span><span class="mono jahr">2025</span><img class="daumen" src="bilder/magazin-doppelseite-hoch.jpg" alt=""></a></li>
  <li class="zeile"><a href="#roestkontor"><span class="mono">02</span><span class="name">Röstkontor</span><span class="art">Marke</span><span class="mono jahr">2024</span><img class="daumen" src="bilder/marke-roestkontor-hoch.jpg" alt=""></a></li>
  <li class="zeile"><a href="#ocker"><span class="mono">03</span><span class="name">Ocker 04</span><span class="art">Packaging</span><span class="mono jahr">2026</span><img class="daumen" src="bilder/flakon-hoch.jpg" alt=""></a></li>
  <li class="zeile"><a href="#krume"><span class="mono">04</span><span class="name">Krume</span><span class="art">Website</span><span class="mono jahr">2023</span><img class="daumen" src="bilder/marke-krume-hoch.jpg" alt=""></a></li>
  <li class="zeile"><a href="#licht"><span class="mono">05</span><span class="name">Werkschau Licht</span><span class="art">Plakat</span><span class="mono jahr">2022</span><img class="daumen" src="bilder/typo-plakat-hoch.jpg" alt=""></a></li>
  <li class="zeile"><a href="#eselsohr"><span class="mono">06</span><span class="name">Eselsohr</span><span class="art">Markenkonzept</span><span class="mono jahr">2021</span><img class="daumen" src="bilder/marke-eselsohr-hoch.jpg" alt=""></a></li>
</ul>
<div class="bild" data-vorschau aria-hidden="true">
  <div class="streifen" data-streifen>
    <img src="bilder/magazin-doppelseite-hoch.jpg" alt=""><img src="bilder/marke-roestkontor-hoch.jpg" alt="">
    <img src="bilder/flakon-hoch.jpg" alt=""><img src="bilder/marke-krume-hoch.jpg" alt="">
    <img src="bilder/typo-plakat-hoch.jpg" alt=""><img src="bilder/marke-eselsohr-hoch.jpg" alt="">
  </div>
</div>
<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="#141413" stroke="#eeebe4" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const liste = document.querySelector('[data-liste]');
  const zeilen = [...liste.querySelectorAll('.zeile')];
  const vorschau = document.querySelector('[data-vorschau]');
  const streifen = document.querySelector('[data-streifen]');
  const zeiger = document.querySelector('[data-zeiger]');

  // Ziel (Maus) und Ist (Vorschau). Pro Frame rückt Ist um LERP an Ziel heran.
  const LERP = 0.1;          // bei 60 Bildern/s; wird auf die echte Bildrate umgerechnet
  const ziel = { x: 0, y: 0 }, ist = { x: 0, y: 0, r: 0 };
  let aktiv = -1, erster = true, letzte = performance.now(), laeuft = false;

  function zeigen(i) {
    if (i === aktiv) return;
    aktiv = i;
    zeilen.forEach((z, j) => z.classList.toggle('an', j === i));
    liste.classList.toggle('aktiv', i >= 0);
    vorschau.classList.toggle('an', i >= 0);
    if (i >= 0) streifen.style.translate = `0 ${-100 * i}%`;
    if (reduziert() && i >= 0) festSetzen(i);
    starten();
  }
  // Ohne Folgen: Vorschau rechts neben der Zeile (reduzierte Bewegung, Tastatur)
  function festSetzen(i) {
    const r = zeilen[i].getBoundingClientRect(), w = vorschau.offsetWidth, h = vorschau.offsetHeight;
    ist.x = ziel.x = Math.min(innerWidth - w - 24, r.left + r.width * 0.62);
    ist.y = ziel.y = Math.max(12, Math.min(innerHeight - h - 12, r.top + r.height / 2 - h / 2));
    ist.r = 0; malen();
  }
  function zielAus(px, py) {
    // Bild rechts unter den Zeiger, damit die gelesene Zeile frei bleibt;
    // am rechten bzw. unteren Rand auf die andere Seite
    const w = vorschau.offsetWidth, h = vorschau.offsetHeight;
    ziel.x = px + 40 + w > innerWidth ? px - 40 - w : px + 40;
    ziel.y = py + 28 + h > innerHeight ? Math.max(8, py - 28 - h) : py + 28;
    if (erster) { ist.x = ziel.x; ist.y = ziel.y; erster = false; }   // nicht von der alten Stelle hereinfliegen
  }
  function malen() {
    vorschau.style.transform = `translate3d(${ist.x}px, ${ist.y}px, 0) rotate(${ist.r}deg)`;
  }
  function bild(t) {
    const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
    const k = 1 - Math.pow(1 - LERP, dt);
    if (!reduziert()) {
      const dx = ziel.x - ist.x;
      ist.x += dx * k; ist.y += (ziel.y - ist.y) * k;
      ist.r += (Math.max(-8, Math.min(8, dx * 0.06)) - ist.r) * k;   // kippt in Laufrichtung, höchstens 8°
      malen();
    }
    const ruhig = Math.abs(ziel.x - ist.x) < 0.1 && Math.abs(ziel.y - ist.y) < 0.1 && Math.abs(ist.r) < 0.01;
    if (aktiv < 0 && ruhig) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }
  function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }

  // Maus
  zeilen.forEach((z, i) => {
    z.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { zielAus(e.clientX, e.clientY); zeigen(i); } });
    z.querySelector('a').addEventListener('focus', () => { zeigen(i); festSetzen(i); });
    z.querySelector('a').addEventListener('blur', () => zeigen(-1));
  });
  liste.addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && !reduziert()) { zielAus(e.clientX, e.clientY); starten(); } });
  liste.addEventListener('pointerleave', () => { zeigen(-1); erster = true; });
  liste.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });

  // Vorführung: ein gedachter Zeiger fährt die Liste ab
  let uhr = null, start = 0;
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    start = performance.now();
    const schritt = t => {
      const p = ((t - start) / 7000) % 1;      // 7 s pro Runde
      const a = zeilen[0].getBoundingClientRect(), b = zeilen[zeilen.length - 1].getBoundingClientRect();
      let x, y;
      if (p < 0.08 || p > 0.86) { x = innerWidth * 0.8; y = innerHeight * 0.92; }        // außerhalb
      else {
        const q = (p - 0.08) / 0.78;
        y = a.top + a.height * 0.5 + (b.bottom - a.top - a.height) * (0.5 - 0.5 * Math.cos(q * Math.PI));
        x = a.left + a.width * (0.2 + 0.22 * Math.sin(q * Math.PI * 3));
      }
      zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
      const i = zeilen.findIndex(z => { const r = z.getBoundingClientRect(); return y >= r.top && y < r.bottom; });
      if (i >= 0) { zielAus(x, y); zeigen(i); } else if (aktiv >= 0) { zeigen(-1); erster = true; }
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() { pause(); html.classList.remove('vorfuehrung'); zeigen(-1); erster = true; }

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