Bild-Zoom in der Maske
Der Rahmen steht, das Bild darin zoomt langsam: drei Varianten in drei Rezept-Welten, vom ruhigen 1.05-Zoom im Projektraster über den schrumpfenden Rahmen bei Hotelzimmern bis zur farbigen Decke im Kulturprogramm.
Projekt- und Artikelraster, Produktkacheln, Blog-Teaser: überall, wo ein Bild auf eine Unterseite führt und Ruhe wichtiger ist als Effekt.
Das Bild liegt in einem Rahmen mit overflow: hidden oder clip-path. Beim Hover bewegt sich nur das Bild darin: Es zoomt leicht von 1 auf 1.05 bis 1.1. In Varianten zieht sich der Rahmen gleichzeitig zusammen, oder eine zweite Ebene öffnet sich per clip-path und das Bild darin zoomt von 1.5 auf 1 zurück. Langsame, weiche Kurven von 0,75 bis 1,2 s erzeugen den Eindruck einer Kamerafahrt.
Die Bewegung bleibt im Bild, der Rahmen und das Layout stehen still. Das wirkt ruhig, hochwertig und filmisch. Der Effekt ist ein klassischer Qualitätsmarker in Projekt- und Artikelrastern.
Wann einsetzen
Bildraster, Projektkarten, Blog-Teaser, Produktkacheln. Fast überall, wo ein Bild auf eine Unterseite führt.
Wann nicht
Bei Logos oder Illustrationen, die bis an den Rand gehen: Der Zoom schneidet sie an. Nicht auf Bildern, die schon eine Scroll-Parallaxe haben, sonst kollidieren die Transforms.
Varianten
- Basis: Bild 1 → 1.05, Rahmen bleibt (Cuberto: 1,2 s expo.out).
- Gegenläufig: Rahmen zieht sich per clip-path inset auf 4 % zusammen, Bild zoomt auf 1.12 (Nachbau des DDD-Hotel-Hovers, Codrops BackgroundScaleHoverEffect).
- Richtung: Eine zweite Ebene öffnet sich per clip-path von der Eintrittsseite, das Bild darin zoomt von 1.5 auf 1 (Codrops HoverGrid, 0,95 s power4).
- Zweites Bild wird per clip-path über dem ersten enthüllt (Codrops DoubleImageHoverEffects).
- Innerer Wrapper mit overflow: hidden, damit das Bild auch während eines Reveals skaliert (Codrops RapidImageHoverMenu).
Umsetzung
.media { overflow: hidden; border-radius: var(--r); } .media img { scale: 1.001; transition: scale 1.2s cubic-bezier(0.16, 1, 0.3, 1); } .card:is(:hover, :focus-within) .media img { scale: 1.05; }. Gegenläufig: .media { clip-path: inset(0 round var(--r)); transition: clip-path 1.2s var(--ease); } .card:hover .media { clip-path: inset(4% round var(--r)); } .card:hover img { scale: 1.12; }. Richtung: per JS die Kante bestimmen, die dem Zeiger am nächsten liegt, clip-path ohne Transition auf diese Kante setzen, dann mit Transition auf inset(0) öffnen; beim Verlassen zur Austrittskante schließen. Hover-Absicht: 30 ms Verzögerung (setTimeout) gegen Streif-Hover in Rastern. Effekt nur unter (hover: hover) and (pointer: fine). scale statt transform verwenden, damit andere Transforms nicht kollidieren. Das Bild in Ruhe minimal überskalieren (1.001), damit keine Subpixel-Kanten blitzen.
Robust umsetzen
Telefon. Auf Touch kein Hover-Zoom. Stattdessen zoomt das Bild einmal leicht beim Scroll-Eintritt, zum Beispiel von 1.08 auf 1.
Ohne Bewegung. Kein Zoom und kein Clip-Path. Hover und Fokus wechseln nur Helligkeit oder Overlay, kurz und linear.
Barrierefrei. Den Effekt auch bei :focus-within auslösen. Der Alt-Text des Bildes bleibt unverändert. Eine zusätzliche Ebene wie die Decke mit aria-hidden versehen und ihr Bild mit leerem Alt.
Code
<!DOCTYPE html>
<!--
Bild-Zoom in der Maske
Fundus · Hover · https://cecodesign.de/lab/fundus/hover/bild-zoom-in-maske/
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>Bild-Zoom in der Maske · 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; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
:root {
--papier: #efede7; --tinte: #161614; --leise: rgba(22, 22, 20, .66); --linie: rgba(22, 22, 20, .15);
--r: 4px;
/* Basis und Gegenläufig: expo.out, 1200 ms (Cuberto, gemessen) */
--kurve: cubic-bezier(0.16, 1, 0.3, 1);
--dauer: 1.2s;
/* Richtung: power4.out, 950 ms (Codrops HoverGrid) */
--kurve-richtung: cubic-bezier(0.22, 1, 0.36, 1);
--dauer-richtung: .95s;
}
* { box-sizing: border-box; margin: 0; }
[hidden] { display: none !important; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
html:has(body[data-v="gegen"]) { background: #f3eee7; }
html:has(body[data-v="richtung"]) { background: #efede9; }
body { min-height: 100svh; }
.welt { min-height: 100svh; padding: clamp(20px, 3.6vw, 52px); 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); }
.buehne { width: min(1240px, 100%); margin-inline: auto; }
/* ── Gemeinsamer Mechanismus: Rahmen steht, Bild bewegt sich ── */
.karte a { display: block; color: inherit; text-decoration: none; outline: none; }
.rahmen {
position: relative; overflow: hidden; border-radius: var(--r);
clip-path: inset(0 round var(--r));
transition: clip-path var(--dauer) var(--kurve);
background: #d9d6ce;
}
.rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
/* minimal überskaliert, damit keine Subpixel-Kanten blitzen */
.haupt { scale: 1.001; transition: scale var(--dauer) var(--kurve), filter .15s linear; }
/* ════ Variante BASIS · Projektraster eines Ateliers: nur das Bild bewegt sich, 1 → 1.05 ════ */
.welt--basis .kopf { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 12px 32px; padding-bottom: clamp(18px, 2.6vw, 34px); }
.welt--basis .kopf h1 { grid-column: 1; font-weight: 500; font-size: clamp(30px, 4.6vw, 64px); line-height: .98; letter-spacing: -.035em; max-width: 13em; }
.welt--basis .kopf h1 em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
.welt--basis .kopf .marke { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 4px 24px; justify-content: space-between; border-bottom: 1px solid var(--linie); padding-bottom: 12px; margin-bottom: clamp(10px, 1.8vw, 22px); }
.welt--basis .raster { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(14px, 2vw, 28px); }
.welt--basis .rahmen { aspect-ratio: 4 / 5; }
.welt--basis .karte.an .haupt { scale: 1.05; }
.welt--basis .zeile { display: grid; grid-template-columns: 2.6em 1fr auto; align-items: baseline; gap: 8px; padding-top: 14px; }
.welt--basis .titel { font-size: clamp(16px, 1.45vw, 20px); font-weight: 500; letter-spacing: -.015em; }
.welt--basis .art { grid-column: 2 / -1; font-size: 14px; color: var(--leise); }
.titel span { display: inline-block; transition: translate .6s var(--kurve); }
.karte.an .titel span { translate: .3em 0; }
.karte a:focus-visible .titel { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .18em; }
.welt--basis .fuss { display: flex; justify-content: space-between; gap: 16px; padding-top: clamp(18px, 2.4vw, 30px); }
/* ════ Variante GEGENLÄUFIG · Zimmer eines Hotels: Rahmen zieht sich auf 4 % zusammen, Bild zoomt auf 1.12 ════ */
.welt--gegen {
--h-grund: #f3eee7; --h-creme: #fef2e3; --h-text: #3e3a37; --h-linie: #d5d1c8; --h-leise: #82847f; --h-holz: #6e5a47;
--r: 2px; background: var(--h-grund); color: var(--h-text); font-family: 'Mona Sans', system-ui, sans-serif;
}
.welt--gegen .h-kopf { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: 12px 40px; align-items: end; padding-bottom: clamp(20px, 2.4vw, 32px); }
.welt--gegen .h-marke { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 12px; padding-bottom: 14px; border-bottom: 1px solid var(--h-linie); font: 500 12px/1.3 'Mona Sans', sans-serif; letter-spacing: .16em; text-transform: uppercase; color: var(--h-leise); }
.welt--gegen .h-marke b { font: italic 500 20px/1 'Newsreader', Georgia, serif; letter-spacing: 0; text-transform: none; color: var(--h-text); }
.welt--gegen h1 { font: 400 clamp(30px, 3.8vw, 54px)/1.02 'Newsreader', Georgia, serif; letter-spacing: -.02em; font-optical-sizing: auto; text-wrap: balance; }
.welt--gegen h1 em { font-style: italic; }
.welt--gegen .h-intro { font-size: 16px; line-height: 1.55; color: var(--h-leise); max-width: 34ch; justify-self: end; }
.welt--gegen .raster { list-style: none; padding: 0; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 4fr); grid-template-rows: auto auto; gap: clamp(16px, 2vw, 28px) clamp(16px, 2.4vw, 32px); }
.welt--gegen .karte:first-child { grid-row: 1 / 3; }
.welt--gegen .karte:first-child .rahmen { aspect-ratio: auto; height: 100%; min-height: 320px; }
.welt--gegen .karte:first-child a { display: grid; grid-template-rows: 1fr auto; height: 100%; }
.welt--gegen .rahmen { aspect-ratio: 2 / 1; background: #e2d9cc; }
.welt--gegen .karte.an .rahmen { clip-path: inset(4% round var(--r)); }
.welt--gegen .karte.an .haupt { scale: 1.12; }
.welt--gegen .h-zeile { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding-top: 14px; }
.welt--gegen .titel { font: 400 clamp(20px, 1.8vw, 28px)/1.1 'Newsreader', Georgia, serif; letter-spacing: -.01em; }
.welt--gegen .h-preis { font-size: 14px; color: var(--h-leise); white-space: nowrap; font-variant-numeric: tabular-nums; }
.welt--gegen .h-preis b { color: var(--h-text); font-weight: 600; }
.welt--gegen .h-fakten { display: flex; gap: 6px 14px; flex-wrap: wrap; padding-top: 6px; font-size: 13px; color: var(--h-leise); }
.welt--gegen .h-pruefen { display: inline-flex; margin-top: 10px; font-size: 14px; font-weight: 500; color: var(--h-holz); border-bottom: 1px solid currentColor; padding-bottom: 2px; transition: color .4s cubic-bezier(0.37, 0, 0.63, 1); }
.welt--gegen .karte.an .h-pruefen { color: var(--h-text); }
/* ════ Variante RICHTUNG · Programm eines Kulturhauses: Decke öffnet sich von der Eintrittsseite ════ */
.welt--richtung {
--k-grund: #efede9; --k-text: #0b0b0b; --k-leise: #5d5a55; --k-linie: #0b0b0b;
--r: 0px; background: var(--k-grund); color: var(--k-text); font-family: 'Schibsted Grotesk', system-ui, sans-serif;
}
.welt--richtung .k-mono { font: 500 11px/1.3 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.welt--richtung .k-kopf { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 12px 28px; padding-bottom: 16px; border-bottom: 2px solid var(--k-linie); margin-bottom: clamp(16px, 2.2vw, 26px); }
.welt--richtung .k-kopf h1 { font: 800 clamp(40px, 6.4vw, 96px)/.86 'Schibsted Grotesk', sans-serif; letter-spacing: -.045em; text-transform: uppercase; }
.welt--richtung .k-kopf p { font-size: 15px; line-height: 1.45; max-width: 30ch; color: var(--k-leise); }
.welt--richtung .k-monat { justify-self: end; text-align: right; }
.welt--richtung .raster { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(10px, 1.4vw, 18px); }
.welt--richtung .rahmen { aspect-ratio: 1 / 1; background: var(--sparte); }
.welt--richtung .karte { --sparte: #c4421a; }
.welt--richtung .karte[data-sparte="theater"] { --sparte: #3d5f9a; }
.welt--richtung .karte[data-sparte="kunst"] { --sparte: #2f7d4a; }
.welt--richtung .karte[data-sparte="film"] { --sparte: #b0186b; }
.welt--richtung .k-zeile { display: grid; gap: 4px; padding-top: 10px; border-top: 4px solid var(--sparte); margin-top: 10px; }
.welt--richtung .k-zeile .k-mono { display: flex; justify-content: space-between; gap: 8px; color: var(--k-leise); }
.welt--richtung .k-zeile .k-mono b { color: var(--sparte); font-weight: 500; }
.welt--richtung .titel { font: 700 clamp(17px, 1.5vw, 22px)/1.08 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.welt--richtung .k-datum { position: absolute; z-index: 1; left: 10px; top: 10px; padding: 5px 7px 4px; background: var(--k-grund); color: var(--k-text); }
/* Die Decke: zweite Ebene über dem Bild, öffnet sich per clip-path von der Kante, an der der Zeiger eintritt */
.decke { position: absolute; inset: 0; z-index: 2; clip-path: inset(0 0 100% 0); }
.decke img { scale: 1.5; filter: saturate(.8) brightness(.42); }
.welt--richtung .decke { background: var(--sparte); }
.welt--richtung .decke img { mix-blend-mode: multiply; filter: grayscale(1) contrast(1.15); opacity: .85; }
.decke .ansehen {
position: absolute; left: clamp(14px, 1.4vw, 20px); right: clamp(14px, 1.4vw, 20px); bottom: clamp(12px, 1.4vw, 18px); color: #fff;
display: grid; gap: 6px;
}
.decke .ansehen b { font: 800 clamp(22px, 2.2vw, 32px)/.95 'Schibsted Grotesk', sans-serif; letter-spacing: -.03em; text-transform: uppercase; }
.decke .ansehen b::after { content: ' ↗'; font-weight: 500; }
/* Pult: Variante und Werte, dezent */
.pult { position: fixed; z-index: 30; right: 14px; bottom: 14px; display: grid; justify-items: end; gap: 8px; padding: 9px 10px; border-radius: 12px; background: rgba(250, 249, 246, .9); box-shadow: 0 0 0 1px rgba(22, 22, 20, .1), 0 12px 30px -14px rgba(22, 22, 20, .35); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); color: var(--tinte); }
.umschalter { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--linie); border-radius: 999px; }
.umschalter button {
font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase;
color: var(--leise); background: none; border: 0; border-radius: 999px; padding: 8px 12px; cursor: pointer;
transition: background-color .15s linear, color .15s linear;
}
.umschalter button:hover { color: var(--tinte); }
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.umschalter button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.werte { text-align: right; font-size: 10px; min-height: 2.6em; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 40; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .welt { padding: 40px 48px; }
.vorschau .fuss, .vorschau .h-intro { display: none; }
.vorschau .welt--gegen .karte:first-child .rahmen { min-height: 300px; }
/* Touch: kein Hover-Zoom, stattdessen leichter Zoom beim Scroll-Eintritt */
@media (hover: none) {
.haupt { scale: 1.08; }
.karte.drin .haupt { scale: 1.001; }
.welt .karte.an .haupt { scale: 1.001; }
.welt--gegen .karte.an .rahmen { clip-path: inset(0 round var(--r)); }
.decke { display: none; }
}
/* Reduzierte Bewegung: kein Zoom, nur Abdunkeln (150 ms) */
.reduziert .haupt, .reduziert .welt .karte.an .haupt, .reduziert .karte.drin .haupt { scale: 1.001; transition: filter .15s linear; }
.reduziert .rahmen, .reduziert .welt .karte.an .rahmen { clip-path: inset(0 round var(--r)); transition: none; }
.reduziert .karte.an .haupt { filter: brightness(.82); }
.reduziert .decke { display: none; }
.reduziert .titel span, .reduziert .karte.an .titel span { translate: none; transition: none; }
.reduziert .karte.an .titel { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: .18em; }
@media (max-width: 900px) {
.welt--richtung .raster { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.welt--richtung .k-kopf { grid-template-columns: 1fr auto; }
.welt--richtung .k-kopf h1 { grid-column: 1; grid-row: 1; }
.welt--richtung .k-monat { grid-column: 2; grid-row: 1; align-self: end; }
.welt--richtung .k-kopf p:not(.k-monat) { grid-column: 1 / -1; grid-row: 2; }
}
@media (max-width: 760px) {
.welt--basis .kopf { grid-template-columns: 1fr; }
.welt--basis .raster { grid-template-columns: 1fr; gap: 34px; }
.welt--basis .fuss { flex-direction: column; }
.welt--gegen .h-kopf, .welt--gegen .raster { grid-template-columns: 1fr; }
.welt--gegen .h-intro { justify-self: start; }
.welt--gegen .karte:first-child { grid-row: auto; }
.welt--gegen .karte:first-child .rahmen { aspect-ratio: 4 / 5; height: auto; min-height: 0; }
.welt--gegen .karte:first-child a { display: block; }
.welt--richtung .raster { gap: 22px 12px; }
.umschalter button { padding: 8px 9px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-v="basis">
<!-- ═══ Grundvariante: Projektraster eines Ateliers ═══ -->
<main class="welt welt--basis" data-welt="basis">
<div class="buehne">
<header class="kopf">
<div class="marke"><p class="mono">Atelier Hollerbach · Köln</p><p class="mono">Arbeiten 2019–2026</p></div>
<h1>Marken mit <em>Haltung,</em> gestaltet für den zweiten Blick.</h1>
</header>
<ul class="raster">
<li class="karte"><a href="#lehmgrube">
<div class="rahmen">
<img class="haupt" src="bilder/keramik-stillleben-hoch.jpg" alt="Handgedrehte Keramik auf hellen Sockeln vor Terrakotta: helle Flasche, schwarze Kugelvase, gelber Becher und flache Schalen" width="800" height="1000">
</div>
<div class="zeile"><span class="mono">01</span><span class="titel"><span>Lehmgrube</span></span><span class="mono">2025</span><span class="art">Erscheinungsbild einer Töpferei</span></div>
</a></li>
<li class="karte"><a href="#archivbau">
<div class="rahmen">
<img class="haupt" src="bilder/beton-treppe-hoch.jpg" alt="Freitragende Treppe an einer Sichtbetonwand, der Handlauf als schwarze Linie, darüber der Schatten eines Baums" width="800" height="1000">
</div>
<div class="zeile"><span class="mono">02</span><span class="titel"><span>Archivbau Kalk</span></span><span class="mono">2024</span><span class="art">Leitsystem und Beschriftung</span></div>
</a></li>
<li class="karte"><a href="#ambraflakon">
<div class="rahmen">
<img class="haupt" src="bilder/flakon-hoch.jpg" alt="Bernsteinfarbener Parfumflakon mit schwarzer Kappe auf weißem Podest vor kräftigem Kobaltblau" width="800" height="1000">
</div>
<div class="zeile"><span class="mono">03</span><span class="titel"><span>Ambraflakon</span></span><span class="mono">2023</span><span class="art">Verpackung einer Duftmanufaktur</span></div>
</a></li>
</ul>
<footer class="fuss">
<p class="mono">Bewegung im Bild, der Rahmen steht</p>
<p class="mono">Hover oder Tab</p>
</footer>
</div>
</main>
<!-- ═══ Variante Gegenläufig: Zimmer eines Hotels ═══ -->
<main class="welt welt--gegen" data-welt="gegen" hidden>
<div class="buehne">
<header class="h-kopf">
<p class="h-marke"><b>Hotel Lärchenhof</b><span>Kleinwalsertal · 14 Zimmer</span></p>
<h1>Drei Zimmer, <em>ein Tal</em> vor jedem Fenster.</h1>
<p class="h-intro">Holz aus dem eigenen Wald, Leinen aus dem Allgäu und morgens Nebel, der langsam aus dem Tal steigt.</p>
</header>
<ul class="raster">
<li class="karte"><a href="#turmzimmer">
<div class="rahmen"><picture><source media="(max-width: 760px)" srcset="bilder/fenster-licht-hoch.jpg"><img class="haupt" src="bilder/fenster-licht.jpg" alt="Turmzimmer am Morgen: Fensterlicht fällt schräg auf eine salbeigrüne Wand, davor eine Holzbank mit dunkler Vase und kahlen Zweigen" width="1200" height="800"></picture></div>
<div><div class="h-zeile"><span class="titel"><span>Turmzimmer</span></span><span class="h-preis">ab <b>168 €</b> / Nacht</span></div>
<p class="h-fakten"><span>24 m²</span><span>Fenster nach drei Seiten</span><span>bis 2 Gäste</span></p>
<span class="h-pruefen">Verfügbarkeit prüfen</span></div>
</a></li>
<li class="karte"><a href="#kammer">
<div class="rahmen"><img class="haupt" src="bilder/stoff-stapel.jpg" alt="Frisch gefaltetes Leinen für die Kammer: Tücher in Weiß, Rosé, Senf und Beere auf einem hellen Holztisch" width="1200" height="800"></div>
<div class="h-zeile"><span class="titel"><span>Kammer unterm Dach</span></span><span class="h-preis">ab <b>132 €</b></span></div>
<p class="h-fakten"><span>18 m²</span><span>Dachschräge</span><span>1 bis 2 Gäste</span></p>
</a></li>
<li class="karte"><a href="#suite">
<div class="rahmen"><img class="haupt" src="bilder/huegel-nebel.jpg" alt="Blick von der Suite über gestaffelte Hügelketten im Morgennebel, darüber eine blasse Sonne" width="1200" height="800"></div>
<div class="h-zeile"><span class="titel"><span>Suite Hochalp</span></span><span class="h-preis">ab <b>245 €</b></span></div>
<p class="h-fakten"><span>42 m²</span><span>Balkon zum Tal</span><span>bis 4 Gäste</span></p>
</a></li>
</ul>
</div>
</main>
<!-- ═══ Variante Richtung: Programm eines Kulturhauses ═══ -->
<main class="welt welt--richtung" data-welt="richtung" hidden>
<div class="buehne">
<header class="k-kopf">
<h1>Gleis­halle</h1>
<p>Musik, Theater, Kunst und Film im alten Lokschuppen. Eintritt für Schüler frei.</p>
<p class="k-mono k-monat">Programm<br>November 2026</p>
</header>
<ul class="raster">
<li class="karte" data-sparte="musik"><a href="#fado">
<div class="rahmen">
<img class="haupt" src="bilder/bild-zoom-in-maske-fado.svg" alt="Gitarre im Lichtkegel eines Scheinwerfers auf dunkler Bühne">
<span class="k-mono k-datum">Fr 07.11.</span>
<div class="decke" aria-hidden="true"><img src="bilder/bild-zoom-in-maske-fado.svg" alt=""><span class="ansehen"><span class="k-mono">20 Uhr · ab 18 €</span><b>Tickets</b></span></div>
</div>
<div class="k-zeile"><span class="k-mono"><b>Musik</b><span>Halle</span></span><span class="titel"><span>Fado am Gleis</span></span></div>
</a></li>
<li class="karte" data-sparte="theater"><a href="#weichenstellerin">
<div class="rahmen">
<img class="haupt" src="bilder/bild-zoom-in-maske-weichenstellerin.svg" alt="Schauspielerin in Blau auf einer Drehscheibe zwischen roten Vorhängen">
<span class="k-mono k-datum">Sa 15.11.</span>
<div class="decke" aria-hidden="true"><img src="bilder/bild-zoom-in-maske-weichenstellerin.svg" alt=""><span class="ansehen"><span class="k-mono">19:30 · ab 14 €</span><b>Tickets</b></span></div>
</div>
<div class="k-zeile"><span class="k-mono"><b>Theater</b><span>Drehscheibe</span></span><span class="titel"><span>Die Weichenstellerin</span></span></div>
</a></li>
<li class="karte" data-sparte="kunst"><a href="#rost">
<div class="rahmen">
<img class="haupt" src="bilder/bild-zoom-in-maske-rost.svg" alt="Drei rostige Stahlplatten, quer durchschnitten von einem hellen Lichtstreifen">
<span class="k-mono k-datum">bis 30.11.</span>
<div class="decke" aria-hidden="true"><img src="bilder/bild-zoom-in-maske-rost.svg" alt=""><span class="ansehen"><span class="k-mono">Di bis So · 11–18 Uhr</span><b>Ansehen</b></span></div>
</div>
<div class="k-zeile"><span class="k-mono"><b>Kunst</b><span>Galerie</span></span><span class="titel"><span>Rost und Licht</span></span></div>
</a></li>
<li class="karte" data-sparte="film"><a href="#nachtzug">
<div class="rahmen">
<img class="haupt" src="bilder/bild-zoom-in-maske-nachtzug.svg" alt="Zugfenster bei Nacht, draußen ziehen farbige Lichter als Streifen vorbei">
<span class="k-mono k-datum">Do 20.11.</span>
<div class="decke" aria-hidden="true"><img src="bilder/bild-zoom-in-maske-nachtzug.svg" alt=""><span class="ansehen"><span class="k-mono">21 Uhr · 8 €</span><b>Tickets</b></span></div>
</div>
<div class="k-zeile"><span class="k-mono"><b>Film</b><span>Lokschuppen</span></span><span class="titel"><span>Nachtzug, Kino im Schuppen</span></span></div>
</a></li>
</ul>
</div>
</main>
<aside class="pult" aria-label="Steuerung der Demo">
<div class="umschalter" role="group" aria-label="Variante">
<button type="button" data-v="basis" aria-pressed="true">Basis</button>
<button type="button" data-v="gegen" aria-pressed="false">Gegenläufig</button>
<button type="button" data-v="richtung" aria-pressed="false">Richtung</button>
</div>
<p class="mono werte" data-werte aria-live="polite"></p>
</aside>
<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="#161614" stroke="#efede7" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const welten = Object.fromEntries([...document.querySelectorAll('[data-welt]')].map(w => [w.dataset.welt, w]));
const alleKarten = [...document.querySelectorAll('.karte')];
const knoepfe = [...document.querySelectorAll('[data-v]')];
const werteEl = document.querySelector('[data-werte]');
const zeiger = document.querySelector('[data-zeiger]');
const VARIANTEN = ['basis', 'gegen', 'richtung'];
const WERTE = {
basis: 'scale 1 → 1.05 · 1200 ms<br>cubic-bezier(0.16, 1, 0.3, 1)',
gegen: 'inset 0 → 4 % · scale 1 → 1.12 · 1200 ms<br>cubic-bezier(0.16, 1, 0.3, 1)',
richtung: 'clip-path je Seite · scale 1.5 → 1 · 950 ms<br>cubic-bezier(0.22, 1, 0.36, 1)'
};
const ABSICHT_MS = 30; // Hover-Absicht gegen Streif-Hover (Codrops HoverGrid)
let variante = 'basis';
const karten = () => [...welten[variante].querySelectorAll('.karte')];
function zeigeWerte() { werteEl.innerHTML = reduziert() ? 'kein Zoom · brightness 1 → 0.82<br>150 ms linear' : WERTE[variante]; }
function setzeVariante(v, schreiben = true) {
if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
variante = v;
Object.entries(welten).forEach(([k, w]) => { w.hidden = k !== v; });
document.body.dataset.v = v;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.v === v)));
if (schreiben) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
zeigeWerte();
alleKarten.forEach(k => { k.classList.remove('an'); deckeZu(k, null, null, true); });
}
knoepfe.forEach(k => k.addEventListener('click', () => { const lief = html.classList.contains('vorfuehrung'); setzeVariante(k.dataset.v); if (lief) vorfuehren(); }));
// Richtung: nächstgelegene Kante zum Zeiger
const KLAPPE = { oben: 'inset(0 0 100% 0)', unten: 'inset(100% 0 0 0)', links: 'inset(0 100% 0 0)', rechts: 'inset(0 0 0 100%)' };
function seite(karte, x, y) {
const r = karte.querySelector('.rahmen').getBoundingClientRect();
if (x == null) return 'unten';
const d = { links: x - r.left, rechts: r.right - x, oben: y - r.top, unten: r.bottom - y };
return Object.keys(d).reduce((a, b) => (d[a] <= d[b] ? a : b));
}
function deckeAuf(karte, x, y) {
const decke = karte.querySelector('.decke');
if (!decke || reduziert()) return;
const bild = decke.querySelector('img');
decke.style.transition = bild.style.transition = 'none';
decke.style.clipPath = KLAPPE[seite(karte, x, y)];
bild.style.scale = '1.5';
decke.offsetWidth; // Startzustand festschreiben
decke.style.transition = 'clip-path var(--dauer-richtung) var(--kurve-richtung)';
bild.style.transition = 'scale var(--dauer-richtung) var(--kurve-richtung)';
decke.style.clipPath = 'inset(0 0 0 0)';
bild.style.scale = '1';
}
function deckeZu(karte, x, y, sofort) {
const decke = karte.querySelector('.decke');
if (!decke) return;
const bild = decke.querySelector('img');
if (sofort) {
decke.style.transition = bild.style.transition = 'none';
decke.style.clipPath = KLAPPE.unten; bild.style.scale = '1.5';
return;
}
decke.style.clipPath = KLAPPE[seite(karte, x, y)];
bild.style.scale = '1.5';
}
const uhren = new Map();
function rein(karte, x, y) {
clearTimeout(uhren.get(karte));
uhren.set(karte, setTimeout(() => { karte.classList.add('an'); deckeAuf(karte, x, y); }, ABSICHT_MS));
}
function raus(karte, x, y) {
clearTimeout(uhren.get(karte));
if (!karte.classList.contains('an')) return;
karte.classList.remove('an'); deckeZu(karte, x, y);
}
const fein = matchMedia('(hover: hover) and (pointer: fine)');
alleKarten.forEach(karte => {
const a = karte.querySelector('a');
a.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && fein.matches) rein(karte, e.clientX, e.clientY); });
a.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') raus(karte, e.clientX, e.clientY); });
a.addEventListener('focus', () => { karte.classList.add('an'); deckeAuf(karte, null, null); });
a.addEventListener('blur', () => raus(karte, null, null));
a.addEventListener('click', e => e.preventDefault());
});
// Touch: leichter Zoom beim Scroll-Eintritt (1.08 → 1)
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver(eintraege => eintraege.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('drin'); io.unobserve(e.target); }
}), { threshold: 0.3 });
alleKarten.forEach(k => io.observe(k));
} else alleKarten.forEach(k => k.classList.add('drin'));
// Vorführung: gedachter Zeiger fährt über die Karten der gewählten Variante
const RUNDE_MS = 7600;
let uhr = null, start = 0, unter = null, versatz = 0;
const glatt = t => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
function punkt(p) {
const ks = karten();
const r = ks.map(k => k.querySelector('.rahmen').getBoundingClientRect());
const mitte = i => ({ x: r[i].left + r[i].width * 0.5, y: r[i].top + r[i].height * 0.55 });
const n = r.length, wege = [[0, { x: r[0].left - 60, y: r[0].top + r[0].height * 0.6 }]];
const spanne = 0.86 / n;
for (let i = 0; i < n; i++) { wege.push([0.04 + i * spanne + spanne * 0.25, mitte(i)]); wege.push([0.04 + (i + 1) * spanne, mitte(i)]); }
const ende = { x: r[n - 1].left + r[n - 1].width * 0.6, y: r[n - 1].bottom + 70 };
wege.push([0.97, ende], [1, ende]);
let i = 0; while (i < wege.length - 2 && p > wege[i + 1][0]) i++;
const [p0, a] = wege[i], [p1, b] = wege[i + 1];
const q = glatt(Math.min(1, Math.max(0, (p - p0) / (p1 - p0 || 1))));
const wackeln = Math.sin(p * Math.PI * 9) * 10; // leichtes Driften beim Verweilen
return { x: a.x + (b.x - a.x) * q + wackeln, y: a.y + (b.y - a.y) * q + Math.cos(p * Math.PI * 7) * 8 };
}
function schritt(t) {
const p = ((t - start + versatz) % RUNDE_MS) / RUNDE_MS;
const { x, y } = punkt(p);
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
const k = karten().find(c => { const r = c.querySelector('.rahmen').getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; }) || null;
if (k !== unter) { if (unter) raus(unter, x, y); if (k) rein(k, x, y); unter = k; }
uhr = requestAnimationFrame(schritt);
}
function vorfuehren() {
pause(); versatz = 0; unter = null;
html.classList.add('vorfuehrung');
start = performance.now();
uhr = requestAnimationFrame(schritt);
}
function pause() { if (uhr) { cancelAnimationFrame(uhr); versatz += performance.now() - start; } uhr = null; }
function weiter() {
if (uhr || !html.classList.contains('vorfuehrung')) return;
start = performance.now(); uhr = requestAnimationFrame(schritt);
}
function neustart() {
cancelAnimationFrame(uhr); uhr = null; versatz = 0; unter = null;
html.classList.remove('vorfuehrung');
setzeVariante(variante, false);
}
// Start in der Variante aus der URL (?v=), sonst Basis
setzeVariante(new URLSearchParams(location.search).get('v'), false);
new MutationObserver(zeigeWerte).observe(html, { attributeFilter: ['class'], attributes: true });
})();
</script>
</body>
</html>