Animierte Variable-Font-Achsen
Die Buchstaben selbst reagieren: Unter dem Zeiger werden sie fett und breit, als Welle läuft das Gewicht durch die Headline, ein Wort schwillt beim Überfahren an.
- Eine variable Schrift mit mehreren Achsen wird geladen, etwa wght, wdth, slnt, opsz oder GRAD.
- Die Achsenwerte werden live gesteuert. Beim Hover wird ein ganzes Wort fetter oder breiter. Pro Buchstabe nach Abstand zum Zeiger: nahe Buchstaben schwellen an, entfernte bleiben dünn. An den Scrollfortschritt gekoppelt: die Headline wird beim Scrollen breiter oder schmaler. An die Scroll-Geschwindigkeit gekoppelt: schnelles Scrollen kippt die Schrift in die Schräglage.
- Die Glyphen verformen sich stufenlos, statt sich nur zu bewegen. Dadurch wirkt die Schrift lebendig.
Die Schrift selbst wird zum Interaktionsmaterial. Es liegt kein Effekt über dem Text, die Form der Buchstaben ändert sich. Das ist sehr markant und bleibt typografisch ehrlich. Figma nennt Variable Fonts, die auf Interaktion reagieren, als Hero-Trend 2026.
Wann einsetzen
Typo-getriebene Marken, Portfolios, Foundry- und Kulturseiten, große Hero-Wörter, Navigation mit wenigen großen Einträgen. Auch für Wirkungszahlen, die einmal beim Eintritt kräftiger werden.
Wann nicht
Wenn keine variable Schrift mit ausreichender Achsenspanne verfügbar ist. Nicht im Fließtext, denn wdth und wght verändern die Laufweite und damit den Umbruch. Nicht in vielen Zeilen gleichzeitig.
Varianten
- Hover: wght 300 → 800 auf dem ganzen Wort
- Zeiger-Nähe pro Buchstabe: Gewicht fällt mit dem Abstand zum Cursor ab
- Scroll-gekoppelt: wdth und wght über animation-timeline: view()
- Geschwindigkeit: slnt bzw. ital proportional zur Scroll-Geschwindigkeit
- Welle: die Achse läuft zeitversetzt durch die Buchstaben, in Stufen hin und zurück (Displaay: 500 ms pro Instanz)
- GRAD-Achse für Hover ohne Layoutsprung: ändert die Strichstärke, nicht die Laufweite
Umsetzung
Hover: transition: font-variation-settings 500ms; im :hover font-variation-settings: 'wght' 800, 'wdth' 125. Nähe: Buchstaben in Spans teilen, pro Frame den Abstand d zum Zeiger messen, wght = map(d, 0 … Radius, 900 … 300), weich mit Lerp nachführen und direkt in style.fontVariationSettings schreiben. Welle: Gewicht in Stufen (z. B. 300 · 450 · 600 · 750 · 900) hin und zurück, je Buchstabe zeitversetzt. Fallstricke: font-variation-settings setzt nicht genannte Achsen auf ihren Standard zurück, also immer alle Achsen gemeinsam setzen. CSS-Variablen lassen sich ohne @property nicht animieren, darum die Eigenschaft selbst animieren. wght und wdth ändern die Laufweite und lösen Reflow aus; für einen ruhigen Hover GRAD nutzen oder Buchstaben in feste Boxen setzen. Breitesten Zustand vorab messen, damit die Zeile nie überläuft. Außerhalb des Viewports pausieren (IntersectionObserver), nur bei Änderung neu schreiben.
Robust umsetzen
Telefon. Hover und Nähe fallen weg. Ersatz: eine selbstlaufende Welle, Kopplung an Scroll oder Gyrosensor, oder eine einmalige Achsen-Animation beim Eintritt. In Listen kann die Zeile in der Bildschirmmitte den vollen Schnitt bekommen.
Ohne Bewegung. Statischer, gut gewählter Schnitt. Höchstens ein Gewichtswechsel beim Hover, ohne Verlauf.
Barrierefrei. Sehr dünne Schnitte (wght unter 300) bei kleinen Größen vermeiden. Der Text bleibt echter Text: Buchstaben-Spans mit aria-hidden versehen und dem Element ein aria-label mit dem ganzen Satz geben.
Code
<!DOCTYPE html>
<!--
Animierte Variable-Font-Achsen
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/variable-font-axes/
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>Animierte Variable-Font-Achsen · 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: 'Archivo'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/archivo:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-stretch: 62% 125%; 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: #ffffff; --flaeche: #edf5fa; --marke: #002e6d; --marke-hell: #0b4594; --text: #222520; --grau: #dfdfd7;
--signal: #ffca0a; /* nur Spenden */
--hell: #edf5fa; --hell-leise: rgba(237, 245, 250, .62); --hell-linie: rgba(237, 245, 250, .2);
--kurve: cubic-bezier(0.645, 0.045, 0.355, 1); /* InOut aus dem Rezept */
--stufe: 500ms; /* gemessen: 500 ms pro Achsen-Instanz */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Archivo', system-ui, sans-serif; font-variation-settings: 'wdth' 100; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body { overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
/* Kopf mit Spenden-Knopf (einzige Stelle mit Signalfarbe) */
.kopf { position: relative; z-index: 3; height: 64px; display: flex; align-items: center; gap: 28px; padding: 0 clamp(16px, 4vw, 48px); background: var(--grund); border-bottom: 1px solid var(--grau); }
.marke { display: flex; align-items: center; gap: 10px; margin-right: auto; }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { font: 800 23px/1 'Archivo', sans-serif; font-variation-settings: 'wdth' 62, 'wght' 800; letter-spacing: .01em; text-transform: uppercase; color: var(--marke); }
.marke small { display: block; font-size: 11px; font-weight: 500; letter-spacing: .02em; color: #5b5f58; margin-top: 2px; }
.navi { display: flex; gap: 26px; font-size: 15px; font-weight: 500; }
.navi a { padding: 6px 0; background: linear-gradient(var(--marke), var(--marke)) 0 100% / 0 2px no-repeat; transition: background-size .15s var(--kurve); }
.navi a:hover { background-size: 100% 2px; }
.spenden { font-size: 16px; font-weight: 600; background: var(--signal); color: var(--text); padding: 11px 20px; border-radius: 6px; transition: background-color .15s var(--kurve); }
.spenden:hover { background: #ffd43d; }
/* Bühne */
.held { position: relative; overflow: hidden; background: var(--marke); color: var(--hell); min-height: calc(100svh - 64px); display: grid; grid-template-rows: auto 1fr auto; gap: 20px; padding: clamp(20px, 3.2vw, 44px) clamp(16px, 4vw, 48px) clamp(20px, 3vw, 36px); touch-action: pan-y; }
.fluss { position: absolute; top: 0; bottom: 0; right: 3%; width: 36%; pointer-events: none; }
.fluss svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.fluss span { position: absolute; color: var(--hell-leise); white-space: nowrap; font-size: 10px; }
.ufer-de { right: calc(61% + 10px); top: 43.6%; transform: translateY(-50%); } /* Enden der Altstadtbrücke */
.ufer-pl { left: calc(78.7% + 10px); top: 43.6%; transform: translateY(-50%); }
.oben, .titel, .unten { position: relative; z-index: 1; }
.oben { display: flex; justify-content: space-between; align-items: center; gap: 12px 20px; flex-wrap: wrap; }
.ort { color: var(--hell-leise); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--hell-leise); background: none; border: 1px solid var(--hell-linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background-color .15s, border-color .15s; }
.modi button:hover { color: var(--hell); border-color: rgba(237, 245, 250, .45); }
.modi button[aria-pressed='true'] { background: var(--hell); color: var(--marke); border-color: var(--hell); }
.titel { align-self: center; font-family: 'Archivo', sans-serif; font-weight: 800; text-transform: uppercase; letter-spacing: 0; line-height: .9; font-size: clamp(56px, min(12.8vw, 19svh), 200px); }
.titel .zeile { display: block; white-space: nowrap; }
.titel .wort { display: inline-block; }
.titel .z { display: inline-block; font-variation-settings: 'wght' 300, 'wdth' 62; }
.titel .zeile:nth-child(2) { padding-left: .6em; } /* Treppe wie zwei Uferkanten */
.titel .zeile:nth-child(3) { padding-left: 1.2em; }
.unten { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px 40px; flex-wrap: wrap; }
.auftrag { display: flex; align-items: center; gap: 22px 28px; flex-wrap: wrap; }
.lead { font-size: 17px; line-height: 1.55; max-width: 42ch; color: rgba(237, 245, 250, .86); text-wrap: pretty; }
.knopf { display: inline-flex; align-items: center; gap: 10px; font-size: 16px; font-weight: 600; color: var(--hell); border: 1.5px solid rgba(237, 245, 250, .55); border-radius: 6px; padding: 12px 18px; transition: background-color .15s var(--kurve), color .15s var(--kurve); }
.knopf:hover { background: var(--hell); color: var(--marke); }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 16px; color: var(--hell-leise); max-width: 48ch; text-align: right; padding: 10px 0 10px 14px; background: linear-gradient(90deg, rgba(0, 46, 109, 0), rgba(0, 46, 109, .86) 18%); }
.werte b { color: var(--hell); font-weight: 500; }
.werte span { white-space: nowrap; }
/* Gedachter Zeiger nur während der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; width: 22px; height: 22px; margin: -11px 0 0 -11px; border: 1.5px solid var(--hell); background: rgba(237, 245, 250, .18); box-shadow: 0 0 0 6px rgba(237, 245, 250, .08); border-radius: 50%; pointer-events: none; z-index: 5; opacity: 0; transition: opacity .2s linear; }
.zeiger.an { opacity: .85; }
/* Mitmachen: ganzes Wort, zwei Achsen (wdth + wght) */
.mitmachen { padding: clamp(64px, 10vw, 128px) clamp(16px, 4vw, 48px) clamp(56px, 8vw, 96px); }
.mitmachen-kopf { display: flex; justify-content: space-between; align-items: end; gap: 16px 40px; flex-wrap: wrap; margin-bottom: clamp(24px, 4vw, 48px); }
.mitmachen h2 { font: 700 clamp(28px, 1.2rem + 2vw, 48px)/1 'Archivo', sans-serif; font-variation-settings: 'wdth' 62, 'wght' 750; text-transform: uppercase; color: var(--marke); }
.mitmachen-kopf p { font-size: 17px; line-height: 1.6; max-width: 46ch; color: #4a4e47; }
.wege { list-style: none; border-top: 1px solid var(--grau); }
.wege li { border-bottom: 1px solid var(--grau); overflow: hidden; }
.wege a { display: grid; grid-template-columns: 3.2em minmax(0, 1fr) 28px; grid-template-rows: auto auto; align-items: center; gap: 0 20px; padding: clamp(14px, 1.8vw, 22px) 0; color: var(--marke); }
.wege .nr { color: #6d716a; }
.wege .wort-gross { font-size: clamp(30px, 6.1vw, 92px); line-height: 1; text-transform: uppercase; white-space: nowrap; font-variation-settings: 'wdth' 62, 'wght' 300; transition: font-variation-settings var(--stufe) var(--kurve); }
.wege .info { grid-column: 2; grid-row: 2; margin-top: 8px; font-size: 15px; line-height: 1.45; color: #4a4e47; max-width: 60ch; }
.wege .pfeil { grid-column: 3; grid-row: 1; width: 28px; height: 28px; opacity: .35; transform: translateX(-6px); transition: opacity .15s var(--kurve), transform .15s var(--kurve); }
.wege a:hover .wort-gross, .wege a:focus-visible .wort-gross, .wege li.aktiv .wort-gross { font-variation-settings: 'wdth' 125, 'wght' 800; }
.wege a:hover .pfeil, .wege a:focus-visible .pfeil, .wege li.aktiv .pfeil { opacity: 1; transform: none; }
.wege a:focus-visible { outline: 2px solid var(--marke); outline-offset: 4px; }
/* Wirkung in Zahlen: einmalige Achsen-Animation beim Eintritt */
.zahlen { background: var(--flaeche); padding: clamp(48px, 7vw, 88px) clamp(16px, 4vw, 48px); }
.zahlen ul { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; }
.zahlen li { border-top: 2px solid var(--marke); padding-top: 18px; }
.zahlen strong { display: block; font: 800 clamp(3rem, 2rem + 4vw, 5.5rem)/1 'Archivo', sans-serif; font-variant-numeric: tabular-nums; color: var(--marke); font-variation-settings: 'wdth' 62, 'wght' 300; transition: font-variation-settings 1000ms var(--kurve); }
.zahlen .da strong { font-variation-settings: 'wdth' 62, 'wght' 800; }
.zahlen li:nth-child(2) strong { transition-delay: 120ms; }
.zahlen li:nth-child(3) strong { transition-delay: 240ms; }
.zahlen span { display: block; margin-top: 10px; font-size: 16px; line-height: 1.45; max-width: 26ch; }
.zahlen .quelle { margin-top: 28px; color: #5b5f58; }
.fuss { display: flex; justify-content: space-between; gap: 8px 24px; flex-wrap: wrap; padding: 22px clamp(16px, 4vw, 48px); font-size: 13px; color: #5b5f58; border-top: 1px solid var(--grau); }
/* Ohne JS: fertiger Schnitt */
html:not(.js) .titel { font-variation-settings: 'wght' 800, 'wdth' 62; }
/* Reduzierte Bewegung: statischer Schnitt, Wechsel ohne Verlauf */
.reduziert .titel .z { font-variation-settings: 'wght' 800, 'wdth' 62 !important; }
.reduziert .wege .wort-gross, .reduziert .wege .pfeil, .reduziert .zahlen strong { transition: none !important; }
.reduziert .zahlen strong { font-variation-settings: 'wdth' 62, 'wght' 800; }
.reduziert .zeiger, .reduziert .modi { display: none; }
@media (max-width: 860px) {
.navi { display: none; }
.werte { justify-content: flex-start; text-align: left; padding: 0; background: none; }
}
@media (max-width: 600px) {
.kopf { gap: 12px; }
.marke small { display: none; }
.fluss { right: -18%; width: 78%; opacity: .7; }
.titel { font-size: clamp(48px, 16.5vw, 104px); } /* gemessen: breitester Zustand (wght 900, wdth 76) passt auf 390 px */
.titel .zeile:nth-child(2) { padding-left: .15em; }
.titel .zeile:nth-child(3) { padding-left: .3em; }
.fluss span, .fluss .bruecke { display: none; }
.lead { font-size: 16px; }
.modi { width: 100%; }
.wege a { grid-template-columns: minmax(0, 1fr) 24px; gap: 0 12px; }
.wege .nr { display: none; }
.wege .wort-gross { font-size: clamp(22px, 6.6vw, 40px); }
.wege .info { grid-column: 1; font-size: 14px; }
.wege .pfeil { grid-column: 2; width: 24px; height: 24px; }
.zahlen ul { grid-template-columns: 1fr; gap: 28px; }
}
@media (hover: none) {
.navi a:hover, .knopf:hover { background-size: 0 2px; }
.wege a:hover .wort-gross { font-variation-settings: 'wdth' 62, 'wght' 300; }
.wege li.aktiv a .wort-gross { font-variation-settings: 'wdth' 125, 'wght' 800; }
.wege a:hover .pfeil { opacity: .35; transform: translateX(-6px); }
.wege li.aktiv a .pfeil { opacity: 1; transform: none; }
}
/* Vorschau in der Galerie: nur Kopf und Bühne */
.vorschau body { overflow: hidden; }
.vorschau .mitmachen, .vorschau .zahlen, .vorschau .fuss { display: none; }
.vorschau .held { height: calc(100vh - 64px); min-height: 0; }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#" aria-label="Uferwerk Neiße e.V., Startseite">
<svg viewBox="0 0 30 30" aria-hidden="true"><rect width="30" height="30" rx="6" fill="#002e6d"/><path d="M11 3c-3 5 3 9 0 13s3 8 0 11M19 3c-3 5 3 9 0 13s3 8 0 11" fill="none" stroke="#edf5fa" stroke-width="2.2" stroke-linecap="round"/><path d="M7 15.5h16" stroke="#edf5fa" stroke-width="1.6" stroke-linecap="round" opacity=".7"/></svg>
<span><b>Uferwerk</b><small>Neiße e.V. · Görlitz</small></span>
</a>
<nav class="navi" aria-label="Hauptnavigation">
<a href="#">Projekte</a><a href="#wege">Mitmachen</a><a href="#">Über uns</a><a href="#">Transparenz</a>
</nav>
<a class="spenden" href="#">Spenden</a>
</header>
<main>
<section class="held" data-held>
<div class="fluss" aria-hidden="true">
<svg viewBox="0 0 300 900" preserveAspectRatio="none">
<path d="M170 -20C116 110 236 214 184 338S86 514 146 636S236 818 168 940" fill="none" stroke="rgba(237,245,250,.16)" stroke-width="76" vector-effect="non-scaling-stroke"/>
<path d="M170 -20C116 110 236 214 184 338S86 514 146 636S236 818 168 940" fill="none" stroke="#0b4594" stroke-width="70" vector-effect="non-scaling-stroke"/>
<path d="M170 -20C116 110 236 214 184 338S86 514 146 636S236 818 168 940" fill="none" stroke="rgba(237,245,250,.3)" stroke-width="1" stroke-dasharray="2 10" vector-effect="non-scaling-stroke"/>
<g class="bruecke" stroke="#edf5fa" stroke-linecap="round" fill="none" vector-effect="non-scaling-stroke" opacity=".8">
<path d="M118 392H236" stroke-width="3" vector-effect="non-scaling-stroke"/>
<path d="M136 392v9M157 392v9M178 392v9M199 392v9M220 392v9" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
</g>
<g stroke="#edf5fa" stroke-linecap="round" stroke-width="7" opacity=".85">
<path d="M162 74h0M197 226h0M136 520h0M197 764h0" vector-effect="non-scaling-stroke"/>
</g>
</svg>
<span class="mono ufer-de">DE</span>
<span class="mono ufer-pl">PL</span>
</div>
<div class="oben">
<p class="ort mono">Görlitz · Zgorzelec · seit 2011</p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="naehe" aria-pressed="true">Nähe</button>
<button type="button" data-modus="welle" aria-pressed="false">Welle</button>
<button type="button" data-modus="wort" aria-pressed="false">Wort</button>
</div>
</div>
<h1 class="titel" data-titel aria-label="Zwei Ufer. Ein Fluss. Ein Verein.">
<span class="zeile">Zwei Ufer.</span>
<span class="zeile">Ein Fluss.</span>
<span class="zeile">Ein Verein.</span>
</h1>
<div class="unten">
<div class="auftrag">
<p class="lead">Wir holen Müll aus der Neiße, messen ihr Wasser und bringen Menschen von beiden Ufern zusammen. Jeden zweiten Samstag, ab der Altstadtbrücke.</p>
<a class="knopf" href="#wege">Zum nächsten Uferputz <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 8h11M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></a>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</div>
</section>
<section class="mitmachen" id="wege">
<div class="mitmachen-kopf">
<h2>Vier Wege, mitzumachen</h2>
<p>Nicht jeder kann jeden Samstag ans Ufer. Darum gibt es bei uns mehr als eine Art, dabei zu sein.</p>
</div>
<ul class="wege" data-wege>
<li><a href="#"><span class="nr mono">01</span><span class="wort-gross">Uferputz</span><span class="info">Jeden 2. Samstag, 10 Uhr. Treffpunkt Altstadtbrücke, Handschuhe und Zangen liegen bereit.</span><svg class="pfeil" viewBox="0 0 28 28" aria-hidden="true"><path d="M4 14h19M16 7l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg></a></li>
<li><a href="#"><span class="nr mono">02</span><span class="wort-gross">Wasser messen</span><span class="info">Messgruppe für 26 Punkte zwischen Hagenwerder und Ludwigsdorf. Einweisung inklusive.</span><svg class="pfeil" viewBox="0 0 28 28" aria-hidden="true"><path d="M4 14h19M16 7l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg></a></li>
<li><a href="#"><span class="nr mono">03</span><span class="wort-gross">Uferpate</span><span class="info">Ein Abschnitt, eine Patenschaft. Für Schulklassen, Firmen und Nachbarschaften.</span><svg class="pfeil" viewBox="0 0 28 28" aria-hidden="true"><path d="M4 14h19M16 7l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg></a></li>
<li><a href="#"><span class="nr mono">04</span><span class="wort-gross">Fördermitglied</span><span class="info">Ab 5 € im Monat. Die Spendenquittung kommt jedes Jahr im Februar.</span><svg class="pfeil" viewBox="0 0 28 28" aria-hidden="true"><path d="M4 14h19M16 7l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2"/></svg></a></li>
</ul>
</section>
<section class="zahlen" data-zahlen aria-label="Wirkung in Zahlen">
<ul>
<li><strong>3,8 t</strong><span>Müll aus Fluss und Uferwiesen geholt</span></li>
<li><strong>412</strong><span>Ehrenamtliche aus Görlitz und Zgorzelec</span></li>
<li><strong>26</strong><span>Messpunkte für die Wasserqualität</span></li>
</ul>
<p class="quelle mono">Stand: Jahresbericht 2025</p>
</section>
</main>
<footer class="fuss"><span>Uferwerk Neiße e.V. · gemeinnützig · Görlitz</span><span>Satzung · Jahresbericht · Impressum</span></footer>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const ohneHover = matchMedia('(hover: none)').matches;
// Achsen und Zeiten. Kurve überall InOut cubic-bezier(0.645, 0.045, 0.355, 1).
const RUHE = 300, VOLL = 900; // wght-Spanne der Headline
const BREIT_RUHE = 62, BREIT_VOLL = 76; // wdth läuft gekoppelt mit (Archivo: 62–125)
const RADIUS_EM = 2.2; // Nähe: Wirkradius in em der Headline
const LERP = 0.14; // Nähe: Annäherung pro Frame
const STUFEN = [300, 450, 600, 750, 900]; // Welle: Instanzen, hin und zurück
const STUFE_MS = 500; // gemessen (Displaay): 500 ms pro Instanz
const VERSATZ_MS = 70; // Welle: Versatz pro Buchstabe
const WORT_MS = 500; // Wort: Dauer des Achsenwechsels
// InOut-Kurve als Funktion (cubic-bezier 0.645, 0.045, 0.355, 1)
const bezier = (x1, y1, x2, y2) => {
const k = (a, b) => 1 - 3 * b + 3 * a, l = (a, b) => 3 * b - 6 * a, m = a => 3 * a;
const kurve = (t, a, b) => ((k(a, b) * t + l(a, b)) * t + m(a)) * t;
const steig = (t, a, b) => 3 * k(a, b) * t * t + 2 * l(a, b) * t + m(a);
return x => { if (x <= 0) return 0; if (x >= 1) return 1; let t = x; for (let i = 0; i < 6; i++) { const s = steig(t, x1, x2); if (!s) break; t -= (kurve(t, x1, x2) - x) / s; } return kurve(t, y1, y2); };
};
const inOut = bezier(0.645, 0.045, 0.355, 1);
const held = document.querySelector('[data-held]');
const titel = document.querySelector('[data-titel]');
const werte = document.querySelector('[data-werte]');
const zeiger = document.querySelector('[data-zeiger]');
// Headline in Wörter und Buchstaben teilen. Buchstaben aria-hidden, h1 trägt aria-label.
const zeichen = [], woerter = [];
titel.querySelectorAll('.zeile').forEach(zeile => {
const teile = zeile.textContent.trim().split(' ');
zeile.textContent = '';
zeile.setAttribute('aria-hidden', 'true');
teile.forEach((w, i) => {
const wort = document.createElement('span');
wort.className = 'wort';
const liste = [];
[...w].forEach(c => {
const z = document.createElement('span');
z.className = 'z'; z.textContent = c;
wort.appendChild(z);
const eintrag = { el: z, wert: RUHE, geschrieben: -1, von: RUHE, nach: RUHE, start: 0, wort: woerter.length };
zeichen.push(eintrag); liste.push(eintrag);
});
zeile.appendChild(wort);
woerter.push({ el: wort, zeichen: liste });
if (i < teile.length - 1) zeile.appendChild(document.createTextNode(' '));
});
});
let modus = ohneHover ? 'welle' : 'naehe';
let zeigerX = -9999, zeigerY = -9999, zeigerAktiv = false;
let welleStart = performance.now();
let auto = null; // Vorführung: gedachter Zeiger
let sichtbar = true, angehalten = false, raf = 0;
function zeigeWerte() {
const t = {
naehe: `<span>Achsen <b>wght ${RUHE} → ${VOLL} · wdth ${BREIT_RUHE} → ${BREIT_VOLL}</b></span><span>Radius <b>${RADIUS_EM} em</b></span><span>Lerp <b>${LERP} / Frame</b></span><span>Kurve <b>smoothstep</b></span>`,
welle: `<span>wght-Stufen <b>${STUFEN.join(' · ')}</b></span><span>wdth <b>${BREIT_RUHE} → ${BREIT_VOLL}</b></span><span>Je Stufe <b>${STUFE_MS} ms</b></span><span>Versatz <b>${VERSATZ_MS} ms</b></span><span>Kurve <b>InOut .645 .045 .355 1</b></span>`,
wort: `<span>Achsen <b>wght ${RUHE} → ${VOLL} · wdth ${BREIT_RUHE} → ${BREIT_VOLL}</b></span><span>Dauer <b>${WORT_MS} ms</b></span><span>Kurve <b>InOut .645 .045 .355 1</b></span>`,
};
werte.innerHTML = reduziert()
? '<span>Reduzierte Bewegung: <b>statisch wght 800 · wdth 62</b></span><span>Hover <b>ohne Verlauf</b></span>'
: t[modus] + '<span>über <b>font-variation-settings</b></span>';
}
function setzeModus(neu) {
modus = neu;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
const jetzt = performance.now();
if (modus === 'welle') welleStart = jetzt;
zeichen.forEach(z => { z.von = z.wert; z.nach = RUHE; z.start = jetzt; });
zeigeWerte();
}
// Wort-Modus: ein Wort fett, alle anderen zurück
function wortAn(index) {
const jetzt = performance.now();
zeichen.forEach(z => {
const ziel = z.wort === index ? VOLL : RUHE;
if (z.nach !== ziel) { z.von = z.wert; z.nach = ziel; z.start = jetzt; }
});
}
function welleWert(t) {
if (t < 0) return STUFEN[0];
const hin = (STUFEN.length - 1) * STUFE_MS; // 2000 ms hin, 2000 ms zurück
const p = t % (2 * hin);
const pos = p < hin ? p : 2 * hin - p;
const i = Math.min(Math.floor(pos / STUFE_MS), STUFEN.length - 2);
const f = inOut((pos - i * STUFE_MS) / STUFE_MS);
return STUFEN[i] + (STUFEN[i + 1] - STUFEN[i]) * f;
}
function bild(jetzt) {
raf = 0;
if (reduziert()) return;
if (auto) auto.schritt(jetzt);
if (modus === 'naehe') {
const fs = parseFloat(getComputedStyle(titel).fontSize);
const radius = RADIUS_EM * fs;
const mitten = zeichen.map(z => { const r = z.el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; });
zeichen.forEach((z, i) => {
let ziel = RUHE;
if (zeigerAktiv) {
const d = Math.hypot(mitten[i][0] - zeigerX, mitten[i][1] - zeigerY);
let n = Math.max(0, 1 - d / radius);
n = n * n * (3 - 2 * n); // smoothstep
ziel = RUHE + (VOLL - RUHE) * n;
}
z.wert += (ziel - z.wert) * LERP;
});
} else if (modus === 'welle') {
zeichen.forEach((z, i) => {
const w = welleWert(jetzt - welleStart - i * VERSATZ_MS);
// weicher Einstieg aus dem vorherigen Zustand
const ein = inOut(Math.min(1, (jetzt - z.start) / STUFE_MS));
z.wert = z.von + (w - z.von) * ein;
});
} else {
zeichen.forEach(z => { z.wert = z.von + (z.nach - z.von) * inOut((jetzt - z.start) / WORT_MS); });
}
zeichen.forEach(z => {
if (Math.abs(z.wert - z.geschrieben) > 0.4) {
// immer beide Achsen gemeinsam setzen, sonst fällt die andere auf ihren Standard zurück
const breite = BREIT_RUHE + (z.wert - RUHE) / (VOLL - RUHE) * (BREIT_VOLL - BREIT_RUHE);
z.el.style.fontVariationSettings = `'wght' ${z.wert.toFixed(1)}, 'wdth' ${breite.toFixed(1)}`;
z.geschrieben = z.wert;
}
});
plane();
}
function plane() { if (!raf && sichtbar && !angehalten && !reduziert()) raf = requestAnimationFrame(bild); }
// Echter Zeiger (Maus, Stift, Finger beim waagerechten Wischen)
held.addEventListener('pointermove', e => {
if (auto) return;
zeigerX = e.clientX; zeigerY = e.clientY; zeigerAktiv = true;
if (modus === 'wort') { const w = e.target.closest('.wort'); wortAn(w ? woerter.findIndex(x => x.el === w) : -1); }
});
held.addEventListener('pointerleave', () => { if (auto) return; zeigerAktiv = false; if (modus === 'wort') wortAn(-1); });
held.addEventListener('pointercancel', () => { if (!auto) zeigerAktiv = false; });
titel.addEventListener('pointerdown', e => {
if (e.pointerType !== 'touch' || modus !== 'wort') return;
const w = e.target.closest('.wort'); wortAn(w ? woerter.findIndex(x => x.el === w) : -1);
});
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-modus]');
if (!b) return;
stoppeVorfuehrung();
setzeModus(b.dataset.modus);
plane();
});
// Außerhalb des Bildes pausieren
new IntersectionObserver(([e]) => { sichtbar = e.isIntersecting; plane(); }).observe(held);
// Mitmachen: auf Touch wird die Zeile in der Bildschirmmitte breit und fett
if (ohneHover) {
const zeilen = document.querySelectorAll('[data-wege] li');
const mitte = new IntersectionObserver(eintraege => eintraege.forEach(e => e.target.classList.toggle('aktiv', e.isIntersecting)), { rootMargin: '-42% 0px -42% 0px' });
zeilen.forEach(z => mitte.observe(z));
}
// Wirkungszahlen: einmal beim Eintritt von wght 300 auf 800
const zahlen = document.querySelector('[data-zahlen]');
new IntersectionObserver((e, io) => { if (e[0].isIntersecting) { zahlen.querySelectorAll('li').forEach(li => li.classList.add('da')); io.disconnect(); } }, { threshold: 0.35 }).observe(zahlen);
// Vorführung: Nähe mit gedachtem Zeiger, dann Welle, dann Wort für Wort
function stoppeVorfuehrung() { auto = null; zeiger.classList.remove('an'); zeigerAktiv = false; }
function vorfuehren() {
stoppeVorfuehrung();
angehalten = false;
if (reduziert()) { zeigeWerte(); return; }
const NAEHE = 6000, WELLE = 6200, WORT = woerter.length * 800 + 600;
let phase = '', beginn = performance.now();
auto = {
schritt(jetzt) {
const t = (jetzt - beginn) % (NAEHE + WELLE + WORT);
// Umriss des tatsächlichen Textes (h1 ist breiter als die Zeilen)
let L = 1e9, R = -1e9, T = 1e9, B = -1e9;
titel.querySelectorAll('.zeile').forEach(z => { const a = z.firstElementChild.getBoundingClientRect(), b = z.lastElementChild.getBoundingClientRect(); L = Math.min(L, a.left); R = Math.max(R, b.right); T = Math.min(T, a.top); B = Math.max(B, b.bottom); });
const W = R - L, H = B - T;
if (t < NAEHE) {
if (phase !== 'naehe') { phase = 'naehe'; setzeModus('naehe'); }
const s = t / 1000;
// entlang der Treppe der drei Zeilen, mit Pendeln zwischen den Zeilen
const fx = 0.5 + 0.44 * Math.sin(s * 1.1 - 1.4);
zeigerX = L + W * fx;
zeigerY = T + H * (0.12 + 0.72 * fx + 0.16 * Math.sin(s * 2.6));
zeigerAktiv = true; zeiger.classList.add('an');
} else if (t < NAEHE + WELLE) {
if (phase !== 'welle') { phase = 'welle'; setzeModus('welle'); zeigerAktiv = false; zeiger.classList.remove('an'); }
} else {
if (phase !== 'wort') { phase = 'wort'; setzeModus('wort'); zeiger.classList.add('an'); }
const i = Math.floor((t - NAEHE - WELLE) / 800);
const w = woerter[Math.min(i, woerter.length - 1)];
if (i < woerter.length) {
const b = w.el.getBoundingClientRect();
const zx = b.left + b.width * 0.55, zy = b.top + b.height * 0.55;
zeigerX += (zx - zeigerX) * 0.18; zeigerY += (zy - zeigerY) * 0.18;
wortAn(woerter.indexOf(w));
} else { wortAn(-1); zeiger.classList.remove('an'); }
}
zeiger.style.transform = `translate(${zeigerX}px, ${zeigerY}px)`;
},
};
plane();
}
function neustart() { stoppeVorfuehrung(); angehalten = false; scrollTo(0, 0); setzeModus(ohneHover ? 'welle' : 'naehe'); plane(); }
function pause() { angehalten = true; cancelAnimationFrame(raf); raf = 0; }
function weiter() { angehalten = false; plane(); }
setzeModus(modus);
addEventListener('load', zeigeWerte); // ?reduziert setzt die Klasse erst nach diesem Skript
document.fonts.ready.then(plane);
})();
</script>
</body>
</html>