Bild-Reveal per Clip-Path/Maske
Bilder öffnen sich wie eine Blende: die Maske wächst, das Bild darin zoomt gegenläufig zurück. Tiefe ohne 3D.
- Bilder starten beschnitten: als schmaler Schlitz in der Mitte, als Vorhang von unten oder als kleine Kapsel.
- Beim Scrollen öffnet sich die Maske auf volle Größe.
- Gleichzeitig zoomt das Bild darin von 1,25 auf 1 zurück, diese Gegenbewegung erzeugt Tiefe.
- Entweder an den Scroll gekoppelt (die Maske folgt dem Daumen) oder einmalig beim Eintritt, mit langer, stark ausklingender Kurve.
Wie das Öffnen einer Blende oder eines Vorhangs: gerichteter und hochwertiger als ein Einblenden. Das Bild entsteht, statt nur aufzutauchen. clip-path ist auf ausgezeichneten Seiten das Reveal-Werkzeug Nummer eins (Messung: Lando Norris 130 Elemente, Obys 40).
Wann einsetzen
Hero- und Projektbilder, Editorial-Strecken, Fallstudien, Galerien mit wenigen großen Bildern.
Wann nicht
Bei Logos, kleinen Vorschaubildern und Icons. Wenn viele Bilder gleichzeitig im Bild stehen, wird es unruhig: dann gestaffelt oder gar nicht.
Varianten
- Schlitz aus der Mitte (inset symmetrisch)
- Vorhang von unten/links (inset einseitig)
- Abgerundete Kapsel wächst zum Rechteck (inset(... round 999px) → round 0)
- Polygon-Diagonale / Wipe
- SVG-Maske mit Form (Kreis, Logo-Silhouette)
- Gestaffeltes Raster: Kacheln öffnen nacheinander
Umsetzung
Rahmen mit overflow: hidden, darauf clip-path; das Bild darin bekommt scale. Gekoppelt rein in CSS: .rahmen { animation: oeffnen linear both; animation-timeline: view(); animation-range: entry 10% cover 48% } und @keyframes oeffnen { from { clip-path: var(--start) } to { clip-path: var(--ende) } }. Das in @supports (animation-timeline: view()) setzen, sonst bleibt das Bild offen. Einmalig: IntersectionObserver setzt bei 33 % Sichtbarkeit eine Klasse, clip-path und scale laufen per transition 1,6 s expo.out. Mit GSAP: gsap.fromTo(el, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 1.6, ease: 'expo.out', scrollTrigger: { trigger: el, start: 'top 80%' } }).
Fallstrick: Start- und Endform müssen gleich aufgebaut sein, sonst wird nicht interpoliert. inset mit round braucht round auf beiden Seiten, Prozentwerte überall.
Robust umsetzen
Telefon. Funktioniert unverändert. Bereich früher beginnen lassen (entry 10 %), damit Bilder auf kurzen Bildschirmen nicht halb verdeckt stehen; Hochformat-Ausschnitt nutzen.
Ohne Bewegung. Bild sofort ganz zeigen, höchstens 200–300 ms einblenden.
Barrierefrei. Alt-Text bleibt unverändert. Das Bild darf nie verdeckt bleiben, wenn die Animation nicht läuft: Startzustand nur innerhalb von @supports bzw. per JavaScript-Klasse setzen.
Code
<!DOCTYPE html>
<!--
Bild-Reveal per Clip-Path/Maske
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/clip-path-bild-reveal/
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-Reveal per Clip-Path · 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 {
--grund: #121110; --text: #f1eee7; --leise: rgba(241, 238, 231, .56); --linie: rgba(241, 238, 231, .14);
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--dauer: 1.6s;
}
* { 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; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.kopf { min-height: 72svh; display: grid; align-content: space-between; padding: clamp(20px, 4vw, 48px); }
.kopf__zeile { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.titel { font-size: clamp(44px, 8.6vw, 132px); font-weight: 600; letter-spacing: -.045em; line-height: .92; max-width: 10ch; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:disabled { opacity: .35; cursor: not-allowed; }
.strecke { display: grid; gap: clamp(48px, 10vh, 120px); padding: 4vh clamp(20px, 4vw, 48px) 30vh; }
.feld { display: grid; gap: 14px; width: min(100%, 72%); }
.feld:nth-child(2) { justify-self: end; width: min(100%, 56%); }
.feld:nth-child(3) { justify-self: center; width: min(100%, 64%); }
.feld figcaption { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.feld figcaption b { color: var(--text); font-weight: 500; }
/* Rahmen schneidet, Bild darin zoomt gegenläufig zurück */
.rahmen { aspect-ratio: 3 / 2; overflow: hidden; clip-path: var(--ende); }
.rahmen img { display: block; width: 100%; height: 100%; object-fit: cover; }
.schlitz { --start: inset(45% 20% 45% 20% round 0px); --ende: inset(0% 0% 0% 0% round 0px); }
.vorhang { --start: inset(100% 0% 0% 0% round 0px); --ende: inset(0% 0% 0% 0% round 0px); }
.kapsel { --start: inset(30% 32% 30% 32% round 999px); --ende: inset(0% 0% 0% 0% round 14px); }
/* A) An den Scroll gekoppelt (CSS scroll-driven animations, ohne JS) */
@supports (animation-timeline: view()) {
.gekoppelt .rahmen { animation: oeffnen linear both; animation-timeline: view(); animation-range: entry 10% cover 48%; }
.gekoppelt .rahmen img { animation: entzoomen linear both; animation-timeline: view(); animation-range: entry 0% cover 55%; }
}
@keyframes oeffnen { from { clip-path: var(--start); } to { clip-path: var(--ende); } }
@keyframes entzoomen { from { scale: 1.25; } to { scale: 1; } }
/* B) Einmalig beim Eintritt (Klasse .offen per IntersectionObserver) */
.einmalig .rahmen { clip-path: var(--start); transition: clip-path var(--dauer) var(--kurve); }
.einmalig .rahmen img { scale: 1.25; transition: scale calc(var(--dauer) + .2s) var(--kurve); }
.einmalig .offen .rahmen { clip-path: var(--ende); }
.einmalig .offen .rahmen img { scale: 1; }
/* Reduzierte Bewegung: Bild sofort ganz, nur kurz einblenden */
.reduziert .rahmen, .reduziert .rahmen img { animation: none !important; clip-path: none !important; scale: 1 !important; transition: none !important; }
.reduziert .feld { opacity: 0; transition: opacity .25s linear; }
.reduziert .feld.offen { opacity: 1; }
.vorschau body { overflow: hidden; }
@media (max-width: 640px) { .rahmen { aspect-ratio: 4 / 5; } .kopf { min-height: 60svh; } .feld, .feld:nth-child(n) { width: 100%; } }
</style>
</head>
<body>
<header class="kopf">
<div class="kopf__zeile">
<p class="mono">Scroll · Clip-Path</p>
<div class="modi mono" role="group" aria-label="Auslöser">
<button type="button" data-modus="gekoppelt" aria-pressed="true">An Scroll gekoppelt</button>
<button type="button" data-modus="einmalig" aria-pressed="false">Einmalig</button>
</div>
</div>
<h1 class="titel">Bilder, die sich öffnen.</h1>
<p class="mono">Weiter scrollen ↓</p>
</header>
<main class="strecke" data-strecke>
<figure class="feld">
<div class="rahmen schlitz"><picture><source media="(max-width: 640px)" srcset="bilder/huegel-nebel-hoch.jpg"><img src="bilder/huegel-nebel.jpg" alt="Hügelketten im Morgennebel, gestaffelt in Mauve und Rosé, darüber eine blasse Sonne" width="1200" height="800"></picture></div>
<figcaption class="mono"><span>01 · Schlitz aus der Mitte</span><span><b>inset(45% 20%)</b> → 0 · Bild 1,25 → 1</span></figcaption>
</figure>
<figure class="feld">
<div class="rahmen vorhang"><picture><source media="(max-width: 640px)" srcset="bilder/beton-treppe-hoch.jpg"><img src="bilder/beton-treppe.jpg" alt="Schwebende Betontreppe an heller Wand, Schatten von Blättern im Streiflicht" width="1200" height="800"></picture></div>
<figcaption class="mono"><span>02 · Vorhang von unten</span><span><b>inset(100% 0 0 0)</b> → 0</span></figcaption>
</figure>
<figure class="feld">
<div class="rahmen kapsel"><picture><source media="(max-width: 640px)" srcset="bilder/flakon-hoch.jpg"><img src="bilder/flakon.jpg" alt="Bernsteinfarbener Flakon auf hellen Sockeln vor kräftigem Kobaltblau" width="1200" height="800"></picture></div>
<figcaption class="mono"><span>03 · Kapsel wird Fläche</span><span><b>inset(30% 32% round 999px)</b> → round 14px</span></figcaption>
</figure>
</main>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const kannKoppeln = CSS.supports('animation-timeline: view()');
const felder = [...document.querySelectorAll('.feld')];
let modus = kannKoppeln ? 'gekoppelt' : 'einmalig';
// Einmalig: Klasse .offen, sobald ein Drittel des Bilds im Viewport ist
const io = new IntersectionObserver(eintraege => eintraege.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('offen'); io.unobserve(e.target); }
}), { threshold: 0.33 });
function setzen(neu) {
modus = neu;
html.classList.toggle('gekoppelt', modus === 'gekoppelt');
html.classList.toggle('einmalig', modus === 'einmalig');
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
felder.forEach(f => { f.classList.remove('offen'); io.observe(f); });
}
document.querySelectorAll('[data-modus]').forEach(b => {
if (b.dataset.modus === 'gekoppelt' && !kannKoppeln) { b.disabled = true; b.title = 'Dieser Browser kann noch keine scroll-gekoppelten CSS-Animationen'; }
b.addEventListener('click', () => { pause(); setzen(b.dataset.modus); });
});
setzen(modus);
// Vorführung: die Seite scrollt von selbst hinunter und springt wieder hoch
let uhr = null, t0 = 0, vorher = 0;
const RUNDE = 9000;
function vorfuehren() {
pause(); scrollTo(0, 0); setzen(modus);
t0 = performance.now(); vorher = 0;
const schritt = t => {
const p = ((t - t0) % RUNDE) / RUNDE;
if (p < vorher) setzen(modus); // neue Runde: Bilder wieder geschlossen
vorher = p;
const max = document.documentElement.scrollHeight - innerHeight;
const q = Math.min(1, Math.max(0, (p - 0.06) / 0.84));
scrollTo(0, max * 0.88 * (0.5 - 0.5 * Math.cos(q * Math.PI)));
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; }
function neustart() { pause(); scrollTo(0, 0); setzen(modus); }
})();
</script>
</body>
</html>