Bild/Video in Schrift (Typo-Maske)
Ein fettes Wort ist das Fenster auf ein bewegtes Bild; beim Scrollen fährt die Kamera durch den Stamm des T, bis das Bild die Bühne füllt.
Ein Bild oder Video erscheint nur innerhalb eines Wortes. Technisch geht das mit CSS mask-image (SVG des Wortes), mit background-clip: text oder mit einer SVG-<mask> bzw. <clipPath>. Als Scroll-Variante klebt die Sektion fest (sticky), und die Maske wächst von etwa 80 % auf ein Vielfaches, bis ein Buchstabe den ganzen Viewport füllt. Am Ende ist das Medium vollflächig sichtbar und leitet in die nächste Sektion über.
Typo und Bild werden zu einem Moment. Das Wort ist zugleich Titel und Fenster. Der Zoom durch den Buchstaben wirkt wie ein Filmschnitt: Aus dem Logo oder Claim wird ohne harten Wechsel das Bild selbst.
Wann einsetzen
Hero-Übergang vom Claim zum Showreel oder Kampagnenbild, Kapiteleinstiege, Produkt-Launches, Ausstellungs- und Festivalseiten. Am besten mit einem kurzen, sehr fetten Wort.
Wann nicht
Bei dünnen Schriften, weil zu wenig Fläche fürs Bild bleibt. Bei langen Wörtern und unruhigen Videos, weil das Wort unlesbar wird. Auf kleinen Viewports ohne genug Scrollraum. Nicht als Dauereffekt auf jeder Sektion.
Varianten
- Statisch: Wort als Fenster auf Bild oder Video (background-clip: text oder mask-image)
- Scroll-Zoom durch einen Buchstaben ins Vollbild (sticky + mask-size bzw. transform auf dem SVG)
- Füllung bei Hover oder Fokus: Überschriften bekommen ein Bild in die Schrift
- Bild expandiert innerhalb der Typo und schiebt den Text weg
- SVG-clip-path-Typo, die sich beim Scrollen verändert
Umsetzung
Scroll-Zoom: Wrapper height: 300vh, Kind position: sticky; top: 0; height: 100vh. Das Medium bekommt mask-image: url(wort.svg); mask-repeat: no-repeat; -webkit-mask für ältere Safari. Fortschritt p = (scrollY − start) / (Wrapperhöhe − 100vh). Ziel-mask-size von 80 % bis etwa 3000 %, pro Frame per Lerp 0,15 angenähert. Alternativ in CSS: animation-timeline: view() auf mask-size. Der Ankerpunkt (mask-position bzw. transform-origin) muss in der Fläche eines Buchstabens liegen, sonst wächst die leere Fläche. Bei einer ausgesparten Platte (Wort transparent auf Vollfläche) liegt er stattdessen im Stamm eines Buchstabens; skaliert wird das Text-SVG von 1 auf 30 oder mehr. Statisch mit Bild: background: url(bild.jpg) center / cover; background-clip: text; color: transparent. Mit Video: SVG-<mask> oder <clipPath> mit <text> über dem <video>, oder weiße Schrift mit mix-blend-mode: screen auf schwarzem Grund.
Robust umsetzen
Telefon. Funktioniert. Im Hochformat das Wort größer setzen oder drehen, damit genug Bild im Wort liegt. Video als komprimiertes, stummes playsinline-MP4, bei schwacher Verbindung ein Standbild. Scrollstrecke kürzer halten.
Ohne Bewegung. Statisches Wort mit Bildfüllung, kein Zoom. Video pausiert und zeigt das Poster.
Barrierefrei. Das maskierte Wort bleibt als echter Text erhalten (visuell versteckt oder als aria-label am SVG); die Grafik bekommt aria-hidden. Video ohne Ton oder mit Steuerung. Kontrast der Buchstabenkanten zum Hintergrund prüfen, auch an hellen Bildstellen.
Code
<!DOCTYPE html>
<!--
Bild/Video in Schrift (Typo-Maske)
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/typo-maske-medien/
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/Video in Schrift (Typo-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: '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: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; 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 {
--grund: #efede9; --text: #0b0b0b; --leise: #5b5955; --linie: #0b0b0b;
--musik: #c4421a; --theater: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
--kurve: cubic-bezier(0.215, 0.61, 0.355, 1); /* Rezept: kurz, ohne Überschwingen */
--kopf: 64px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; scroll-padding-top: var(--kopf); } /* Fokus nie unter dem klebenden Kopf */
body { background: var(--grund); }
.mono { font: 500 12px/1.4 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
#titel { color: #f6f3ee; } /* nur für Screenreader; Farbe passend zur Bühne */
a { color: inherit; }
/* Kopf: sticky, klare Unterkante */
.kopf { position: sticky; top: 0; z-index: 20; height: var(--kopf); display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 clamp(16px, 3vw, 40px); background: var(--grund); border-bottom: 1px solid var(--linie); }
.marke { font: 800 19px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-decoration: none; display: flex; align-items: baseline; gap: 8px; white-space: nowrap; }
.marke small { font: 500 11px/1 'IBM Plex Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; align-items: center; gap: clamp(12px, 2vw, 28px); list-style: none; font-size: 16px; }
.nav a { text-decoration: none; }
.nav a:hover { text-decoration: underline; text-underline-offset: 4px; }
.ticket { display: inline-flex; align-items: center; height: 36px; padding: 0 16px; background: var(--text); color: var(--grund) !important; border-radius: 999px; font-weight: 700; transition: background-color .2s var(--kurve); }
.ticket:hover { background: var(--musik); text-decoration: none !important; }
/* Strecke: Scrollraum für den Zoom; Bühne klebt darin */
.strecke { position: relative; height: 300vh; }
.fenster .strecke, .reduziert .strecke { height: auto; }
.buehne { position: sticky; top: var(--kopf); height: calc(100svh - var(--kopf)); overflow: hidden; background: #10202c; }
.fenster .buehne, .reduziert .buehne { position: relative; top: 0; }
.film { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.fallback { position: absolute; inset: 0; background: linear-gradient(#1d2d4f, #3d5f9a 30%, #d9693f 44%, #17344b 46%, #0e1d29); }
/* Platte in Grundfarbe, Wort ausgespart: wirkt wie eine Maske auf dem Bewegtbild */
.platte { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.platte text { font: 900 100px/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.035em; }
.zeile { position: absolute; left: clamp(16px, 3vw, 40px); right: clamp(16px, 3vw, 40px); display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; color: var(--text); transition: opacity .25s var(--kurve); }
.zeile--oben { top: clamp(18px, 4vh, 36px); }
.zeile--unten { bottom: clamp(18px, 4vh, 36px); }
.zeile b { font-weight: 500; }
.zeile .lead { font: 400 clamp(17px, 1.5vw, 20px)/1.45 'Atkinson Hyperlegible Next', sans-serif; letter-spacing: 0; text-transform: none; max-width: 34ch; }
.sparte { display: inline-flex; align-items: center; gap: 8px; }
.sparte::before { content: ''; width: 10px; height: 10px; border-radius: 50%; background: var(--c, var(--kunst)); }
/* Endzustand: Bewegtbild vollflächig, Titel darauf */
.ende { position: absolute; inset: 0; display: grid; align-content: end; gap: 18px; padding: clamp(20px, 4vw, 48px); color: #f6f3ee; opacity: 0; visibility: hidden; transition: opacity .3s var(--kurve), visibility 0s .3s; background: linear-gradient(transparent 45%, rgba(6, 14, 20, .55)); }
.ende.da { opacity: 1; visibility: visible; transition: opacity .3s var(--kurve), visibility 0s; }
.ende h2 { font: 900 clamp(44px, 7vw, 112px)/.96 'Schibsted Grotesk', sans-serif; letter-spacing: -.035em; max-width: 11ch; }
.ende p { font-size: clamp(17px, 1.4vw, 20px); line-height: 1.5; max-width: 46ch; }
.ende__fuss { display: flex; gap: 12px 28px; flex-wrap: wrap; align-items: center; }
.ende .ticket { background: #f6f3ee; color: var(--text) !important; }
.ende .ticket:hover { background: var(--musik); color: #fff !important; }
/* Werte und Umschalter, dezent */
.werkbank { position: absolute; z-index: 5; right: clamp(4px, calc(3vw - 12px), 28px); top: clamp(4px, calc(3.4vh - 12px), 20px); padding: 12px 12px 0 0; display: grid; justify-items: end; gap: 8px; } /* Innenabstand: Platz für den ×-Knopf des Rahmens */
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: var(--grund); border: 1px solid rgba(11, 11, 11, .2); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: background-color .15s var(--kurve), color .15s var(--kurve); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible, a:focus-visible { outline: 2px solid var(--theater); outline-offset: 3px; }
.messung { color: var(--leise); font-size: 11px; background: var(--grund); padding: 3px 8px; border-radius: 4px; }
.reduziert .modi button[data-modus='zoom'] { opacity: .4; cursor: not-allowed; }
/* Programm: Titel füllen sich bei Hover/Fokus mit dem Standbild (background-clip: text) */
.programm { padding: clamp(56px, 9vw, 120px) clamp(16px, 3vw, 40px) clamp(64px, 10vw, 140px); display: grid; gap: 28px; }
.programm__kopf { display: flex; justify-content: space-between; align-items: end; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--linie); padding-bottom: 14px; }
.programm__kopf h2 { font: 800 clamp(30px, 3.6vw, 52px)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.liste { list-style: none; display: grid; }
.termin { display: grid; grid-template-columns: 9.5em 1fr auto; gap: 8px 28px; align-items: baseline; padding: 20px 0; border-bottom: 1px solid rgba(11, 11, 11, .18); text-decoration: none; }
.termin__titel { font: 800 clamp(26px, 3.3vw, 48px)/1.04 'Schibsted Grotesk', sans-serif; letter-spacing: -.025em;
background: var(--still, linear-gradient(90deg, #3d5f9a, #c4421a)) var(--px, 50%) 40% / cover; -webkit-background-clip: text; background-clip: text;
color: var(--text); transition: color .25s var(--kurve); }
.termin:hover .termin__titel, .termin:focus-visible .termin__titel, .termin.an .termin__titel { color: transparent; }
.termin__info { display: grid; gap: 6px; justify-items: end; text-align: right; color: var(--leise); }
.termin__datum { color: var(--text); }
.termin__ort { font-size: 15px; line-height: 1.4; color: var(--leise); grid-column: 2; }
.besuch { display: flex; gap: 10px 32px; flex-wrap: wrap; color: var(--leise); }
/* Reduziert: keine Zoom-Bewegung, Bild steht (Poster), Hover nur Farbwechsel ohne Übergang */
.reduziert .zeile, .reduziert .ende, .reduziert .termin__titel, .reduziert .ticket { transition: none !important; }
.vorschau body { overflow: hidden; }
.vorschau .werkbank .modi { display: none; }
@media (max-width: 760px) {
:root { --kopf: 56px; }
.nav li:not(:last-child) { display: none; }
.marke small { display: none; }
.strecke { height: 220vh; }
.zeile--oben { top: 64px; }
.werkbank { left: 16px; right: 16px; top: 14px; padding: 0; grid-template-columns: 1fr auto; align-items: center; justify-items: start; }
.messung { display: none; }
.lang { display: none; }
.zeile .lead { display: none; }
.modi button { padding: 0 10px; }
.termin { grid-template-columns: 1fr; gap: 6px; }
.termin__info { justify-items: start; text-align: left; grid-row: 1; display: flex; gap: 14px; }
.termin__ort { grid-column: 1; }
}
@media (hover: none) {
/* Touch: kein Hover; der Titel füllt sich beim Antippen (Fokus) und für den Eintrag, der gerade die Mitte des Bildschirms kreuzt */
.termin.mitte .termin__titel { color: transparent; }
}
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#">Kunsthalle Zurlauben <small>Trier</small></a>
<ul class="nav">
<li><a href="#programm">Programm</a></li>
<li><a href="#besuch">Besuch</a></li>
<li><a href="#">Leichte Sprache</a></li>
<li><a class="ticket" href="#">Tickets</a></li>
</ul>
</header>
<main>
<section class="strecke" data-strecke aria-labelledby="titel">
<div class="buehne" data-buehne>
<div class="fallback" aria-hidden="true"></div>
<canvas class="film" data-film aria-hidden="true"></canvas>
<svg class="platte" data-platte aria-hidden="true" focusable="false">
<defs>
<mask id="aussparung" maskUnits="userSpaceOnUse">
<rect data-maskgrund fill="#fff" width="100%" height="100%"/>
<g data-wortgruppe><text data-wort x="0" y="0" fill="#000">STROM</text></g>
</mask>
</defs>
<rect data-flaeche width="100%" height="100%" fill="#efede9" mask="url(#aussparung)"/>
</svg>
<h1 id="titel" class="unsichtbar">STROM. Bewegtbild am Fluss. Ausstellung in der Kunsthalle Zurlauben, 14.11.2026 bis 22.02.2027</h1>
<div class="zeile zeile--oben mono" data-ausblenden>
<span class="sparte" style="--c: var(--kunst)">Kunst · Ausstellung</span>
</div>
<div class="zeile zeile--unten mono" data-ausblenden>
<p class="lead">Sieben Videoarbeiten über die Mosel. Über Licht auf Wasser, über das, was der Fluss mitnimmt.</p>
<b>Halle 1 · 14.11.2026 – 22.02.2027</b>
</div>
<div class="ende" data-ende>
<p class="mono">Kunst · Halle 1 · 9 € / ermäßigt 5 €</p>
<h2>Bewegtbild am Fluss</h2>
<p>Die Ausstellung beginnt im Wasser. Mehr als 40 Stunden Material, gefilmt zwischen Zurlauben und der Römerbrücke, zu jeder Tageszeit und bei jedem Pegel.</p>
<div class="ende__fuss"><a class="ticket" href="#">Zeitfenster buchen</a><span class="mono">Di – So · 11 – 19 Uhr</span></div>
</div>
<div class="werkbank mono">
<div class="modi" role="group" aria-label="Variante">
<button type="button" data-modus="zoom" aria-pressed="true">Zoom<span class="lang"> durch den Buchstaben</span></button>
<button type="button" data-modus="fenster" aria-pressed="false"><span class="lang">Wort als </span>Fenster</button>
</div>
<span class="messung" data-messung>mask-size 80 % → 3000 % · Lerp 0,15</span>
</div>
</div>
</section>
<section class="programm" id="programm" aria-labelledby="programm-titel">
<div class="programm__kopf">
<h2 id="programm-titel">Rund um STROM</h2>
<p class="mono" style="color: var(--leise)">Titel füllen sich bei Hover · Fokus</p>
</div>
<ul class="liste">
<li><a class="termin" href="#" style="--px: 20%">
<span class="termin__info mono"><span class="termin__datum">Fr 14.11. · 19:00</span><span class="sparte" style="--c: var(--kunst)">Kunst</span></span>
<span class="termin__titel">Eröffnung am Ufer</span>
<span class="mono" aria-hidden="true">Ticket →</span>
<span class="termin__ort">Halle 1 und Moselvorplatz · Eintritt frei</span>
</a></li>
<li><a class="termin" href="#" style="--px: 55%">
<span class="termin__info mono"><span class="termin__datum">Sa 29.11. · 20:30</span><span class="sparte" style="--c: var(--musik)">Musik</span></span>
<span class="termin__titel">Pegelstand. Live-Vertonung</span>
<span class="mono" aria-hidden="true">Ticket →</span>
<span class="termin__ort">Halle 1 · 14 € / 8 €</span>
</a></li>
<li><a class="termin" href="#" style="--px: 80%">
<span class="termin__info mono"><span class="termin__datum">Do 11.12. · 18:00</span><span class="sparte" style="--c: var(--film)">Film</span></span>
<span class="termin__titel">Flussfilme aus dem Archiv</span>
<span class="mono" aria-hidden="true">Ticket →</span>
<span class="termin__ort">Kinosaal · 7 € · mit Untertiteln</span>
</a></li>
<li><a class="termin" href="#" style="--px: 38%">
<span class="termin__info mono"><span class="termin__datum">So 18.01. · 15:00</span><span class="sparte" style="--c: var(--theater)">Führung</span></span>
<span class="termin__titel">Rundgang in Leichter Sprache</span>
<span class="mono" aria-hidden="true">Anmelden →</span>
<span class="termin__ort">Treffpunkt Foyer · im Eintritt enthalten</span>
</a></li>
</ul>
<p class="besuch mono" id="besuch"><span>Di – So 11 – 19 Uhr</span><span>Do bis 22 Uhr</span><span>Stufenlos · Induktionsschleife</span><span>Bus 3 · Haltestelle Zurlauben</span></p>
</section>
</main>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const strecke = $('[data-strecke]'), buehne = $('[data-buehne]'), film = $('[data-film]');
const platte = $('[data-platte]'), gruppe = $('[data-wortgruppe]'), wort = $('[data-wort]');
const ende = $('[data-ende]'), messung = $('[data-messung]');
const ausblenden = [...document.querySelectorAll('[data-ausblenden]')];
const termine = [...document.querySelectorAll('.termin')];
/* Werte (siehe bau.werte) */
const START = 80, ZIEL = 3000; // mask-size in % der Bühnenbreite → Faktor 37,5
const LERP = 0.15; // Annäherung pro Frame
const BREITE = 0.8; // Wort füllt 80 % der Bühnenbreite (Zoom)
const BREITE_FENSTER = 0.92; // Riesenwort als Fenster
let modus = 'zoom', ist = 0, soll = 0, W = 0, H = 0;
let anker = { x: 0, y: 0 };
/* ---------- Bewegtbild: „Mosel am Abend“, als Canvas gemalt ---------- */
const ctx = film.getContext('2d');
const AUFL = 0.5; // halbe Auflösung reicht, das Bild ist ohnehin malerisch weich
let fw = 0, fh = 0, funken = [];
function zufall(n) { let s = n * 9301 + 49297; return () => ((s = (s * 9301 + 49297) % 233280) / 233280); }
function filmGroesse() {
fw = Math.max(2, Math.round(W * AUFL)); fh = Math.max(2, Math.round(H * AUFL));
film.width = fw; film.height = fh;
const r = zufall(7); funken = [];
for (let i = 0; i < 520; i++) funken.push({ x: r(), y: r(), l: 0.01 + r() * 0.05, v: 0.004 + r() * 0.012, p: r() * 6.28 });
}
function malen(t) {
const hz = fh * 0.5, sx = fw * 0.66;
// Himmel
let g = ctx.createLinearGradient(0, 0, 0, hz);
g.addColorStop(0, '#17223f'); g.addColorStop(0.42, '#3d5f9a'); g.addColorStop(0.78, '#c4664f'); g.addColorStop(1, '#f3ab72');
ctx.fillStyle = g; ctx.fillRect(0, 0, fw, hz + 1);
// Sonne und Dunst, atmet langsam
const atm = 1 + Math.sin(t * 0.0006) * 0.05;
g = ctx.createRadialGradient(sx, hz, 0, sx, hz, fw * 0.45 * atm);
g.addColorStop(0, 'rgba(255,236,200,1)'); g.addColorStop(0.06, 'rgba(252,196,140,.85)'); g.addColorStop(1, 'rgba(196,66,26,0)');
ctx.fillStyle = g; ctx.fillRect(0, 0, fw, hz + 1);
// Weinberg-Hänge gegenüber, im Dunst
ctx.fillStyle = '#4a3a4a';
ctx.beginPath(); ctx.moveTo(0, hz);
for (let x = 0; x <= fw; x += 4) ctx.lineTo(x, hz - fh * (0.022 + 0.018 * Math.sin(x / fw * 3.1 + 0.6) + 0.008 * Math.sin(x / fw * 9.3)));
ctx.lineTo(fw, hz); ctx.closePath(); ctx.fill();
// Wasser, spiegelt den Himmel
g = ctx.createLinearGradient(0, hz, 0, fh);
g.addColorStop(0, '#b47a68'); g.addColorStop(0.1, '#4f5e7c'); g.addColorStop(0.45, '#1f3d5c'); g.addColorStop(1, '#0b1824');
ctx.fillStyle = g; ctx.fillRect(0, hz, fw, fh - hz);
// Lichtsäule unter der Sonne
g = ctx.createLinearGradient(sx - fw * 0.16, 0, sx + fw * 0.16, 0);
g.addColorStop(0, 'rgba(255,190,130,0)'); g.addColorStop(0.3, 'rgba(255,190,130,.1)'); g.addColorStop(0.5, 'rgba(255,205,150,.42)');
g.addColorStop(0.7, 'rgba(255,190,130,.1)'); g.addColorStop(1, 'rgba(255,190,130,0)');
ctx.fillStyle = g; ctx.fillRect(sx - fw * 0.16, hz, fw * 0.32, fh - hz);
// Strömung: breite, helle Bahnen, die langsam ziehen
for (let i = 0; i < 7; i++) {
const y = hz + (fh - hz) * (0.12 + i * 0.13) + Math.sin(t * 0.0007 + i) * 3;
ctx.fillStyle = `rgba(150,180,215,${0.05 + 0.02 * Math.sin(t * 0.001 + i * 2)})`;
ctx.fillRect(0, y, fw, 1 + i * 0.6);
}
// Glitzern: kurze Lichtstriche, die flussabwärts ziehen und flimmern
ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round';
for (const f of funken) {
const y = hz + 2 + f.y * f.y * (fh - hz);
const tiefe = (y - hz) / (fh - hz);
const x = ((f.x - t * 0.0000012 * (0.5 + f.v * 60)) % 1 + 1) % 1 * fw;
const nah = Math.max(0, 1 - Math.abs(x - sx) / (fw * (0.05 + tiefe * 0.16)));
const flimmer = Math.max(0, Math.sin(t * 0.003 * (1 + f.v * 30) + f.p));
const a = (0.09 + nah * nah * 0.9) * flimmer;
if (a < 0.02) continue;
ctx.strokeStyle = nah > 0.2 ? `rgba(255,${200 + nah * 40 | 0},${150 + nah * 70 | 0},${a})` : `rgba(175,205,240,${a})`;
ctx.lineWidth = 0.5 + tiefe * 1.6;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + f.l * fw * (0.3 + tiefe * 0.8), y); ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
// Uferkante unten mit Pollern (Zurlaubener Ufer)
ctx.fillStyle = '#071017';
ctx.fillRect(0, fh * 0.95, fw, fh * 0.05);
for (let i = 0; i < 9; i++) ctx.fillRect(fw * (0.06 + i * 0.115), fh * 0.93, Math.max(2, fw * 0.005), fh * 0.025);
}
let filmUhr = null;
function filmLauf(t) { malen(t); filmUhr = requestAnimationFrame(filmLauf); }
function filmStart() { cancelAnimationFrame(filmUhr); if (reduziert()) { malen(9000); filmUhr = null; } else filmUhr = requestAnimationFrame(filmLauf); }
function standbild() { // Poster für die Programm-Titel (background-clip: text)
try { malen(9000); const url = film.toDataURL('image/jpeg', 0.82); document.body.style.setProperty('--still', `url(${url})`); } catch (e) {}
}
/* ---------- Wort vermessen, Anker im Stamm des T ---------- */
let hoch = false, start = { x: 0, y: 0 };
function vermessen() {
W = buehne.clientWidth; H = buehne.clientHeight;
platte.setAttribute('viewBox', `0 0 ${W} ${H}`);
// Hochformat (Telefon): Wort um 90° gedreht, damit Himmel und Wasser im Wort liegen
hoch = W < 600 && H > W * 1.4;
const laenge = hoch ? H * 0.74 : W * (modus === 'fenster' ? BREITE_FENSTER : BREITE);
wort.setAttribute('x', 0); wort.setAttribute('y', 0); wort.style.fontSize = '100px';
const b = wort.getBBox();
const fs = 100 * laenge / b.width;
wort.style.fontSize = fs.toFixed(2) + 'px';
const b2 = wort.getBBox();
// Wort im eigenen Koordinatensystem um (0|0) zentriert, Versalien optisch mittig
const x0 = -b2.width / 2 - b2.x, y0 = fs * 0.36;
wort.setAttribute('x', x0.toFixed(1)); wort.setAttribute('y', y0.toFixed(1));
let t = { x: x0 + b2.width * 0.3, width: fs * 0.5 };
try { t = wort.getExtentOfChar(1); } catch (e) {}
anker = { x: t.x + t.width / 2, y: y0 - fs * 0.24 }; // unterer Teil des T-Stamms
const mx = W / 2, my = hoch ? H * 0.5 : H * 0.5;
start = hoch ? { x: mx + anker.y, y: my - anker.x } : { x: mx + anker.x, y: my + anker.y };
}
/* ---------- Fortschritt ---------- */
function fortschritt() {
if (modus !== 'zoom' || reduziert()) return 0;
const oben = parseFloat(getComputedStyle(buehne).top) || 0;
const weg = strecke.offsetHeight - buehne.offsetHeight;
return Math.min(1, Math.max(0, (oben - strecke.getBoundingClientRect().top) / Math.max(1, weg)));
}
function anwenden(p) {
// mask-size wächst exponentiell von 80 % auf 3000 % (gleichmäßig empfundener Zoom)
const q = Math.min(1, p / 0.86);
const groesse = START * Math.pow(ZIEL / START, q * q * (3 - 2 * q) * 0.35 + q * 0.65);
const s = groesse / START;
// Anker wandert in die Bühnenmitte, damit der Stamm das Bild sauber füllt
const k = Math.min(1, q * 1.4), m = k * k * (3 - 2 * k);
const cx = start.x + (W / 2 - start.x) * m, cy = start.y + (H / 2 - start.y) * m;
gruppe.setAttribute('transform', `translate(${cx.toFixed(2)} ${cy.toFixed(2)}) scale(${s.toFixed(4)})${hoch ? ' rotate(-90)' : ''} translate(${(-anker.x).toFixed(2)} ${(-anker.y).toFixed(2)})`);
const aus = Math.max(0, 1 - p / 0.12);
ausblenden.forEach(e => e.style.opacity = aus.toFixed(3));
ende.classList.toggle('da', p > 0.9);
platte.style.visibility = p > 0.95 ? 'hidden' : 'visible';
messung.textContent = modus === 'zoom'
? `mask-size ${Math.round(groesse)} % · p ${p.toFixed(2).replace('.', ',')} · Lerp 0,15`
: 'statisch · background-clip / mask';
}
let uhr = null;
function tick() {
soll = vorfuehrt ? soll : fortschritt();
ist += (soll - ist) * LERP;
if (Math.abs(soll - ist) < 0.0005) ist = soll;
anwenden(ist);
uhr = requestAnimationFrame(tick);
}
function setzen(neu) {
if (neu === 'zoom' && reduziert()) neu = 'fenster';
modus = neu;
html.classList.toggle('zoom', modus === 'zoom');
html.classList.toggle('fenster', modus === 'fenster');
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
requestAnimationFrame(() => { neuMessen(); ist = soll = 0; anwenden(0); });
}
document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => {
if (b.dataset.modus === 'zoom' && reduziert()) return;
vorfuehrungStop(); setzen(b.dataset.modus);
}));
function neuMessen() { vermessen(); filmGroesse(); standbild(); if (reduziert()) malen(9000); }
addEventListener('resize', () => { neuMessen(); anwenden(ist); });
// Touch: Eintrag in Bildschirmmitte füllt sich
if (matchMedia('(hover: none)').matches && 'IntersectionObserver' in window) {
const io = new IntersectionObserver(es => es.forEach(e => e.target.classList.toggle('mitte', e.isIntersecting)), { rootMargin: '-45% 0px -45% 0px' });
termine.forEach(t => io.observe(t));
}
/* ---------- Vorführung: simulierter Scroll durch den Buchstaben, dann Programm ---------- */
let vorfuehrt = false, vUhr = null, v0 = 0;
const RUNDE = 10000;
function vorfuehrungStop() { vorfuehrt = false; cancelAnimationFrame(vUhr); vUhr = null; termine.forEach(t => t.classList.remove('an')); }
function vorfuehren() {
vorfuehrungStop();
if (!reduziert() && modus !== 'zoom') setzen('zoom');
scrollTo(0, 0); ist = soll = 0;
vorfuehrt = true; v0 = performance.now();
const schritt = t => {
const z = ((t - v0) % RUNDE) / RUNDE;
const vs = html.classList.contains('vorschau');
if (reduziert()) {
// Ohne Zoom: nur die Programm-Titel füllen sich nacheinander
const i = Math.floor(z * 8) % termine.length;
termine.forEach((e, n) => e.classList.toggle('an', n === i && z > 0.1));
} else if (z < 0.1) { soll = 0; termine.forEach(e => e.classList.remove('an')); if (!vs) scrollTo(0, 0); }
else if (z < 0.6) { const q = (z - 0.1) / 0.5; soll = q * q * (3 - 2 * q); }
else if (z < 0.97) { soll = 1; }
else { soll = 0; ist = 0; }
vUhr = requestAnimationFrame(schritt);
};
vUhr = requestAnimationFrame(schritt);
}
function neustart() { vorfuehrungStop(); scrollTo(0, 0); ist = soll = 0; setzen(reduziert() ? 'fenster' : 'zoom'); filmStart(); }
function pause() { cancelAnimationFrame(vUhr); vUhr = null; cancelAnimationFrame(filmUhr); filmUhr = null; }
function weiter() { filmStart(); if (vorfuehrt) vorfuehren(); }
// Start, sobald die Schrift da ist (Vermessung braucht Schibsted)
const los = () => { setzen(reduziert() ? 'fenster' : 'zoom'); neuMessen(); filmStart(); cancelAnimationFrame(uhr); uhr = requestAnimationFrame(tick); };
(document.fonts && document.fonts.load ? document.fonts.load('900 100px "Schibsted Grotesk"').catch(() => {}) : Promise.resolve()).then(los);
// Reduziert-Schalter der Detailseite kann die Klasse später setzen
let warReduziert = reduziert();
new MutationObserver(() => {
if (reduziert() === warReduziert) return;
warReduziert = reduziert(); setzen(warReduziert ? 'fenster' : 'zoom'); filmStart();
}).observe(html, { attributes: true, attributeFilter: ['class'] });
// Wer selbst scrollt, übernimmt
['wheel', 'touchstart', 'keydown'].forEach(e => addEventListener(e, () => { if (vorfuehrt) vorfuehrungStop(); }, { passive: true }));
})();
</script>
</body>
</html>