Animationsstil „Brutal hart“
Keine Kurve, nur Takt: Zeichen tippen im 40-ms-Raster, ein Verschluss fährt in 32 Stufen, das Bild rendert in sechs Pixelstufen.
Bewegung ohne weiche Kurven. Harte Schnitte, steps()-Animationen, lineare Bewegung, sofortiges Ein- und Ausblenden. Buchstaben erscheinen wie auf einem Terminal: sehr kurze Einzeldauer, fester Versatz. Bilder wechseln ohne Übergang oder bauen sich in Pixel- und Rasterstufen auf. Der Kontrast entsteht über den Rhythmus, nicht über die Kurve.
Fast alle Seiten bewegen sich weich und gefedert. Harte Stufen fallen dagegen sofort auf. Sie wirken roh, ehrlich, technisch oder subkulturell. Tempo und Takt werden selbst zum Gestaltungsmittel.
Wann einsetzen
Musik, Mode-Avantgarde, Kunst, Entwickler-Portfolios, Tech-Kultur, Retro-Futurismus, Boot- und Ladesequenzen, Drops mit Countdown.
Wann nicht
Bei konservativen Zielgruppen, bei Marken, die von weicher Premium-Anmutung leben, und bei Inhalten, die Vertrauen aufbauen müssen (Gesundheit, Finanzen, Beratung).
Varianten
- Terminal-Aufbau: ein Zeichen nach dem anderen, Einzeldauer 20 ms, Versatz 40 ms, blinkender Cursor
- Stufen: Positions- und Größenwechsel mit steps(4) bis steps(32), z. B. ein Verschluss in 32 Stufen
- Harte Bildwechsel im festen Takt (0 ms Übergang)
- Pixel-Render: Bild baut sich in groben bis feinen Rasterstufen auf
Umsetzung
CSS-Token: --ease-hard: steps(1, end); --ease-steps: steps(6, jump-none); --ease-linear: linear; --dur-cut: 0ms; --dur-tick: 20ms; --stagger-char: 40ms. In GSAP: ease "none" oder "steps(6)", duration 0.02, stagger 0.04. Rhythmus über eine feste Taktung, z. B. alle 80 ms. Gemessen: basement.studio Verschluss 1.4s steps(32, end) und Flackern .18s steps(3, end), Jean Dawson Schreibmaschine 2s steps(30, end) mit Cursor .9s steps(2, start), Fiddle.Digital Doppelpunkt 1s steps(1). Achtung: steps(n, end) landet am Ende durch Rundung manchmal auf der vorletzten Stufe. Den Zielzustand nach Ablauf fest setzen.
Robust umsetzen
Telefon. Unverändert. Hover-Effekte durch Tippen ersetzen.
Ohne Bewegung. Harte Schnitte sind meist unkritisch. Schnelles Flackern vermeiden (WCAG 2.3.1: höchstens 3 Blitze pro Sekunde). Zeichenaufbau sofort vollständig zeigen, Blinken und Laufbänder anhalten.
Barrierefrei. Zeichenweise aufgebauter Text bekommt ein aria-label am Container, die Einzelzeichen aria-hidden. Keine Blitze. Blinkende Elemente langsamer als 3 Hz halten.
Code
<!DOCTYPE html>
<!--
Animationsstil „Brutal hart“
Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-brutal-hart/
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>Animationsstil „Brutal hart“ · 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: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-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: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Die Token des Musters: keine Kurve, nur Takt */
--ease-hard: steps(1, end); /* harter Schnitt */
--ease-steps: steps(6, jump-none); /* Positionswechsel in 6 Stufen */
--ease-shutter: steps(32, end); /* Verschluss, 1,4 s */
--ease-flicker: steps(3, end); /* Stufen-Einblendung, 180 ms */
--ease-linear: linear;
--dur-cut: 0ms;
--dur-tick: 20ms;
--dur-step: 80ms;
--stagger-char: 40ms;
--stagger-line: 80ms;
--raster: 8px;
/* Welt: Rezept „Kollektion und Ritual“ (warmes Off-White, Fast-Schwarz, eine Signalfarbe) */
--grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --leise: #6f685f; --signal: #a3402a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', 'Arial', sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
ol, ul { list-style: none; }
.mono { font: 400 11px/1.35 'Fragment Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.leise { color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
/* Kopf: Wortmarke als schwarzer Block, alles andere Mono */
.kopf { display: grid; grid-template-columns: auto 1fr auto auto; align-items: stretch; border-bottom: 1px solid var(--text); min-height: 52px; }
.marke { display: flex; align-items: center; gap: 14px; padding: 0 clamp(16px, 2.4vw, 32px); background: var(--text); color: var(--grund); }
.marke b { font-weight: 500; font-stretch: 125%; font-size: 17px; letter-spacing: .22em; }
.marke span { color: rgba(246, 243, 238, .6); }
.nav { display: flex; align-items: center; gap: clamp(14px, 2vw, 28px); padding: 0 clamp(16px, 2.4vw, 32px); }
.nav a { color: inherit; text-decoration: none; }
.nav a[aria-current] { text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 1px; }
.modi { display: flex; align-items: center; gap: 0; border-left: 1px solid var(--text); padding: 0 clamp(12px, 1.6vw, 20px); }
.modi > span { margin-right: 10px; }
.modi button { height: 28px; padding: 0 10px; border: 1px solid var(--text); margin-left: -1px; letter-spacing: inherit; text-transform: inherit; font-size: 11px; }
.modi button:hover { background: var(--flaeche); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); }
.korb { display: flex; align-items: center; padding: 0 clamp(16px, 2.4vw, 32px); border-left: 1px solid var(--text); }
/* Bühne: Text links, Bild rechts, getrennt durch eine Linie */
.buehne { display: grid; grid-template-columns: minmax(0, 1fr) auto; border-bottom: 1px solid var(--text); }
.text { display: grid; grid-template-rows: auto 1fr auto auto; gap: clamp(18px, 2.6vw, 32px); padding: clamp(18px, 2.4vw, 32px); min-width: 0; }
.rubrik { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.rubrik .signal { color: var(--signal); }
.titel { align-self: center; font-weight: 400; font-stretch: 125%; font-size: clamp(40px, 7vw, 116px); line-height: 1; letter-spacing: -.01em; text-transform: uppercase; }
.titel .zeile { display: block; overflow: hidden; padding-bottom: .04em; }
.titel .innen { display: block; white-space: nowrap; }
.titel .z { display: inline-block; white-space: pre; }
.cursor { display: none; width: .52em; height: .78em; margin-left: .06em; background: var(--signal); vertical-align: -.02em; }
.cursor.an { display: inline-block; animation: blinker .9s steps(2, start) infinite; } /* Jean Dawson: blinker .9s steps(2, start) */
@keyframes blinker { to { visibility: hidden; } }
.produkt { display: grid; gap: 10px; max-width: 46ch; }
.produkt .formel { font-size: clamp(15px, 1.15vw, 17px); line-height: 1.45; font-weight: 400; }
.produkt .formel b { font-weight: 500; }
.produkt .formel .preis { font-variant-numeric: tabular-nums; white-space: nowrap; }
.aktion { display: flex; flex-wrap: wrap; gap: 0; align-items: stretch; }
.aktion button { height: 46px; padding: 0 18px; border: 1px solid var(--text); margin-right: -1px; letter-spacing: inherit; text-transform: inherit; font-size: 11px; }
.aktion .kauf { background: var(--text); color: var(--grund); }
.aktion .kauf:hover { background: var(--signal); border-color: var(--signal); }
.aktion .weiter:hover { background: var(--text); color: var(--grund); }
.uhr { display: flex; align-items: center; gap: 10px; padding-left: 18px; font-variant-numeric: tabular-nums; }
.uhr .doppel { animation: blink 1s steps(1) infinite; } /* Fiddle.Digital: blink 1s steps(1) */
@keyframes blink { 50% { opacity: 0; } }
.index { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--text); }
.index li + li { border-left: 1px solid var(--linie); }
.index button { width: 100%; display: grid; gap: 6px; text-align: left; padding: 12px 12px 14px; }
.index .name { font-size: 14px; font-weight: 500; hyphens: manual; }
.index .preis { font-variant-numeric: tabular-nums; }
.index button:hover, .index button[aria-current='true'] { background: var(--text); color: var(--grund); }
.index button:hover .leise, .index button[aria-current='true'] .leise { color: rgba(246, 243, 238, .62); }
/* Bild: Freisteller auf Off-White-Fläche, 4:5 */
.bildspalte { border-left: 1px solid var(--text); padding: clamp(18px, 2.4vw, 32px); display: grid; gap: 12px; align-content: start; }
.karte { position: relative; aspect-ratio: 4 / 5; height: min(calc(100svh - 236px), 720px); min-height: 380px; background: var(--flaeche); overflow: hidden; cursor: pointer; }
.karte canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.karte .ecke { position: absolute; z-index: 3; padding: 10px 12px; pointer-events: none; }
.karte .oben { top: 0; left: 0; right: 0; display: flex; justify-content: space-between; }
.karte .unten { bottom: 0; left: 0; }
.tipp { display: none; }
.verschluss { position: absolute; inset: 0; z-index: 2; background: var(--text); color: var(--grund); display: none; place-items: end start; padding: 12px; }
.verschluss.an { display: grid; }
.fortschritt { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; }
.balken { --n: 32; height: 10px; position: relative; background: repeating-linear-gradient(90deg, var(--linie) 0 calc(100% / var(--n) - 2px), transparent calc(100% / var(--n) - 2px) calc(100% / var(--n))); }
.balken i { position: absolute; inset: 0; background: repeating-linear-gradient(90deg, var(--text) 0 calc(100% / var(--n) - 2px), transparent calc(100% / var(--n) - 2px) calc(100% / var(--n))); clip-path: inset(0 0 0 0); }
/* Laufband: springt in ganzen 8-px-Schritten, alle 80 ms */
.band { background: var(--text); color: var(--grund); overflow: hidden; height: 38px; display: flex; align-items: center; }
.band .spur { display: flex; white-space: nowrap; will-change: transform; }
.band span { font-stretch: 125%; font-size: 13px; font-weight: 400; letter-spacing: .14em; text-transform: uppercase; padding-right: 2.2em; }
.band span em { font-style: normal; color: #e0866f; }
/* Laufband anhalten (WCAG 2.2.2): harter Block am rechten Ende des Bands */
.band-halter { position: relative; }
.band-halt { position: absolute; top: 0; right: 0; height: 38px; padding: 0 16px; background: var(--text); color: var(--grund); border-left: 1px solid rgba(246, 243, 238, .35); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; }
.band-halt:hover, .band-halt[aria-pressed="true"] { background: var(--signal); }
.band-halt:focus-visible { outline: 2px solid var(--signal); outline-offset: -6px; }
.vorschau .band-halt, :root.reduziert .band-halt { display: none; }
.angehalten .cursor.an, .angehalten .uhr .doppel { animation-play-state: paused; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 12px clamp(18px, 2.4vw, 32px) 16px; color: var(--leise); }
.werte b { color: var(--text); font-weight: 400; }
.werte span { white-space: nowrap; }
/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .karte { height: min(calc(100svh - 166px), 720px); }
.vorschau .werte { display: none; }
@media (max-width: 1100px) {
.nav { display: none; }
.kopf { grid-template-columns: auto 1fr auto; }
.modi { justify-content: flex-end; border-left: 0; }
.lang { display: none; }
}
@media (max-width: 1100px) and (min-width: 761px) {
.index { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.index li:nth-child(3) { border-left: 0; }
.index li:nth-child(n+3) { border-top: 1px solid var(--linie); }
.vorschau .index { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.vorschau .index li:nth-child(3) { border-left: 1px solid var(--linie); }
.vorschau .index li:nth-child(n+3) { border-top: 0; }
.vorschau .index .preis { display: none; }
.vorschau .uhr { display: none; }
}
@media (max-width: 760px) {
.kopf { grid-template-columns: auto 1fr; }
.marke span { display: none; }
.korb { justify-content: flex-end; border-left: 0; }
.modi { grid-column: 1 / -1; grid-row: 2; border-top: 1px solid var(--text); justify-content: flex-start; padding: 10px 16px; }
.modi button { flex: 1; }
.buehne { grid-template-columns: 1fr; }
.text { display: contents; }
.rubrik { padding: 16px 16px 0; order: 0; }
.titel { order: 1; }
.bildspalte { order: 2; }
.produkt { order: 3; }
.aktion { order: 4; }
.index { order: 5; }
.titel { padding: 0 16px; font-size: clamp(38px, 12.4vw, 60px); }
.bildspalte { border-left: 0; padding: 0 16px; }
.karte { height: auto; width: 100%; min-height: 0; }
.produkt, .aktion { padding: 0 16px; }
.aktion button { flex: 1 1 auto; }
.uhr { padding: 12px 0 0; width: 100%; }
.index { grid-template-columns: 1fr 1fr; margin-top: 4px; }
.index li:nth-child(3) { border-left: 0; }
.index li:nth-child(n+3) { border-top: 1px solid var(--linie); }
.buehne > * + * { margin-top: 18px; }
.text > * { margin-top: 18px; }
.werte span { white-space: normal; }
}
@media (hover: none) {
.tipp { display: block; }
.modi button:hover { background: none; }
.modi button[aria-pressed='true'] { background: var(--text); }
.index button:hover { background: none; color: inherit; }
.index button:hover .leise { color: var(--leise); }
.index button[aria-current='true'] { background: var(--text); color: var(--grund); }
.index button[aria-current='true'] .leise { color: rgba(246, 243, 238, .62); }
.aktion .weiter:hover { background: none; color: inherit; }
}
/* Reduzierte Bewegung: kein Blinken, kein Laufband, Zustände springen sofort */
:root.reduziert .cursor.an, :root.reduziert .uhr .doppel { animation: none; }
:root.reduziert .band .spur { transform: none !important; }
@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 mono" href="#" aria-label="Klybeck Uniform, Startseite" onclick="return false"><b>KLYBECK</b><span>Uniform · Basel</span></a>
<nav class="nav mono" aria-label="Hauptnavigation">
<a href="#" aria-current="page" onclick="return false">Kollektion 04</a>
<a href="#" onclick="return false">Lookbook</a>
<a href="#" onclick="return false">Werkstatt</a>
<a href="#" onclick="return false">Laden Klybeckquai</a>
</nav>
<div class="modi mono" role="group" aria-label="Variante der Bewegung">
<span class="leise">Takt</span>
<button type="button" data-modus="terminal" aria-pressed="true">Terminal</button>
<button type="button" data-modus="stufen" aria-pressed="false">Stufen</button>
<button type="button" data-modus="pixel" aria-pressed="false">Pixel</button>
</div>
<p class="korb mono">Korb (0)</p>
</header>
<main class="buehne">
<div class="text">
<p class="rubrik mono"><span>Kollektion 04</span><span class="leise">Rhein, Kran, Uniform</span><span class="signal" data-zaehler>Look 01 / 04</span></p>
<h1 class="titel" data-titel aria-label="Nebelmantel"></h1>
<div class="produkt" aria-live="polite">
<p class="mono leise" data-code>KL-04-01 · Gr. 44–56</p>
<p class="formel" data-formel></p>
</div>
<div class="aktion mono">
<button type="button" class="kauf" data-kauf>In den Korb</button>
<button type="button" class="weiter" data-weiter>Nächster Look</button>
<p class="uhr leise" data-uhr aria-label="Zeit bis zum nächsten Drop"></p>
</div>
<ol class="index" data-index></ol>
</div>
<div class="bildspalte">
<div class="karte" data-karte role="img" aria-label="Freisteller Nebelmantel">
<canvas data-bild aria-hidden="true"></canvas>
<div class="verschluss mono" data-verschluss aria-hidden="true">Lade Look</div>
<div class="ecke oben mono" aria-hidden="true"><span data-ecke-look>Look 01</span><span data-ecke-code>KL-04-01</span></div>
<p class="ecke unten mono tipp" aria-hidden="true">Bild antippen: nächster Look</p>
</div>
<div class="fortschritt mono" aria-hidden="true"><span class="leise">Aufbau</span><span class="balken"><i data-balken></i></span><span data-takt>32 / 32</span></div>
</div>
</main>
<div class="band-halter">
<div class="band" aria-hidden="true"><div class="spur" data-spur></div></div>
<button type="button" class="band-halt mono" data-halt aria-pressed="false" aria-label="Laufband anhalten">Stopp</button>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
<script>
(() => {
const H = document.documentElement;
const reduziert = () => H.classList.contains('reduziert');
const $ = s => document.querySelector(s);
/* ---------- Inhalte: Kollektion 04 von Klybeck Uniform ---------- */
const LOOKS = [
{ code: 'KL-04-01', groesse: 'Gr. 44–56', zeilen: ['Nebel', 'Mantel'], name: 'Nebelmantel', formel: 'Baumwolle gewachst 100 % + Rosshaar-Einlage', preis: '1’280' },
{ code: 'KL-04-02', groesse: 'Gr. 28–38', zeilen: ['Kran', 'Hose'], name: 'Kranhose', formel: 'Canvas 14 oz, Baumwolle 70 % + Leinen 30 %', preis: '390' },
{ code: 'KL-04-03', groesse: 'Gr. XS–XL', zeilen: ['Pier', 'Strick'], name: 'Pierstrick', formel: 'Merino 80 % + Yak 20 %, Zopfmuster', preis: '460' },
{ code: 'KL-04-04', groesse: '38 × 42 cm', zeilen: ['Schleuse', 'Tasche'], name: 'Schleusentasche', formel: 'Rindsleder pflanzlich gegerbt 100 %', preis: '540' },
];
/* ---------- Werte je Variante (exakt die verwendeten) ---------- */
const T = {
zeichen: 20, versatzZeichen: 40, // Terminal: Codrops 3DCarousel, duration .02 / stagger .04
verschluss: 1400, verschlussStufen: 32, // basement: 1.4s steps(32, end), translateY 0 → 100 %
zeile: 480, zeileStufen: 6, versatzZeile: 80,
takt: 80, raster: [48, 32, 20, 12, 6, 1], // Pixel: 6 Stufen à 80 ms
flackern: 180, // basement: .18s steps(3, end)
band: 8, // Laufband: 8 px je 80 ms
vorfuehrung: 3200,
};
const WERTE = {
terminal: [['Zeichen', '20 ms linear, 0 → 1'], ['Versatz', '40 ms je Zeichen'], ['Bild', '0 ms, harter Schnitt'], ['Cursor', '900 ms steps(2, start)']],
stufen: [['Verschluss', '1400 ms steps(32, end)'], ['Weg', 'translateY 0 → 100 %'], ['Zeilen', '480 ms steps(6, jump-none)'], ['Versatz', '80 ms je Zeile']],
pixel: [['Raster', '48 → 32 → 20 → 12 → 6 → 1 px'], ['Takt', '80 ms je Stufe'], ['Text', '180 ms steps(3, end)']],
};
const IMMER = [['Laufband', '8 px je 80 ms'], ['Uhr', '1000 ms steps(1)']];
const titel = $('[data-titel]'), formel = $('[data-formel]'), codeEl = $('[data-code]');
const karte = $('[data-karte]'), bild = $('[data-bild]'), verschluss = $('[data-verschluss]');
const balken = $('[data-balken]'), taktEl = $('[data-takt]'), werteEl = $('[data-werte]');
const indexEl = $('[data-index]'), spur = $('[data-spur]'), uhr = $('[data-uhr]');
const cursor = document.createElement('span'); cursor.className = 'cursor'; cursor.setAttribute('aria-hidden', 'true');
indexEl.innerHTML = LOOKS.map((l, i) => `<li><button type="button" data-look="${i}" aria-current="false"><span class="mono leise">${String(i + 1).padStart(2, '0')}<span class="lang"> · ${l.code}</span></span><span class="name">${l.name.replace('Schleusen', 'Schleusen­')}</span><span class="mono preis leise">CHF ${l.preis}</span></button></li>`).join('');
/* ---------- Bildwelt: Freisteller auf Off-White, im Canvas gezeichnet ---------- */
const QW = 600, QH = 750; // Quelle 4:5, Zeichenraster 300 × 375 (×2)
const quellen = [];
let rauschen = null;
function zufall(seed) { return () => (seed = (seed * 16807) % 2147483647) / 2147483647; }
function ton(hex, f) { // f > 0 heller, f < 0 dunkler
const n = parseInt(hex.slice(1), 16), r = n >> 16, g = (n >> 8) & 255, b = n & 255;
const m = c => Math.round(f > 0 ? c + (255 - c) * f : c * (1 + f));
return `rgb(${m(r)},${m(g)},${m(b)})`;
}
function rauschenBauen() {
const c = document.createElement('canvas'); c.width = QW; c.height = QH;
const x = c.getContext('2d'), d = x.createImageData(QW, QH), z = zufall(7);
for (let i = 0; i < d.data.length; i += 4) { const v = z() * 255; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 16; }
x.putImageData(d, 0, 0);
return c;
}
const P = s => new Path2D(s);
const ENTWURF = [
{ // Nebelmantel
farbe: '#2b2927',
koerper: ['M112 42 L188 42 L212 62 L230 342 L70 342 L88 62 Z', 'M88 62 L64 74 L42 252 L66 259 L86 130 Z', 'M212 62 L236 74 L258 252 L234 259 L214 130 Z'],
dunkel: ['M112 42 L150 144 L138 150 L106 58 Z', 'M188 42 L150 144 L162 150 L194 58 Z', 'M86 192 L214 192 L215 206 L85 206 Z', 'M42 252 L66 259 L64 268 L40 261 Z', 'M258 252 L234 259 L236 268 L260 261 Z'],
linien: ['M150 152 L150 342', 'M96 236 L124 232', 'M204 236 L176 232', 'M88 62 L86 130', 'M212 62 L214 130'],
naht: ['M154 160 L154 342', 'M74 330 L226 330'],
knoepfe: [[160, 172], [160, 228], [160, 270]],
},
{ // Kranhose
farbe: '#2f3d58',
koerper: ['M98 34 L202 34 L215 342 L163 342 L150 122 L137 342 L85 342 Z'],
dunkel: ['M98 34 L202 34 L203 52 L97 52 Z', 'M180 168 L208 166 L210 212 L182 214 Z', 'M85 326 L137 326 L137 342 L85 342 Z', 'M163 326 L215 326 L215 342 L163 342 Z'],
linien: ['M122 52 L112 326', 'M178 52 L188 326', 'M150 52 L150 116', 'M100 52 L118 96'],
naht: ['M146 52 Q147 98 150 116', 'M182 176 L208 175', 'M88 318 L136 318', 'M164 318 L212 318'],
knoepfe: [[150, 43]],
},
{ // Pierstrick
farbe: '#cdbfa6',
koerper: ['M106 62 Q150 86 194 62 L228 78 L272 236 L248 246 L214 150 L214 322 L86 322 L86 150 L52 246 L28 236 L72 78 Z'],
dunkel: ['M106 62 Q150 98 194 62 Q150 86 106 62 Z', 'M86 304 L214 304 L214 322 L86 322 Z', 'M272 236 L248 246 L244 234 L266 224 Z', 'M28 236 L52 246 L56 234 L34 224 Z'],
linien: ['M86 150 L86 304', 'M214 150 L214 304'],
naht: [],
zopf: [124, 150, 176],
knoepfe: [],
},
{ // Schleusentasche
farbe: '#8a4a2b',
henkel: 'M112 152 C110 66 190 66 188 152',
koerper: ['M70 152 L230 152 L242 344 L58 344 Z'],
dunkel: ['M70 152 L230 152 L231 170 L69 170 Z', 'M138 208 L162 208 L162 236 L138 236 Z'],
linien: [],
naht: ['M76 178 L224 178', 'M74 186 L66 336', 'M226 186 L234 336', 'M66 336 L234 336'],
knoepfe: [[112, 160], [188, 160]],
},
];
function malen(e) {
const c = document.createElement('canvas'); c.width = QW; c.height = QH;
const x = c.getContext('2d');
x.fillStyle = '#ebe7e0'; x.fillRect(0, 0, QW, QH);
// weiches Licht von links oben, wie im Studio
const licht = x.createRadialGradient(150, 120, 40, 260, 300, 620);
licht.addColorStop(0, 'rgba(255,255,255,.55)'); licht.addColorStop(1, 'rgba(255,255,255,0)');
x.fillStyle = licht; x.fillRect(0, 0, QW, QH);
x.setTransform(2, 0, 0, 2, 0, 0);
const koerper = new Path2D(); e.koerper.forEach(d => koerper.addPath(P(d)));
// Schatten des Freistellers
x.save(); x.filter = 'blur(9px)'; x.translate(5, 8); x.fillStyle = 'rgba(40,30,20,.28)'; x.fill(koerper);
if (e.henkel) { x.lineWidth = 9; x.strokeStyle = 'rgba(40,30,20,.24)'; x.stroke(P(e.henkel)); }
x.restore();
if (e.henkel) { x.lineWidth = 9; x.lineCap = 'round'; x.strokeStyle = ton(e.farbe, -.18); x.stroke(P(e.henkel)); x.lineWidth = 1; x.setLineDash([2.5, 2.5]); x.strokeStyle = 'rgba(255,240,220,.35)'; x.stroke(P(e.henkel)); x.setLineDash([]); }
// Stoff mit Verlauf
const v = x.createLinearGradient(40, 40, 270, 350);
v.addColorStop(0, ton(e.farbe, .16)); v.addColorStop(.55, e.farbe); v.addColorStop(1, ton(e.farbe, -.22));
x.fillStyle = v; x.fill(koerper);
x.save(); x.clip(koerper);
// Falten: weiche helle und dunkle Bahnen
x.filter = 'blur(7px)';
const z = zufall(e.farbe.length * 97 + e.koerper[0].length);
for (let i = 0; i < 7; i++) {
x.strokeStyle = i % 2 ? 'rgba(0,0,0,.16)' : 'rgba(255,255,255,.12)'; x.lineWidth = 6 + z() * 10;
const sx = 60 + z() * 180; x.beginPath(); x.moveTo(sx, 40); x.bezierCurveTo(sx + 30 * (z() - .5), 150, sx + 40 * (z() - .5), 250, sx + 50 * (z() - .5), 350); x.stroke();
}
x.filter = 'none';
if (e.zopf) { // Strick: Rippen und Zöpfe
x.strokeStyle = 'rgba(60,45,25,.10)'; x.lineWidth = 1;
for (let y = 66; y < 322; y += 3.2) { x.beginPath(); x.moveTo(20, y); x.lineTo(280, y); x.stroke(); }
x.strokeStyle = 'rgba(60,45,25,.28)'; x.lineWidth = 1.6;
e.zopf.forEach(cx => { for (let y = 104; y < 300; y += 14) { x.beginPath(); x.moveTo(cx - 7, y); x.quadraticCurveTo(cx + 9, y + 7, cx - 7, y + 14); x.moveTo(cx + 7, y); x.quadraticCurveTo(cx - 9, y + 7, cx + 7, y + 14); x.stroke(); } });
x.strokeStyle = 'rgba(60,45,25,.18)'; x.lineWidth = 1;
for (let xx = 88; xx < 214; xx += 4) { x.beginPath(); x.moveTo(xx, 306); x.lineTo(xx, 322); x.stroke(); }
}
x.restore();
x.fillStyle = ton(e.farbe, -.3); e.dunkel.forEach(d => { x.globalAlpha = .55; x.fill(P(d)); }); x.globalAlpha = 1;
x.lineWidth = 1.2; x.strokeStyle = 'rgba(0,0,0,.32)'; e.linien.forEach(d => x.stroke(P(d)));
x.lineWidth = .9; x.setLineDash([2.4, 2.2]); x.strokeStyle = e.farbe === '#cdbfa6' ? 'rgba(60,45,25,.4)' : 'rgba(255,245,230,.32)'; e.naht.forEach(d => x.stroke(P(d))); x.setLineDash([]);
e.knoepfe.forEach(([kx, ky]) => { x.beginPath(); x.arc(kx, ky, 3.6, 0, 7); x.fillStyle = '#1a1816'; x.fill(); x.beginPath(); x.arc(kx - .8, ky - .8, 1.2, 0, 7); x.fillStyle = 'rgba(255,255,255,.35)'; x.fill(); });
x.setTransform(1, 0, 0, 1, 0, 0);
x.drawImage(rauschen, 0, 0);
return c;
}
/* ---------- Bild zeichnen, optional gerastert ---------- */
const ctx = bild.getContext('2d');
const klein = document.createElement('canvas'), kctx = klein.getContext('2d');
let jetzt = { look: 0, block: 1 };
function zeichne(look, block) {
jetzt = { look, block };
const w = bild.width, h = bild.height, q = quellen[look];
if (!w || !q) return;
if (block <= 1) { ctx.imageSmoothingEnabled = true; ctx.drawImage(q, 0, 0, w, h); return; }
const b = block * (w / bild.clientWidth || 1);
klein.width = Math.max(1, Math.ceil(w / b)); klein.height = Math.max(1, Math.ceil(h / b));
kctx.imageSmoothingEnabled = true; kctx.drawImage(q, 0, 0, klein.width, klein.height);
ctx.imageSmoothingEnabled = false; ctx.drawImage(klein, 0, 0, klein.width * b, klein.height * b);
}
function groesse() {
const r = Math.min(devicePixelRatio || 1, 2);
bild.width = Math.round(bild.clientWidth * r); bild.height = Math.round(bild.clientHeight * r);
zeichne(jetzt.look, jetzt.block);
}
/* ---------- Überschrift, Zeichen für Zeichen ---------- */
function titelSetzen(l) {
titel.setAttribute('aria-label', l.name);
titel.innerHTML = l.zeilen.map(z => `<span class="zeile" aria-hidden="true"><span class="innen">${[...z].map(c => `<span class="z">${c}</span>`).join('')}</span></span>`).join('');
cursor.classList.remove('an');
return [...titel.querySelectorAll('.z')];
}
/* ---------- Ablauf ---------- */
let modus = 'terminal', aktuell = 0, lauf = 0, timer = [];
const spaeter = (fn, ms) => { const id = setTimeout(fn, ms); timer.push(id); return id; };
const animationen = new Set();
// Am Ende den Zielzustand fest setzen: steps(n, end) trifft am Rand sonst durch Rundung die vorletzte Stufe.
const anim = (el, kf, opt) => {
const a = el.animate(kf, { fill: 'both', ...opt }); animationen.add(a);
a.finished.then(() => { animationen.delete(a); const { offset, ...ziel } = kf[kf.length - 1]; Object.assign(el.style, ziel); a.cancel(); }, () => animationen.delete(a));
return a;
};
function werteZeigen() {
if (reduziert()) { werteEl.innerHTML = '<span>Reduzierte Bewegung: <b>alles sofort, 0 ms</b></span><span>Kein Blinken, kein Laufband</span>'; return; }
werteEl.innerHTML = [...WERTE[modus], ...IMMER].map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('');
}
function fortschritt(dauer, stufen) {
taktEl.textContent = `${stufen} Stufen`;
balken.parentNode.style.setProperty('--n', stufen); // ein Feld je Stufe
if (reduziert() || !dauer) { balken.style.clipPath = 'inset(0 0 0 0)'; return; }
anim(balken, [{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0% 0 0)' }], { duration: dauer, easing: `steps(${stufen}, end)` });
}
function zeige(i, m = modus) {
lauf++; const meinLauf = lauf;
timer.forEach(clearTimeout); timer = [];
animationen.forEach(a => a.cancel()); animationen.clear();
verschluss.classList.remove('an');
aktuell = (i + LOOKS.length) % LOOKS.length; modus = m;
const l = LOOKS[aktuell], nr = String(aktuell + 1).padStart(2, '0');
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
document.querySelectorAll('[data-look]').forEach(b => b.setAttribute('aria-current', String(+b.dataset.look === aktuell)));
$('[data-zaehler]').textContent = `Look ${nr} / 04`;
$('[data-ecke-look]').textContent = `Look ${nr}`;
$('[data-ecke-code]').textContent = l.code;
$('[data-kauf]').textContent = `In den Korb · CHF ${l.preis}`;
karte.setAttribute('aria-label', `Freisteller ${l.name}`);
codeEl.textContent = `${l.code} · ${l.groesse}`;
formel.innerHTML = `<b>${l.name}</b> · ${l.formel} · <span class="preis">CHF ${l.preis}</span>`;
werteZeigen();
const zeichen = titelSetzen(l);
const produkt = [codeEl, formel];
if (reduziert()) { // sofort vollständig
zeichne(aktuell, 1); fortschritt(0, modus === 'stufen' ? 32 : modus === 'pixel' ? 6 : zeichen.length);
return;
}
const flackern = (el, delay) => anim(el, [{ opacity: 0 }, { opacity: 1 }], { duration: T.flackern, delay, easing: 'steps(3, end)' });
if (modus === 'terminal') {
zeichne(aktuell, 1); // Bild: harter Schnitt, 0 ms
zeichen.forEach((z, k) => anim(z, [{ opacity: 0 }, { opacity: 1 }], { duration: T.zeichen, delay: k * T.versatzZeichen, easing: 'linear' }));
const setzeCursor = k => { const z = zeichen[k]; z.after(cursor); };
setzeCursor(0); cursor.style.visibility = '';
cursor.classList.remove('an'); cursor.style.display = 'inline-block';
zeichen.forEach((z, k) => spaeter(() => { if (meinLauf === lauf) setzeCursor(k); }, k * T.versatzZeichen));
const ende = zeichen.length * T.versatzZeichen;
spaeter(() => { if (meinLauf !== lauf) return; cursor.style.display = ''; zeichen[zeichen.length - 1].after(cursor); cursor.classList.add('an'); }, ende);
produkt.forEach((el, k) => flackern(el, ende + k * T.versatzZeile));
fortschritt(ende, zeichen.length);
}
if (modus === 'stufen') {
zeichne(aktuell, 1);
verschluss.textContent = `Lade ${l.code}`;
verschluss.classList.add('an'); // harter Schnitt auf Schwarz
anim(verschluss, [{ transform: 'translateY(0)' }, { transform: 'translateY(100%)' }], { duration: T.verschluss, easing: 'steps(32, end)' })
.finished.then(() => { if (meinLauf === lauf) verschluss.classList.remove('an'); }, () => {});
titel.querySelectorAll('.innen').forEach((el, k) => anim(el, [{ transform: 'translateY(105%)' }, { transform: 'translateY(0)' }], { duration: T.zeile, delay: k * T.versatzZeile, easing: 'steps(6, jump-none)' }));
produkt.forEach((el, k) => anim(el, [{ transform: 'translateY(16px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: T.zeile, delay: T.zeile + k * T.versatzZeile, easing: 'steps(6, jump-none)' }));
fortschritt(T.verschluss, 32);
}
if (modus === 'pixel') {
T.raster.forEach((b, k) => spaeter(() => { if (meinLauf === lauf) zeichne(aktuell, b); }, k * T.takt));
zeichne(aktuell, T.raster[0]);
flackern(titel.querySelectorAll('.innen')[0], 0);
flackern(titel.querySelectorAll('.innen')[1], T.versatzZeile);
produkt.forEach((el, k) => flackern(el, T.raster.length * T.takt + k * T.versatzZeile));
fortschritt(T.raster.length * T.takt, 6);
}
}
/* ---------- Laufband in 8-px-Schritten ---------- */
const BAND = ['Kollektion 04 <em>·</em> Rhein, Kran, Uniform', 'Drop jeden Samstag 18:00', 'Laden Klybeckquai 12, 4057 Basel', 'Genäht in Basel und Biel', 'Reparatur ein Leben lang'];
spur.innerHTML = Array(2).fill(BAND.map(t => `<span>${t}</span>`).join('')).join('');
let bandX = 0, bandUhr = null;
function bandStart() {
clearInterval(bandUhr);
if (reduziert()) return;
bandUhr = setInterval(() => {
if (reduziert()) return;
const breite = spur.scrollWidth / 2;
bandX -= T.band; if (-bandX >= breite) bandX += Math.floor(breite / T.band) * T.band;
spur.style.transform = `translate3d(${bandX}px,0,0)`;
}, T.takt);
}
/* ---------- Drop-Uhr: nächster Samstag 18:00 ---------- */
function uhrStellen() {
const n = new Date(), ziel = new Date(n);
ziel.setHours(18, 0, 0, 0); ziel.setDate(n.getDate() + ((6 - n.getDay() + 7) % 7));
if (ziel <= n) ziel.setDate(ziel.getDate() + 7);
let s = Math.floor((ziel - n) / 1000);
const t = Math.floor(s / 86400); s %= 86400;
const p = v => String(v).padStart(2, '0');
const d = '<span class="doppel">:</span>';
uhr.innerHTML = `Drop Sa 18:00 · ${t} T ${p(Math.floor(s / 3600))}${d}${p(Math.floor(s % 3600 / 60))}${d}${p(s % 60)}`;
}
uhrStellen(); setInterval(uhrStellen, 1000);
/* ---------- Bedienung ---------- */
let schleife = null;
document.querySelector('.modi').addEventListener('click', e => { const b = e.target.closest('[data-modus]'); if (!b) return; halt(); zeige(aktuell, b.dataset.modus); });
indexEl.addEventListener('click', e => { const b = e.target.closest('[data-look]'); if (!b) return; halt(); zeige(+b.dataset.look); });
$('[data-weiter]').addEventListener('click', () => { halt(); zeige(aktuell + 1); });
karte.addEventListener('click', () => { halt(); zeige(aktuell + 1); });
$('[data-kauf]').addEventListener('click', e => { const b = e.currentTarget; b.textContent = 'Im Korb · 1'; document.querySelector('.korb').textContent = 'Korb (1)'; });
new ResizeObserver(groesse).observe(bild);
/* ---------- Vorführung: jede Variante einen Look lang ---------- */
const REIHE = ['terminal', 'stufen', 'pixel'];
function halt() { clearTimeout(schleife); schleife = null; }
function vorfuehren() {
halt();
let k = 0;
const schritt = () => { zeige(k % LOOKS.length, REIHE[k % REIHE.length]); k++; schleife = setTimeout(schritt, T.vorfuehrung); };
schritt();
}
function neustart() { halt(); scrollTo(0, 0); zeige(0, modus); }
const haltKnopf = $('[data-halt]');
function haltZeigen(an) { H.classList.toggle('angehalten', an); haltKnopf.setAttribute('aria-pressed', String(an)); haltKnopf.textContent = an ? 'Lauf' : 'Stopp'; haltKnopf.setAttribute('aria-label', an ? 'Laufband abspielen' : 'Laufband anhalten'); }
function pause() { halt(); clearInterval(bandUhr); bandUhr = null; animationen.forEach(a => a.pause()); haltZeigen(true); }
function weiter() { animationen.forEach(a => a.play()); bandStart(); haltZeigen(false); if (H.classList.contains('vorschau') && !schleife) vorfuehren(); }
haltKnopf.addEventListener('click', () => (haltKnopf.getAttribute('aria-pressed') === 'true' ? weiter : pause)());
rauschen = rauschenBauen();
ENTWURF.forEach(e => quellen.push(malen(e)));
groesse();
bandStart();
document.fonts.ready.then(() => { if (!schleife) zeige(0, 'terminal'); });
})();
</script>
</body>
</html>