Zum Inhalt
← Scroll & Section-Animationen

Medien-Expansion beim Scrollen (Bild/Video wächst zum Vollbild)

Ein kleines Bild zwischen zwei Wörtern wächst beim Scrollen zum randlosen Vollbild, die Wörter weichen der Kante aus.

  1. Ein Bild oder Video steht zunächst klein im Layout, etwa mit 30 bis 40 % Breite zwischen zwei Wörtern einer Headline oder als Kachel einer Galerie.
  2. Die Section wird gepinnt (sticky).
  3. Beim Scrollen wächst das Medium auf volle Viewportgröße, der Eckenradius geht auf 0, die umgebende Typo fährt seitlich weg.
  4. Am Ende steht das Medium randlos, die nächste Section setzt darunter an.

Klarer Übergang von Überblick zu Eintauchen. Der Nutzer zieht das Bild selbst heran, wie bei einem Kamera-Zoom: Kontrolle und Spannungsaufbau in einem. Typo-Hero und Bild-Hero verbinden sich in einer Bewegung.

Wann einsetzen

Erster Übergang vom Typo-Hero zum Showreel, Produktenthüllung, Einstieg in eine Fallstudie, Architektur- und Fotoportfolios.

Wann nicht

Wenn das Medium wenig Auflösung hat oder im Vollbild nicht trägt. Auf Seiten ohne starkes Bildmaterial, auf sehr kurzen Seiten und nicht mehrfach auf derselben Seite.

Varianten

  • Bild zwischen zwei Headline-Wörtern, die auseinanderfahren
  • Aus der Galerie: eine Kachel wächst, die Nachbarkacheln weichen aus
  • Zoom-out: das Vollbild schrumpft zur Karte
  • Bild in Typografie: Text als Maske, die sich öffnet
  • Gerahmtes Video im Hero öffnet sich beim ersten Scrollen; Ton erst im Vollbild mit Hinweis
  • Scrollytelling-Intro mit Zoom durch ein Fenster oder Objekt

Umsetzung

Pin-Container 200 bis 300vh hoch, innen eine sticky Bühne mit 100svh. Das Medium in Endgröße (100vw × 100svh) positionieren und nur beschneiden oder skalieren, nie width/height animieren. Clip-Path: inset(a b round 1rem) → inset(0 round 0), dazu das innere <img>/<video> gegenläufig von scale 1,2 auf 1. Scale: Start mit scale(0,22 bis 0,35), border-radius durch scale teilen, damit die Ecke optisch gleich bleibt. Kurve immer linear (scrub). GSAP: Timeline mit scrollTrigger { pin: true, scrub: true, end: '+=150%' }, Headline-Wörter gleichzeitig per xPercent nach außen. CSS-only geht mit animation-timeline: view() und animation-range: contain 0% contain 100% (Chrome, Safari 26+); Firefox bekommt den statischen Endzustand. Text nie mitskalieren, sonst wird er unscharf. Video: preload="metadata" und poster setzen.

Robust umsetzen

Telefon. Im Hochformat breiter starten (60 bis 80 %), kürzere Pin-Strecke, Wörter über und unter dem Bild statt daneben. Video gegebenenfalls durch ein Standbild ersetzen. Alternativ ohne Pin als einfache Einblendung.

Ohne Bewegung. Medium direkt in Endgröße zeigen, ohne Pinning. Headline als statischer Text darüber oder als Bildunterschrift.

Barrierefrei. Headline als echter Text, getrennt vom Medium. Video nie automatisch mit Ton starten und eine Pause-Bedienung anbieten. Das Pinning darf keine Inhalte verdecken; die Bühne endet, bevor die nächste Section beginnt.

Code

