Projektindex als Liste mit Hover-Bildvorschau
Ein Werkverzeichnis als typografische Liste: das Bild erscheint erst beim Zeigen, folgt dem Zeiger oder wechselt in einer festen Fläche und wächst beim Klick zum Hero der Fallstudie.
Projekte stehen nicht im Kachelraster, sondern in einer typografischen Liste. Jede Zeile hat Nummer, großen Projektnamen und rechts kleine Metadaten wie Disziplin, Branche und Jahr, getrennt durch Haarlinien. Beim Hover erscheint ein Bild des Projekts. Es folgt entweder verzögert dem Zeiger und kippt je nach Mausgeschwindigkeit, oder es wechselt in einer festen Fläche neben der Liste. Das neue Bild schiebt sich aus der Richtung herein, aus der die Maus kommt. Die übrigen Zeilen treten zurück. Ein Klick führt per Seitenübergang zur Fallstudie, oft wächst das Vorschaubild dabei zum Hero der Detailseite.
Die Liste liest sich wie das Inhaltsverzeichnis eines Buches: seriös, schnell zu überfliegen, viele Projekte auf wenig Raum. Das Bild kommt erst, wenn Interesse da ist. Das macht neugierig, ohne die Seite zu überladen.
Wann einsetzen
Portfolios mit vielen Projekten, Studios, Architekturbüros, Fotografen, Archive. Auch für Journal- und Blogübersichten.
Wann nicht
Wenn Bilder das Hauptargument sind und viele Besucher am Telefon kommen, wo es keinen Hover gibt. Dann gehören Daumenbilder fest in die Zeilen. Bei sehr wenigen Projekten (zwei oder drei) wirkt die Liste leer.
Varianten
- Bild folgt dem Zeiger mit Lerp und Rotation
- Feste Vorschaufläche, das Bild wechselt per Reveal
- Zeile klappt beim Hover auf und zeigt einen Bildstreifen
- Bildspur: mehrere Bilder bleiben als Trail hinter dem Zeiger
- Index als Overlay über der aktuellen Seite
Umsetzung
Liste als <ul> mit einem <a> pro Zeile, Grid aus Nummer, Name und Meta. Ein einziges Vorschau-Element mit pointer-events: none teilen sich alle Zeilen, damit die Bewegung beim Zeilenwechsel nicht abreißt. In einer requestAnimationFrame-Schleife rückt die Position pro Tick um ein Zwölftel an die Maus heran (bei 120 Hz, auf die echte Bildrate umrechnen). Die Rotation ergibt sich aus der Mausgeschwindigkeit und wird begrenzt. Für den Reveal fahren Ebene und Bild gegenläufig von ±100 % auf 0, die Richtung kommt aus dem Zeilenwechsel. Bilder vorladen. Den Übergang zur Detailseite übernehmen View Transitions: view-transition-name auf Vorschau und Ziel-Hero, zwischen echten Seiten mit @view-transition { navigation: auto; } in beiden Dokumenten. Alternativ GSAP Flip.
Robust umsetzen
Telefon. Kein Hover: Daumenbild in der Zeile zeigen oder das Bild einblenden, wenn die Zeile beim Scrollen die Bildschirmmitte kreuzt. Tippen öffnet direkt.
Ohne Bewegung. Kein Folgen und keine Rotation. Das Bild steht an fester Stelle und wechselt per Überblendung.
Barrierefrei. Vorschau auch bei :focus-visible zeigen, damit sie per Tastatur erreichbar ist. Das Vorschaubild ist dekorativ (alt=""), der Linktext trägt die Information. Metadaten als echter Text, nicht nur im Bild. Nach dem Seitenwechsel den Fokus auf die neue Seite setzen und beim Zurückkehren auf den geöffneten Eintrag.
Code
<!DOCTYPE html>
<!--
Projektindex als Liste mit Hover-Bildvorschau
Fundus · Hero & Section-Layouts · https://cecodesign.de/lab/fundus/hero-layouts/projektindex-hover-vorschau/
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>Projektindex als Liste mit Hover-Bildvorschau · 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; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
:root {
--grund: #121211; --text: #ece9e1; --leise: rgba(236, 233, 225, .52); --linie: rgba(236, 233, 225, .14);
--akzent: #e8814f;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out: Intro, Bildwechsel, Seitenwechsel */
--name: clamp(24px, 3.3vw, 46px);
--pad: clamp(8px, .95vw, 13px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
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); }
.seite { padding: clamp(20px, 3.4vw, 48px); min-height: 100svh; }
.seite[hidden] { display: none; }
/* ---------- Kopf ---------- */
.kopf { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 10px 24px; margin-bottom: clamp(18px, 3vw, 40px); }
.kopf > .mono:first-child { align-self: center; }
.modi { display: flex; gap: 4px; }
.modi button { letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: color .15s, background-color .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(236, 233, 225, .3); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.titel { font-size: clamp(60px, 10.5vw, 156px); font-weight: 600; letter-spacing: -.055em; line-height: .8; display: flex; align-items: flex-start; gap: .08em; overflow: clip; padding-block: .06em; }
.titel .zahl { font-size: 13px; letter-spacing: .1em; color: var(--akzent); padding-top: .5em; }
.intro { max-width: 31ch; font-size: clamp(15px, 1.25vw, 18px); line-height: 1.4; color: var(--leise); padding-bottom: .3em; }
.intro em { font-family: 'Instrument Serif', Georgia, serif; font-size: 1.22em; line-height: 1; color: var(--text); letter-spacing: -.01em; }
/* ---------- Spalten und Liste ---------- */
.koerper { display: grid; grid-template-columns: 1fr; gap: clamp(24px, 3vw, 44px); align-items: start; }
.spalten, .zeile a { display: grid; grid-template-columns: 3.4rem minmax(0, 1fr) 11.5rem 9rem 3.4rem; column-gap: clamp(12px, 1.6vw, 24px); }
.spalten { padding-bottom: 10px; }
.spalten span:last-child, .zeile .jahr { text-align: right; }
.liste { list-style: none; padding: 0; position: relative; border-bottom: 1px solid var(--linie); }
.zeile a { position: relative; align-items: baseline; padding: var(--pad) 0; color: inherit; text-decoration: none; outline: none; cursor: pointer; }
.zeile a::before { content: ''; position: absolute; inset: 0 0 auto; height: 1px; background: var(--linie); transform-origin: left; }
.zeile .name { font-size: var(--name); font-weight: 500; letter-spacing: -.035em; line-height: 1.04; overflow: clip; padding-block: .06em; margin-block: -.06em; white-space: nowrap; text-overflow: ellipsis; }
.zeile .name > span { display: inline-block; transition: translate .4s var(--kurve); }
.zeile .disz, .zeile .branche { font-size: 15px; color: var(--leise); }
.zeile .nr, .zeile .jahr { transition: color .4s var(--kurve); }
.zeile .daumen { display: none; }
.zeile :is(.nr, .name, .disz, .branche, .jahr) { transition: opacity .4s var(--kurve), color .4s var(--kurve); }
/* Die übrigen Zeilen treten zurück */
.liste.aktiv .zeile:not(.an) :is(.nr, .name, .disz, .branche, .jahr) { opacity: .3; }
.liste.aktiv .zeile.an .name > span { translate: .18em 0; }
.zeile.an .nr { color: var(--text); }
.zeile a:focus-visible .name > span { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .12em; }
/* Intro: jede Zeile steigt aus ihrer Maske, Haarlinie zieht von links */
@keyframes steigen { from { translate: 0 105%; } }
@keyframes linie { from { scale: 0 1; } }
@keyframes auf { from { opacity: 0; translate: 0 40%; } }
.auftritt .zeile .name > span { animation: steigen 1200ms var(--kurve) both; animation-delay: calc(var(--i) * 60ms + 120ms); }
.auftritt .zeile a::before { animation: linie 1200ms var(--kurve) both; animation-delay: calc(var(--i) * 60ms); }
.auftritt .zeile :is(.nr, .disz, .branche, .jahr) { animation: auf 1200ms var(--kurve) both; animation-delay: calc(var(--i) * 60ms + 220ms); }
.auftritt .titel > span { animation: steigen 1200ms var(--kurve) both; }
/* ---------- Variante „Am Zeiger“: ein geteiltes Vorschaubild ---------- */
/* 256 × 320 statt der gemessenen 220 × 320: 4:5 wie die Medien, sonst schneidet die Vorschau breite Wortmarken an */
.folger { position: fixed; left: 0; top: 0; z-index: 5; box-shadow: 0 24px 60px -20px rgba(0, 0, 0, .6); width: 256px; height: 320px; margin: -160px 0 0 -128px; pointer-events: none; overflow: hidden; border-radius: 3px; scale: 0; transition: scale 200ms var(--kurve); will-change: transform; }
.folger.an { scale: 1; }
.folger.weg { display: none; }
.stapel { position: absolute; inset: 0; overflow: hidden; }
.ebene { position: absolute; inset: 0; overflow: hidden; opacity: 0; }
.ebene img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* ---------- Variante „Feste Fläche“ ---------- */
.flaeche { display: none; position: sticky; top: 24px; }
.flaeche .stapel { position: relative; aspect-ratio: 4 / 5; inset: auto; border: 1px solid var(--linie); border-radius: 3px; background: repeating-linear-gradient(135deg, transparent 0 9px, rgba(236, 233, 225, .035) 9px 10px); }
.flaeche .stapel::after { content: 'Vorschau'; position: absolute; left: 50%; top: 50%; translate: -50% -50%; font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); z-index: 0; }
.flaeche .ebene { z-index: 1; }
.flaeche .stapel.leer .ebene { opacity: 0 !important; transition: opacity 200ms linear; }
.unterzeile { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding-top: 12px; min-height: 2.6em; }
.unterzeile em { font: italic 400 34px/1 'Instrument Serif', Georgia, serif; color: var(--akzent); }
[data-variante='flaeche'] .koerper { grid-template-columns: minmax(0, 1fr) clamp(220px, 27vw, 380px); }
[data-variante='flaeche'] .flaeche { display: block; }
[data-variante='flaeche'] .folger { display: none; }
[data-variante='flaeche'] :is(.spalten, .zeile a) { grid-template-columns: 3.4rem minmax(0, 1fr) 3.4rem; }
[data-variante='flaeche'] :is(.disz, .branche, .spalten .sp-disz, .spalten .sp-branche) { display: none; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: clamp(20px, 3vw, 36px); }
.werte b { color: var(--text); font-weight: 500; }
/* ---------- Fallstudie (zweite „Seite“) ---------- */
.case-kopf { display: flex; justify-content: space-between; align-items: center; margin-bottom: clamp(20px, 3.2vw, 44px); }
.zurueck { background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 14px; cursor: pointer; letter-spacing: .14em; transition: border-color .15s, color .15s; }
.zurueck:hover { color: var(--text); border-color: rgba(236, 233, 225, .4); }
.zurueck:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.case { display: grid; grid-template-rows: auto 1fr; }
.case-raster { display: grid; gap: clamp(20px, 2.6vw, 36px); align-content: start; }
.case-bild { aspect-ratio: 2.2 / 1; max-height: 56svh; width: 100%; overflow: hidden; border-radius: 3px; }
.case-text { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 16px clamp(24px, 4vw, 64px); align-items: end; }
.case-nr { font: italic 400 .62em/1 'Instrument Serif', Georgia, serif; letter-spacing: 0; color: var(--akzent); vertical-align: .32em; margin-right: .12em; }
.case-bild img { display: block; width: 100%; height: 100%; object-fit: cover; }
.case-titel { font-size: clamp(40px, 6.4vw, 100px); font-weight: 600; letter-spacing: -.05em; line-height: .92; margin-top: 12px; }
.case-titel span { display: inline-block; }
.case-satz { font-size: clamp(16px, 1.4vw, 20px); line-height: 1.45; color: var(--leise); max-width: 34ch; }
.case-daten { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 6px 36px; margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--linie); }
.case-daten dt { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.case-daten dd { font-size: 15px; margin-top: 4px; }
.case:not([hidden]) :is(.case-art, .case-satz, .case-daten) { animation: auf 1000ms var(--kurve) both; }
.case:not([hidden]) .case-satz { animation-delay: 90ms; }
.case:not([hidden]) .case-daten { animation-delay: 180ms; }
/* Seitenwechsel (same-document View Transition): Vorschau wächst zum Hero, Titel wandert mit */
::view-transition-group(*) { animation-duration: 1000ms; animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); }
::view-transition-old(root) { animation-duration: 300ms; }
::view-transition-new(root) { animation-duration: 600ms; animation-delay: 150ms; }
::view-transition-group(projekt-bild) { overflow: hidden; border-radius: 3px; z-index: 3; }
::view-transition-old(projekt-bild), ::view-transition-new(projekt-bild) { width: 100%; height: 100%; object-fit: cover; animation-duration: 500ms; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 18px; height: 18px; pointer-events: none; display: none; transition: scale .15s; }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .8; }
/* ---------- Kleine Breite ---------- */
@media (max-width: 760px) {
:is(.spalten, .zeile a) { grid-template-columns: 2.6rem minmax(0, 1fr) 3.4rem; }
:is(.disz, .branche, .spalten .sp-disz, .spalten .sp-branche) { display: none; }
.kopf { grid-template-columns: 1fr; row-gap: 14px; }
.modi { grid-row: 3; }
[data-variante='flaeche'] .koerper { grid-template-columns: 1fr; }
[data-variante='flaeche'] .flaeche { display: none; }
[data-variante='flaeche'] .folger { display: block; }
.folger { width: 186px; height: 232px; margin: -116px 0 0 -93px; }
.case-raster { grid-template-columns: 1fr; }
.case-bild { aspect-ratio: 4 / 3; }
.case-text { grid-template-columns: 1fr; }
.zeile .name { white-space: normal; } /* schmal: Namen brechen um statt mit Auslassungspunkten zu enden */
}
/* ---------- Touch: kein Hover. Daumenbild in der Zeile, die Zeile in der Bildschirmmitte ist aktiv ---------- */
@media (hover: none) {
.folger, .flaeche, .modi { display: none !important; }
[data-variante] .koerper { grid-template-columns: 1fr; }
:is(.spalten, .zeile a) { grid-template-columns: 2.6rem minmax(0, 1fr) 56px; align-items: center; }
.spalten .sp-jahr, .zeile .jahr { display: none; }
.spalten :is(.sp-disz, .sp-branche) { display: none; }
.spalten::after { content: ''; }
.zeile .branche { display: none; }
.zeile .name { white-space: normal; font-size: clamp(24px, 7.4vw, 34px); }
.zeile .nr { align-self: start; padding-top: .9em; }
.zeile a { grid-template-rows: auto 1fr; }
.zeile .disz { align-self: start; }
.liste.aktiv .zeile.an .name > span { translate: none; }
.zeile .disz { display: block; grid-column: 2; grid-row: 2; font-size: 13px; margin-top: 4px; }
.zeile .daumen { display: block; grid-column: 3; grid-row: 1 / 3; width: 56px; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 3px; filter: grayscale(1) brightness(.7); scale: .86; transition: filter .4s var(--kurve), scale .4s var(--kurve); }
.zeile.an .daumen { filter: none; scale: 1; }
}
/* ---------- Reduzierte Bewegung: kein Folgen, kein Kippen, nur Überblenden an fester Stelle ---------- */
.reduziert .auftritt *, .reduziert .case:not([hidden]) * { animation: none !important; }
.reduziert .folger { transition: opacity 150ms linear; scale: 1; opacity: 0; }
.reduziert .folger.an { opacity: 1; }
.reduziert .zeile .name > span { transition: none; }
.reduziert .liste.aktiv .zeile.an .name > span { translate: none; }
.reduziert ::view-transition-group(*), .reduziert ::view-transition-old(*), .reduziert ::view-transition-new(*) { animation-duration: 150ms !important; animation-delay: 0s !important; }
/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .werte { display: none; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="zeiger">
<main>
<section class="seite index" data-index aria-labelledby="index-titel">
<header class="kopf">
<p class="mono">Werkverzeichnis · 2016 bis 2026</p>
<div class="modi mono" role="group" aria-label="Art der Vorschau">
<button type="button" data-wahl="zeiger" aria-pressed="true">Am Zeiger</button>
<button type="button" data-wahl="flaeche" aria-pressed="false">Feste Fläche</button>
</div>
<h1 class="titel" id="index-titel"><span>Index</span><sup class="zahl mono">(08)</sup></h1>
<p class="intro"><em>Marken, Kampagnen und Editorial</em> aus zehn Jahren. Eine Zeile pro Arbeit, ein Klick öffnet die Fallstudie.</p>
</header>
<div class="koerper">
<div>
<div class="spalten mono" aria-hidden="true"><span>Nr.</span><span>Projekt</span><span class="sp-disz">Disziplin</span><span class="sp-branche">Branche</span><span class="sp-jahr">Jahr</span></div>
<ul class="liste" data-liste>
<li class="zeile" style="--i:0"><a href="#roestkontor"><span class="nr mono">01</span><span class="name"><span>Röstkontor</span></span><span class="disz">Marke & Verpackung</span><span class="branche">Kaffeerösterei</span><span class="jahr mono">2024</span><img class="daumen" src="bilder/marke-roestkontor-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:1"><a href="#eselsohr"><span class="nr mono">02</span><span class="name"><span>Eselsohr</span></span><span class="disz">Marke & Website</span><span class="branche">Buchhandlung</span><span class="jahr mono">2023</span><img class="daumen" src="bilder/marke-eselsohr-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:2"><a href="#krume"><span class="nr mono">03</span><span class="name"><span>Krume</span></span><span class="disz">Logo</span><span class="branche">Backstube</span><span class="jahr mono">2025</span><img class="daumen" src="bilder/marke-krume-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:3"><a href="#gefuege"><span class="nr mono">04</span><span class="name"><span>Gefüge</span></span><span class="disz">Editorial</span><span class="branche">Verlag</span><span class="jahr mono">2022</span><img class="daumen" src="bilder/magazin-doppelseite-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:4"><a href="#ambra-nord"><span class="nr mono">05</span><span class="name"><span>Ambra Nord</span></span><span class="disz">Kampagne</span><span class="branche">Parfum</span><span class="jahr mono">2025</span><img class="daumen" src="bilder/flakon-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:5"><a href="#licht"><span class="nr mono">06</span><span class="name"><span>Licht, Halle 4</span></span><span class="disz">Plakat</span><span class="branche">Ausstellung</span><span class="jahr mono">2024</span><img class="daumen" src="bilder/typo-plakat-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:6"><a href="#atelier-hain"><span class="nr mono">07</span><span class="name"><span>Atelier Hain</span></span><span class="disz">Bildsprache</span><span class="branche">Keramik</span><span class="jahr mono">2021</span><img class="daumen" src="bilder/keramik-stillleben-hoch.jpg" alt=""></a></li>
<li class="zeile" style="--i:7"><a href="#kaltenbach"><span class="nr mono">08</span><span class="name"><span>Haus Kaltenbach</span></span><span class="disz">Buch & Website</span><span class="branche">Architektur</span><span class="jahr mono">2026</span><img class="daumen" src="bilder/beton-treppe-hoch.jpg" alt=""></a></li>
</ul>
</div>
<aside class="flaeche" aria-hidden="true">
<div class="stapel leer" data-flaeche></div>
<div class="unterzeile"><em data-fl-nr> </em><span class="mono" data-fl-text>Zeile wählen</span></div>
</aside>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</section>
<section class="seite case" data-case hidden aria-labelledby="case-titel">
<div class="case-kopf">
<button type="button" class="zurueck mono" data-zurueck>← Index</button>
<p class="mono" data-case-zaehler>01 / 08</p>
</div>
<div class="case-raster">
<figure class="case-bild" data-case-bild><img src="bilder/marke-roestkontor.jpg" alt=""></figure>
<div class="case-text">
<div>
<p class="mono case-art" data-case-art></p>
<h2 class="case-titel" id="case-titel"><em class="case-nr" data-case-nr>01</em><span data-case-titel></span></h2>
</div>
<div>
<p class="case-satz" data-case-satz></p>
<dl class="case-daten">
<div><dt>Leistung</dt><dd data-case-leistung></dd></div>
<div><dt>Branche</dt><dd data-case-branche></dd></div>
<div><dt>Jahr</dt><dd data-case-jahr></dd></div>
</dl>
</div>
</div>
</div>
</section>
</main>
<div class="folger" data-folger aria-hidden="true"><div class="stapel" data-folger-stapel></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="#ece9e1" stroke="#121211" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const touch = matchMedia('(hover: none)');
const KURVE = 'cubic-bezier(0.16, 1, 0.3, 1)';
// Werte (gemessen bei dennissnellenberg.com, index-new.js): pos += (maus - pos) / 12 auf einem 120-Hz-Ticker
const TICK = 1000 / 120, LERP = 1 / 12;
const FRAME = 1000 / 60, V_MAX = 100, ROT_MAX = 60; // Rotation: 0 bis 100 px/Frame → 0 bis ±60°
const WECHSEL = 700, AUS = 200, BLENDE = 150;
const TEXTE = {
'roestkontor': 'Eine Bohne als Signet, Versalien wie auf einem Frachtstempel. Ziegelrot, damit die Tüte im Regal nicht leise ist.',
'eselsohr': 'Die umgeknickte Ecke als Zeichen. Rosa nur dort, wo gelesen wurde, alles andere bleibt ruhig in Anthrazit.',
'krume': 'Fette, runde Buchstaben wie frisch aufgegangener Teig. Senfgelb für die Tüte, Dunkelbraun für die Kruste.',
'gefuege': 'Ein Magazin über Dinge, die bleiben. Viel Rand, eine Serif mit Kursive und Bilder, die sich Zeit nehmen.',
'ambra-nord': 'Ein Flakon, zwei Sockel, ein Blau. Die Kampagne zeigt den Duft als Objekt und kommt ohne Gesichter aus.',
'licht': 'Eine Ausstellung über Fotografie und Raum. Das Plakat ruft in Neongelb und flüstert in Kursive.',
'atelier-hain': 'Gefäße auf Sockeln, Terrakotta als Grund. Eine Bildsprache, die die Werkstatt zeigt, ohne die Werkstatt zu zeigen.',
'kaltenbach': 'Ein Haus aus Beton und Licht. Buch und Website folgen dem Schatten der Treppe durch den Tag.',
};
const BILD = {
'roestkontor': ['marke-roestkontor', 'Wortmarke Röstkontor in Creme auf Ziegelrot, darüber eine Kaffeebohne'],
'eselsohr': ['marke-eselsohr', 'Bildmarke Eselsohr: Buchseite mit rosa Eselsohr und Schriftzug in Serif-Kursive auf Anthrazit'],
'krume': ['marke-krume', 'Wortmarke krume in Dunkelbraun auf Senfgelb, darüber ein Brotlaib'],
'gefuege': ['magazin-doppelseite', 'Aufgeschlagenes Magazin Gefüge mit der Überschrift „Die Ruhe der Dinge“'],
'ambra-nord': ['flakon', 'Bernsteinfarbener Flakon auf hellen Sockeln vor Kobaltblau'],
'licht': ['typo-plakat', 'Plakat Licht mit schwarzen Versalien und oranger Scheibe auf Neongelb'],
'atelier-hain': ['keramik-stillleben', 'Keramik-Stillleben mit Vasen und Schalen auf Sockeln vor Terrakotta'],
'kaltenbach': ['beton-treppe', 'Schwebende Betontreppe an heller Wand im Streiflicht'],
};
const indexSeite = document.querySelector('[data-index]');
const caseSeite = document.querySelector('[data-case]');
const liste = document.querySelector('[data-liste]');
const zeilen = [...liste.querySelectorAll('.zeile')];
const links = zeilen.map(z => z.querySelector('a'));
const P = zeilen.map(z => {
const a = z.querySelector('a');
return { id: a.hash.slice(1), nr: z.querySelector('.nr').textContent, name: z.querySelector('.name').textContent, disz: z.querySelector('.disz').textContent, branche: z.querySelector('.branche').textContent, jahr: z.querySelector('.jahr').textContent };
});
const folger = document.querySelector('[data-folger]');
const zeiger = document.querySelector('[data-zeiger]');
const werte = document.querySelector('[data-werte]');
const flNr = document.querySelector('[data-fl-nr]'), flText = document.querySelector('[data-fl-text]');
let variante = 'zeiger';
// ---------- Bildstapel: eine Ebene je Projekt, Wechsel als gegenläufiger Reveal ----------
function stapel(el) {
const ebenen = P.map(p => {
const e = document.createElement('div'); e.className = 'ebene';
const img = new Image(); img.src = `bilder/${BILD[p.id][0]}-hoch.jpg`; img.alt = ''; img.decoding = 'async';
e.append(img); el.append(e); return e;
});
let i = -1, z = 1;
return {
el,
zeige(n, richtung) {
if (n === i) return;
const e = ebenen[n];
if (i < 0) ebenen.forEach(x => { if (x !== e) x.style.opacity = 0; });
e.getAnimations({ subtree: true }).forEach(a => a.cancel());
e.style.zIndex = ++z; e.style.opacity = 1;
el.classList.remove('leer');
if (reduziert()) e.animate([{ opacity: 0 }, { opacity: 1 }], { duration: BLENDE, easing: 'linear' });
else {
// Wrapper kommt von ±100 %, das Bild darin gegenläufig: der Rand schiebt, das Motiv steht fast still
e.animate([{ transform: `translateY(${richtung * 100}%)` }, { transform: 'none' }], { duration: WECHSEL, easing: KURVE });
e.firstChild.animate([{ transform: `translateY(${-richtung * 100}%) scale(1.15)` }, { transform: 'none' }], { duration: WECHSEL, easing: KURVE });
}
i = n;
},
leeren() { i = -1; el.classList.add('leer'); },
};
}
const folgerStapel = stapel(document.querySelector('[data-folger-stapel]'));
const flStapel = stapel(document.querySelector('[data-flaeche]'));
// ---------- Zeiger folgen: Lerp 1/12 je 120-Hz-Tick, Kippen aus der Mausgeschwindigkeit ----------
const ziel = { x: innerWidth / 2, y: innerHeight / 2 }, ist = { x: 0, y: 0, r: 0 };
let zielVorher = ziel.x, letzte = 0, laeuft = false, aktiv = -1, erster = true;
const klemme = (v, a) => Math.max(-a, Math.min(a, v));
function malen() { folger.style.transform = `translate3d(${ist.x}px, ${ist.y}px, 0) rotate(${ist.r}deg)`; }
function bild(t) {
const dt = Math.min(64, t - letzte) || FRAME; letzte = t;
if (!reduziert()) {
const k = 1 - Math.pow(1 - LERP, dt / TICK);
const v = (ziel.x - zielVorher) / (dt / FRAME); // Mausweg in px je 60-Hz-Frame
zielVorher = ziel.x;
ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k;
ist.r += (klemme(v, V_MAX) / V_MAX * ROT_MAX - ist.r) * k;
malen();
}
const ruhig = Math.abs(ziel.x - ist.x) < .1 && Math.abs(ziel.y - ist.y) < .1 && Math.abs(ist.r) < .01;
if (aktiv < 0 && ruhig) { laeuft = false; return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
function zielSetzen(x, y) {
ziel.x = x; ziel.y = y;
if (erster) { ist.x = zielVorher = x; ist.y = y; ist.r = 0; erster = false; malen(); }
}
// Ohne Folgen (reduzierte Bewegung, Tastatur): feste Stelle rechts in der Zeile
function festSetzen(i) {
const r = zeilen[i].getBoundingClientRect();
ist.x = ziel.x = zielVorher = r.left + r.width * .66; ist.y = ziel.y = Math.max(folger.offsetHeight / 2 + 12, Math.min(innerHeight - folger.offsetHeight / 2 - 12, r.top + r.height / 2));
ist.r = 0; malen();
}
function aktivieren(i) {
if (i === aktiv) return;
const richtung = aktiv < 0 || i < 0 ? 1 : (i > aktiv ? 1 : -1); // nach unten: neues Bild kommt von unten
aktiv = i;
zeilen.forEach((z, j) => z.classList.toggle('an', j === i));
liste.classList.toggle('aktiv', i >= 0);
if (i >= 0) {
folgerStapel.zeige(i, richtung); flStapel.zeige(i, richtung);
flNr.textContent = P[i].nr; flText.textContent = `${P[i].disz} · ${P[i].jahr}`;
} else {
folgerStapel.leeren(); flStapel.leeren(); erster = true;
flNr.innerHTML = ' '; flText.textContent = 'Zeile wählen';
}
folger.classList.toggle('an', i >= 0 && variante === 'zeiger');
if (i >= 0 && reduziert()) festSetzen(i);
starten();
}
// Zeiger an x/y: echte Maus und Vorführung laufen durch dieselbe Funktion
function zeigerBei(x, y) {
const i = zeilen.findIndex(z => { const r = z.getBoundingClientRect(); return y >= r.top && y < r.bottom && x >= r.left && x <= r.right; });
if (i >= 0 && !reduziert()) { zielSetzen(x, y); }
aktivieren(i);
starten();
}
const echteMaus = e => e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung');
liste.addEventListener('pointermove', e => { if (echteMaus(e)) zeigerBei(e.clientX, e.clientY); });
liste.addEventListener('pointerleave', e => { if (echteMaus(e)) aktivieren(-1); });
links.forEach((a, i) => {
// Tastatur: Vorschau an fester Stelle (nur :focus-visible, damit ein Mausklick sie nicht versetzt)
a.addEventListener('focus', () => { if (html.classList.contains('vorfuehrung') || !a.matches(':focus-visible')) return; aktivieren(i); festSetzen(i); });
a.addEventListener('blur', () => { if (!html.classList.contains('vorfuehrung') && offen < 0 && !liste.matches(':hover')) aktivieren(-1); });
a.addEventListener('click', e => { e.preventDefault(); oeffnen(i); });
});
// ---------- Touch: die Zeile in der Bildschirmmitte ist aktiv ----------
if (touch.matches && 'IntersectionObserver' in window) {
const io = new IntersectionObserver(eintraege => {
eintraege.forEach(en => { if (en.isIntersecting) { const i = zeilen.indexOf(en.target); zeilen.forEach((z, j) => z.classList.toggle('an', j === i)); liste.classList.add('aktiv'); } });
}, { rootMargin: '-50% 0px -50% 0px' });
zeilen.forEach(z => io.observe(z));
}
// ---------- Seitenwechsel zur Fallstudie: same-document View Transition ----------
let offen = -1, scrollMerk = 0;
function quelleBild(i) {
if (touch.matches || innerWidth <= 760 && variante === 'flaeche') return zeilen[i].querySelector('.daumen');
if (variante === 'flaeche' && innerWidth > 760) return flStapel.el;
return folger.classList.contains('an') ? folger : null;
}
function wechsel(tausch) {
if (document.startViewTransition) return document.startViewTransition(tausch).finished.catch(() => {});
tausch(); return Promise.resolve();
}
const vtn = (el, n) => { if (el) el.style.viewTransitionName = n; };
function oeffnen(i) {
if (offen >= 0) return;
const p = P[i], bildQ = quelleBild(i), titelQ = zeilen[i].querySelector('.name > span');
if (bildQ !== folger) vtn(folger, 'none');
vtn(bildQ, 'projekt-bild'); vtn(titelQ, 'projekt-titel');
scrollMerk = scrollY;
return wechsel(() => {
vtn(bildQ, ''); vtn(titelQ, '');
caseSeite.querySelector('[data-case-bild] img').src = `bilder/${BILD[p.id][0]}.jpg`;
caseSeite.querySelector('[data-case-bild] img').alt = BILD[p.id][1];
caseSeite.querySelector('[data-case-titel]').textContent = p.name;
caseSeite.querySelector('[data-case-nr]').textContent = p.nr;
caseSeite.querySelector('[data-case-art]').textContent = `${p.disz} · ${p.jahr}`;
caseSeite.querySelector('[data-case-satz]').textContent = TEXTE[p.id];
caseSeite.querySelector('[data-case-leistung]').textContent = p.disz;
caseSeite.querySelector('[data-case-branche]').textContent = p.branche;
caseSeite.querySelector('[data-case-jahr]').textContent = p.jahr;
caseSeite.querySelector('[data-case-zaehler]').textContent = `${p.nr} / ${String(P.length).padStart(2, '0')}`;
vtn(caseSeite.querySelector('[data-case-bild]'), 'projekt-bild');
vtn(caseSeite.querySelector('[data-case-titel]'), 'projekt-titel');
folger.classList.add('weg');
indexSeite.hidden = true; caseSeite.hidden = false; offen = i;
scrollTo(0, 0);
}).then(() => { if (!html.classList.contains('vorfuehrung')) caseSeite.querySelector('[data-zurueck]').focus({ preventScroll: true }); });
}
function schliessen() {
if (offen < 0) return;
const i = offen, cBild = caseSeite.querySelector('[data-case-bild]'), cTitel = caseSeite.querySelector('[data-case-titel]');
return wechsel(() => {
vtn(cBild, ''); vtn(cTitel, '');
caseSeite.hidden = true; indexSeite.hidden = false; folger.classList.remove('weg'); offen = -1;
scrollTo(0, scrollMerk);
const ziel = quelleBild(i) || (variante === 'zeiger' && !touch.matches ? folger : null);
if (ziel === folger) { folger.classList.add('an'); }
vtn(ziel, 'projekt-bild'); vtn(zeilen[i].querySelector('.name > span'), 'projekt-titel');
}).then(() => {
zeilen.forEach(z => { vtn(z.querySelector('.name > span'), ''); vtn(z.querySelector('.daumen'), ''); });
vtn(folger, ''); vtn(flStapel.el, '');
if (!html.classList.contains('vorfuehrung')) {
links[i].focus({ preventScroll: true });
folger.classList.toggle('an', aktiv >= 0 && variante === 'zeiger'); // Maus nicht mehr über der Liste: Vorschau geht
}
});
}
caseSeite.querySelector('[data-zurueck]').addEventListener('click', schliessen);
addEventListener('keydown', e => { if (e.key === 'Escape') schliessen(); });
// ---------- Varianten ----------
function setzeVariante(v) {
variante = v;
document.body.dataset.variante = v;
document.querySelectorAll('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
aktivieren(-1);
zeigeWerte();
}
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-wahl]'); if (!b) return;
pause(); html.classList.remove('vorfuehrung'); setzeVariante(b.dataset.wahl);
});
function zeigeWerte() {
const seite = '<span>Seitenwechsel <b>View Transition 1000 ms expo.out</b></span>';
if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>feste Stelle, Überblenden 150 ms</b></span><span>Intro <b>aus</b></span>'; return; }
werte.innerHTML = variante === 'zeiger'
? `<span>Folgen <b>Lerp 1/12 je Tick (120 Hz)</b></span><span>Kippen <b>bis ±60° bei 100 px/Frame</b></span><span>Bild <b>256 × 320 (4:5)</b></span><span>Ein/Aus <b>200 ms</b></span><span>Wechsel <b>±100 % → 0, 700 ms</b></span>${seite}`
: `<span>Wechsel <b>±100 % → 0, 700 ms expo.out</b></span><span>Richtung <b>aus dem Zeilenwechsel</b></span><span>Aus <b>200 ms</b></span><span>Nachbarn <b>30 % Deckkraft</b></span>${seite}`;
}
// ---------- Intro ----------
function intro() {
indexSeite.classList.remove('auftritt'); void indexSeite.offsetWidth;
if (!reduziert()) indexSeite.classList.add('auftritt');
}
// ---------- Vorführung: ein gedachter Zeiger fährt die Liste ab, klickt, kehrt zurück ----------
const RUNDE = 16000;
let uhr = null, start = 0, pAlt = 0, pPause = null;
const mitteVon = (i, fx = .3) => { const r = zeilen[i].getBoundingClientRect(); return { x: r.left + r.width * fx, y: r.top + r.height / 2 }; };
const ereignisse = [
{ bei: 0, tun: () => { if (offen >= 0) schliessen(); setzeVariante('zeiger'); intro(); } },
{ bei: 6200, tun: () => { zeiger.classList.add('drueckt'); setTimeout(() => zeiger.classList.remove('drueckt'), 160); oeffnen(3); } },
{ bei: 9300, tun: () => { zeiger.classList.add('drueckt'); setTimeout(() => zeiger.classList.remove('drueckt'), 160); schliessen(); } },
{ bei: 10600, tun: () => setzeVariante('flaeche') },
];
function zeigerPos(p) {
const w = innerWidth, h = innerHeight, n = zeilen.length - 1;
const aus = { x: w * .84, y: h * 1.02 };
if (offen >= 0 || (p > 6200 && p < 10200)) {
// auf der Fallstudie: zum Zurück-Knopf
const z = caseSeite.querySelector('[data-zurueck]').getBoundingClientRect();
if (offen < 0 && p > 9300) return mitteVon(3, .42);
const q = Math.min(1, Math.max(0, (p - 7400) / 1200)), e = 1 - Math.pow(1 - q, 3);
const a = { x: w * .42, y: h * .45 }, b = { x: z.left + z.width / 2, y: z.top + z.height / 2 };
return { x: a.x + (b.x - a.x) * e, y: a.y + (b.y - a.y) * e };
}
if (p < 500 || (p > 10200 && p < 10900) || p > 15300) return aus;
if (p < 5400) { // Variante „Am Zeiger“: Zickzack über die Liste, schnelle Querbewegung kippt das Bild
const q = (p - 500) / 4900;
const s = 0.5 - 0.5 * Math.cos(q * Math.PI);
const zeile = s * n * 1.0;
const a = mitteVon(0), b = mitteVon(n);
return { x: w * (.42 + .2 * Math.sin(q * Math.PI * 4.5)), y: a.y + (b.y - a.y) * (zeile / n) };
}
if (p < 6200) { // auf „Gefüge“ einschwenken
const q = (p - 5400) / 800, e = 1 - Math.pow(1 - q, 3);
const von = zeigerPos(5399), zu = mitteVon(3, .42);
return { x: von.x + (zu.x - von.x) * e, y: von.y + (zu.y - von.y) * e };
}
// Variante „Feste Fläche“: langsam abwärts, dann zurück nach oben
const q = (p - 10900) / 4400;
const s = q < .7 ? q / .7 : 1 - (q - .7) / .3 * .6;
const a = mitteVon(0, .25), b = mitteVon(n, .25);
return { x: a.x + 40 * Math.sin(q * Math.PI * 2), y: a.y + (b.y - a.y) * s };
}
function schritt(t) {
const p = (t - start) % RUNDE;
if (p < pAlt) ereignisse.forEach(e => e.fertig = false);
ereignisse.forEach(e => { if (!e.fertig && p >= e.bei) { e.fertig = true; e.tun(); } });
pAlt = p;
const { x, y } = zeigerPos(p);
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
if (offen < 0 && !(p > 6200 && p < 10400)) zeigerBei(x, y);
uhr = requestAnimationFrame(schritt);
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
if (offen >= 0) schliessen();
ereignisse.forEach(e => e.fertig = false); pAlt = 0;
start = performance.now() - (pPause ?? 0); pPause = null;
uhr = requestAnimationFrame(schritt);
}
function pause() { if (uhr) { cancelAnimationFrame(uhr); uhr = null; pPause = (performance.now() - start) % RUNDE; } }
function weiter() {
if (uhr || !html.classList.contains('vorfuehrung')) return;
const p = pPause ?? 0;
html.classList.add('vorfuehrung');
start = performance.now() - p; pPause = null; pAlt = p;
uhr = requestAnimationFrame(schritt);
}
function neustart() {
if (uhr) cancelAnimationFrame(uhr); uhr = null; pPause = null;
html.classList.remove('vorfuehrung');
if (offen >= 0) { caseSeite.hidden = true; indexSeite.hidden = false; folger.classList.remove('weg'); offen = -1; }
setzeVariante(variante);
intro();
}
zeigeWerte();
intro();
addEventListener('load', zeigeWerte); // ?reduziert setzt der Rahmen erst nach diesem Skript
})();
</script>
</body>
</html>