<!DOCTYPE html>
<!--
  Medien-Expansion beim Scrollen (Bild/Video wächst zum Vollbild)
  Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/medien-expansion-scroll/
  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>Medien-Expansion beim Scrollen · 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'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  --grund: #121110; --text: #f1eee7; --leise: rgba(241, 238, 231, .56); --linie: rgba(241, 238, 231, .18);
  --tinte: #151413; --akzent: #2fb3b8;
  --rand: clamp(20px, 3.4vw, 48px);
}
* { 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 { background: var(--grund); overflow-x: clip; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }

/* Spur: 300vh hoch, innen klebt die Bühne (100svh). Die Expansion läuft über 80 % der Strecke. */
.spur { position: relative; height: 300vh; }
.buehne { position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden; }

/* Das Medium liegt immer in Endgröße (Vollbild) und wird nur beschnitten bzw. skaliert */
.medium { position: absolute; inset: 0; overflow: hidden; background: #e7e7e7; will-change: clip-path, transform; }
.medium img { display: block; width: 100%; height: 100%; object-fit: cover; will-change: transform; }
.medium picture { display: contents; }

/* Bildunterschrift erscheint erst im Vollbild */
.auf { position: absolute; left: var(--rand); right: var(--rand); bottom: var(--rand); display: flex; justify-content: space-between; align-items: end; gap: 24px; color: var(--tinte); opacity: 0; pointer-events: none; }
.auf__zeile { font: 400 italic clamp(26px, 2.9vw, 46px)/1.05 'Instrument Serif', Georgia, serif; letter-spacing: -.01em; }
.auf .mono { color: rgba(21, 20, 19, .62); text-align: right; }
.auf .mono b { color: var(--tinte); font-weight: 600; }

/* Headline: zwei Wörter links und rechts des Mediums, echter Text, nie mitskaliert */
.titel { position: absolute; inset: 0; pointer-events: none; font-weight: 600; font-size: clamp(42px, 7.6vw, 132px); letter-spacing: -.045em; line-height: 1; }
.wort { position: absolute; top: 50%; white-space: nowrap; will-change: transform; }
.wort--l { right: 50%; }
.wort--r { left: 50%; font: 400 italic 1.1em/1 'Instrument Serif', Georgia, serif; letter-spacing: -.02em; }

/* Galerie-Kacheln (nur Variante „Aus der Galerie“) */
.kacheln { position: absolute; inset: 0; pointer-events: none; display: none; }
.kachel { position: absolute; left: 50%; top: 50%; border-radius: 16px; overflow: hidden; will-change: transform, opacity; }
.kachel img { width: 100%; height: 100%; object-fit: cover; display: block; }
.galerie .kacheln { display: block; }
.galerie .titel { display: none; }

/* Bedienung und Werte: mix-blend-mode hält sie auf dunklem Grund und hellem Bild lesbar */
.ui { position: absolute; inset: 0; pointer-events: none; mix-blend-mode: difference; color: #fff; }
.ui .mono { color: rgba(255, 255, 255, .7); }
.ui__oben, .ui__unten { position: absolute; left: var(--rand); right: var(--rand); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.ui__oben { top: var(--rand); align-items: flex-start; }
.ui__links { display: grid; gap: 14px; }
.ui__unten { bottom: var(--rand); align-items: end; transition: opacity 150ms linear; }
.marke { color: #fff !important; }
.modi { display: flex; gap: 4px; pointer-events: auto; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(255, 255, 255, .7); background: none; border: 1px solid rgba(255, 255, 255, .28); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: background-color 150ms linear, color 150ms linear; }
.modi button:hover { color: #fff; }
.modi button[aria-pressed='true'] { background: #fff; color: #000; border-color: #fff; }
.modi button:focus-visible { outline: 1px solid #fff; outline-offset: 3px; }
.werte { display: block; line-height: 1.6; font-variant-numeric: tabular-nums; white-space: nowrap; font-size: 10px; }
.werte b { color: #fff; font-weight: 500; }
.werte span { display: block; }
.hinweis { transition: opacity 150ms linear; }

/* Folge-Section setzt nach der Strecke an */
.folge { position: relative; padding: clamp(72px, 14vh, 160px) var(--rand) clamp(80px, 16vh, 180px); display: grid; grid-template-columns: repeat(12, 1fr); gap: 24px; border-top: 1px solid var(--linie); }
.folge h2 { grid-column: 1 / 8; font-size: clamp(30px, 3.8vw, 58px); font-weight: 500; letter-spacing: -.035em; line-height: 1.04; }
.folge h2 em { font: 400 italic 1.08em 'Instrument Serif', Georgia, serif; color: var(--akzent); }
.folge p { grid-column: 8 / 13; font-size: 17px; line-height: 1.55; color: var(--leise); align-self: end; max-width: 42ch; }
.fakten { grid-column: 1 / 13; display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; margin-top: clamp(40px, 8vh, 96px); padding-top: 20px; border-top: 1px solid var(--linie); }
.fakten dd { margin: 8px 0 0; font: 500 17px/1.4 'Instrument Sans', system-ui, sans-serif; letter-spacing: -.01em; text-transform: none; color: var(--text); }

/* Reduzierte Bewegung: Medium sofort in Endgröße, Bühne nicht gepinnt, nur kurzes Einblenden */
.reduziert .spur { height: auto; }
.reduziert .buehne { position: relative; animation: einblenden .25s linear both; }
.reduziert .titel, .reduziert .kacheln, .reduziert .ui__unten, .reduziert .werte { display: none; }
.reduziert .medium, .reduziert .medium img { clip-path: none !important; transform: none !important; border-radius: 0 !important; }
.reduziert .auf { opacity: 1 !important; }
@keyframes einblenden { from { opacity: 0; } }

.vorschau body { overflow: hidden; }
.vorschau .folge { display: none; }

@media (max-width: 640px) {
  .wort { top: auto; left: 50% !important; right: auto !important; }
  .titel { font-size: clamp(40px, 12.5vw, 64px); }
  .ui__oben { flex-direction: column; align-items: flex-start; gap: 10px; }
  .marke span, .werte span + span { display: none; }
  .auf { flex-direction: column; align-items: flex-start; }
  .auf .mono { text-align: left; }
  .folge { grid-template-columns: 1fr; }
  .folge h2, .folge p, .fakten { grid-column: 1; }
  .fakten { grid-template-columns: 1fr; gap: 16px; }
}
@media (hover: none) { .modi button { height: 34px; } }
</style>
</head>
<body>
<section class="spur" data-spur aria-label="Case Gefüge">
  <div class="buehne">
    <div class="kacheln" aria-hidden="true">
      <div class="kachel"><img src="bilder/keramik-stillleben.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/typo-plakat.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/fenster-licht.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/marke-eselsohr.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/flakon.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/stoff-stapel.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/beton-treppe.jpg" alt="" width="1200" height="800"></div>
      <div class="kachel"><img src="bilder/huegel-nebel.jpg" alt="" width="1200" height="800"></div>
    </div>

    <figure class="medium" data-medium>
      <picture>
        <source media="(max-width: 640px)" srcset="bilder/magazin-doppelseite-hoch.jpg">
        <img src="bilder/magazin-doppelseite.jpg" alt="Gefüge Heft 06, aufgeschlagen: links ein Keramik-Stillleben auf Orange, rechts die Überschrift Die Ruhe der Dinge" width="1200" height="800">
      </picture>
      <figcaption class="auf" data-auf>
        <span class="auf__zeile">Ein Heft über Dinge, das man nicht weglegt.</span>
        <span class="mono"><b>Gefüge 06</b><br>Editorial · 64 Seiten</span>
      </figcaption>
    </figure>

    <h1 class="titel"><span class="wort wort--l" data-wort="l">Papier</span> <span class="wort wort--r" data-wort="r">bleibt.</span></h1>

    <div class="ui">
      <div class="ui__oben">
        <div class="ui__links">
          <p class="mono marke">Studio Falz<span> · Editorial Design, Köln</span></p>
          <p class="mono werte" data-werte aria-hidden="true"></p>
        </div>
        <div class="modi mono" role="group" aria-label="Variante">
          <button type="button" data-variante="woerter" aria-pressed="true">Zwischen Wörtern</button>
          <button type="button" data-variante="galerie" aria-pressed="false">Aus der Galerie</button>
          <button type="button" data-variante="zoomout" aria-pressed="false">Zoom-out</button>
        </div>
      </div>
      <div class="ui__unten">
        <p class="mono">Case 03 · Gefüge</p>
        <p class="mono hinweis" data-hinweis>Scrollen ↓</p>
      </div>
    </div>
  </div>
</section>

<section class="folge">
  <h2>Ein Magazin für Leute, die Dinge <em>ansehen</em> und nicht nur besitzen.</h2>
  <p>Für Gefüge haben wir Raster, Titel und Bildsprache entwickelt. Jede Ausgabe stellt eine Werkstatt vor, mit Interviews, Stillleben aus dem Studio und Materialproben auf Naturpapier.</p>
  <dl class="fakten mono">
    <div><dt>Auftrag</dt><dd>Konzept, Layout, Titel</dd></div>
    <div><dt>Umfang</dt><dd>64 Seiten, Rückstichheftung</dd></div>
    <div><dt>Jahr</dt><dd>2026</dd></div>
  </dl>
</section>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const spur = document.querySelector('[data-spur]');
  const medium = document.querySelector('[data-medium]');
  const bild = medium.querySelector('img');
  const auf = document.querySelector('[data-auf]');
  const wortL = document.querySelector('[data-wort="l"]');
  const wortR = document.querySelector('[data-wort="r"]');
  const kacheln = [...document.querySelectorAll('.kachel')];
  const werteEl = document.querySelector('[data-werte]');
  const unten = document.querySelector('.ui__unten');
  const knoepfe = [...document.querySelectorAll('[data-variante]')];

  // Werte (auch im Bau-Block): linear, scrollgekoppelt (scrub, ease none)
  const W = {
    strecke: 0.8,          // Anteil der Spur, über den das Medium wächst; Rest: Vollbild steht
    radius: 16,            // 1rem → 0 (Fantasy)
    bildStart: 1.2,        // inneres Bild 1,2 → 1, gegenläufig
    breiteDesktop: 0.34,   // Startbreite 34 % des Viewports
    breiteMobil: 0.72,     // Startbreite 72 % auf schmalen Viewports
    kachelDesktop: 0.22,   // Galerie: Startgröße = Kachel, scale 0,22
    kachelMobil: 0.5,
    luft: 24,              // Abstand Kachel/Wort zum Medium in px
    extra: 0.6             // Wörter fahren zusätzlich ±60 % ihrer Breite nach außen
  };
  const VARIANTEN = ['woerter', 'galerie', 'zoomout'];
  let variante = 'woerter';
  const klemme = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
  const zahl = (v, n = 2) => v.toFixed(n).replace('.', ',');
  const SPALTEN = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]];

  function render() {
    const vw = innerWidth, vh = spur.querySelector('.buehne').clientHeight;
    const schmal = vw <= 640;
    let p = 0;
    if (!reduziert()) {
      const r = spur.getBoundingClientRect();
      p = klemme(-r.top / Math.max(1, r.height - vh));
    }
    const lauf = klemme(p / W.strecke);
    let e = variante === 'zoomout' ? 1 - lauf : lauf;   // e = Expansion 0 → 1
    if (reduziert()) e = 1;

    const aufSicht = klemme((e - 0.82) / 0.18);
    auf.style.opacity = String(aufSicht);
    unten.style.opacity = String(Math.min(1 - klemme(p / 0.12), 1 - aufSicht));
    const zeilen = [];

    if (variante === 'galerie') {
      // Scale-Variante: Medium in Endgröße, uniform skaliert; Radius gegen die Skalierung ausgeglichen
      const s0 = schmal ? W.kachelMobil : W.kachelDesktop;
      const s = s0 + (1 - s0) * e;
      medium.style.clipPath = 'none';
      medium.style.transform = `scale(${s})`;
      medium.style.borderRadius = `${(W.radius * (1 - e)) / s}px`;
      bild.style.transform = 'none';
      const kw = vw * s0, kh = vh * s0, cw = vw * s, ch = vh * s;
      kacheln.forEach((k, i) => {
        const [sx, sy] = SPALTEN[i];
        const x = sx * (cw / 2 + W.luft + kw / 2), y = sy * (ch / 2 + W.luft + kh / 2);
        k.style.width = kw + 'px'; k.style.height = kh + 'px';
        k.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
        k.style.opacity = String(1 - e * 0.7);
      });
      zeilen.push(`scale ${zahl(s)} · radius ${zahl(W.radius * (1 - e), 1)} px`, `Kacheln weichen aus · Luft ${W.luft} px`);
    } else {
      // Clip-Path-Variante: inset(a b round r) → inset(0 round 0), Bild 1,2 → 1
      const w0 = vw * (schmal ? W.breiteMobil : W.breiteDesktop);
      const h0 = schmal ? Math.min(w0 * 1.25, vh * 0.52) : w0 / 1.5;
      const w = w0 + (vw - w0) * e, h = h0 + (vh - h0) * e;
      const ix = (vw - w) / 2, iy = (vh - h) / 2, rad = W.radius * (1 - e);
      medium.style.transform = 'none';
      medium.style.borderRadius = '0';
      medium.style.clipPath = `inset(${iy}px ${ix}px round ${rad}px)`;
      const b = W.bildStart - (W.bildStart - 1) * e;
      bild.style.transform = `scale(${b})`;
      // Wörter folgen der wachsenden Kante und fahren zusätzlich nach außen
      const lw = wortL.offsetWidth, rw = wortR.offsetWidth;
      if (schmal) {
        const lh = wortL.offsetHeight;
        wortL.style.transform = `translate(-50%, ${vh / 2 - h / 2 - lh - 14 - e * W.extra * lh}px)`;
        wortR.style.transform = `translate(-50%, ${vh / 2 + h / 2 + 6 + e * W.extra * lh}px)`;
        wortL.style.top = wortR.style.top = '0';
      } else {
        wortL.style.top = wortR.style.top = '';
        wortL.style.transform = `translate(${-(w / 2 + W.luft) - e * W.extra * lw}px, -54%)`;
        wortR.style.transform = `translate(${w / 2 + W.luft + e * W.extra * rw}px, -54%)`;
      }
      const pi = (v, g) => zahl((v / g) * 100, 1);
      zeilen.push(`inset ${pi(iy, vh)} % ${pi(ix, vw)} % round ${zahl(rad, 1)} px`, `Bild ${zahl(b)} · Wörter ±${Math.round(e * W.extra * 100)} %`);
    }
    werteEl.innerHTML = [`Fortschritt <b>${zahl(p)}</b> · linear`, ...zeilen].map(z => `<span>${z}</span>`).join('');
  }

  let geplant = false;
  const bitte = () => { if (!geplant) { geplant = true; requestAnimationFrame(() => { geplant = false; render(); }); } };
  addEventListener('scroll', bitte, { passive: true });
  addEventListener('resize', bitte);

  function setzen(neu) {
    variante = neu;
    html.classList.toggle('galerie', variante === 'galerie');
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
    render();
  }
  knoepfe.forEach(b => b.addEventListener('click', () => {
    const lief = !!uhr; pause(); setzen(b.dataset.variante); if (lief) vorfuehren(true);
  }));
  if (document.fonts) document.fonts.ready.then(render);
  addEventListener('load', render);
  setzen(variante);

  // Vorführung: Selbst-Scroll durch die Spur und zurück; in der Galerie-Vorschau wechseln die Varianten je Runde
  let uhr = null, t0 = 0, runde = -1;
  const RUNDE = 7600;
  const sanft = q => 0.5 - 0.5 * Math.cos(Math.PI * klemme(q));
  function vorfuehren(behalten) {
    pause();
    t0 = performance.now(); runde = -1;
    const schritt = t => {
      const n = Math.floor((t - t0) / RUNDE), p = ((t - t0) % RUNDE) / RUNDE;
      if (n !== runde) {
        runde = n;
        if (html.classList.contains('vorschau') && behalten !== true) setzen(VARIANTEN[n % VARIANTEN.length]);
      }
      // 0–8 % Start halten, bis 58 % hinein, bis 76 % Vollbild halten, bis 96 % zurück
      const q = p < 0.08 ? 0 : p < 0.58 ? sanft((p - 0.08) / 0.5) : p < 0.76 ? 1 : 1 - sanft((p - 0.76) / 0.2);
      const max = spur.offsetHeight - spur.querySelector('.buehne').clientHeight;
      scrollTo(0, spur.offsetTop + max * q);
      render();
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function neustart() { pause(); scrollTo(0, 0); setzen(variante); }

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