Bento-Grid mit lebenden Zellen
Vier-Spalten-Raster mit sieben Kacheln für ein Kölner Studio: Bildwechsel, Live-Uhr, Zähler, Stimmen, Mini-Website und sich konstruierendes Logo, gestaffelt aufgebaut.
Eine Sektion wird als Raster aus unterschiedlich großen, abgerundeten Kacheln gebaut, etwa 4 Spalten mit Kacheln von 1×1, 2×1 und 2×2. Die wichtigste Aussage bekommt die größte Kachel: Größe signalisiert Bedeutung, nicht Position. Jede Kachel ist eine abgeschlossene Idee aus Visual (60 bis 70 % der Höhe), kurzer Headline und höchstens einer Zeile Text. Award-Niveau entsteht erst durch lebende Zellen: Mikroanimationen, die beim Hover oder bei Sichtbarkeit die Funktion vorführen (Mini-UI, Zähler, Uhr, Video), statt statischer Icons. Beim Einscrollen bauen sich die Kacheln gestaffelt auf.
Viele parallele Aussagen werden auf einen Blick scanbar, die Größenhierarchie lenkt das Auge. Lebende Zellen beweisen, statt zu behaupten.
Wann einsetzen
Feature-Übersichten mit 6 bis 9 Aussagen, Über-uns- und Über-mich-Steckbriefe (Ort, Werkzeuge, Kunden, Zitat, Foto), Leistungsübersichten von Studios.
Wann nicht
Bei Inhalten mit linearer Abfolge oder Tiefe (Prozess, Geschichte). Wenn eine Aussage klar dominiert, dann besser ein klassischer Hero. Wenn Kacheln nur mit Füllinhalt gefüllt würden: Generische Bentos gelten 2026 als Template-Look.
Varianten
- Feature- oder Leistungs-Bento mit Mini-UIs in den Zellen
- Über-uns-Bento mit Foto, Standort-Uhr, Kennzahl, Zitat und Werkzeugen
- Bento mit Hintergrundvideo in einzelnen Zellen
- Bento, das beim Scrollen aus einer großen Kachel aufbricht (Grid-Assembly)
- Bento mit Bildwechsel oder Slider in einzelnen Zellen
Umsetzung
CSS Grid mit grid-template-columns: repeat(4, 1fr) und grid-auto-rows: minmax(200px, auto) oder festen Zeilen; Kacheln per grid-column: span 2 und grid-row: span 2 oder benannte grid-template-areas. Der Gap ist überall gleich (Raycast: 30px, mobil 15px). Kacheln als Container anlegen (container-type: inline-size), damit sich Inhalte an der Kachel statt am Viewport ausrichten. Mikroanimationen nur bei Sichtbarkeit starten (IntersectionObserver oder ScrollTrigger), am besten über einen gemeinsamen requestAnimationFrame-Takt. Videos lazy laden, muted und playsinline. Mobil nach Wichtigkeit ordnen (DOM-Reihenfolge), große Kacheln über die volle Breite, kleine paarweise.
Robust umsetzen
Telefon. Zwei- oder einspaltiger Stapel mit unterschiedlichen Höhen, nicht alle gleich. Hover-Demos werden zu Autoplay bei Sichtbarkeit oder zu Tippen.
Ohne Bewegung. Mikroanimationen anhalten und den Endzustand zeigen, Videos pausiert mit Poster. Kein gestaffelter Aufbau.
Barrierefrei. DOM-Reihenfolge gleich Lesereihenfolge. Kacheln mit Ziel als ganze Fläche klickbar: ein <a> pro Kachel, kein verschachtelter Button. Dekorative Animationen mit aria-hidden ausblenden, wechselnde Inhalte nicht als einzige Informationsquelle nutzen. Kontrast von Text über Bild oder Video prüfen.
Code
<!DOCTYPE html>
<!--
Bento-Grid mit lebenden Zellen
Fundus · Hero & Section-Layouts · https://cecodesign.de/lab/fundus/hero-layouts/bento-grid-interaktiv/
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>Bento-Grid mit lebenden Zellen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; 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 {
--papier: #ebe8e1; --kachel: #f7f5f0; --tinte: #161514; --leise: rgba(22, 21, 20, .66); --linie: rgba(22, 21, 20, .2);
--akzent: #e4572e; --akzent-hell: #f6c8b7;
/* Gemessen (Raycast, Zentry): Gap 30 px Desktop / 15 px mobil, Radius .8rem, Rahmen 1px mit 20 % Deckkraft */
--gap: clamp(15px, 2.1vw, 30px);
--radius: .8rem;
/* Eine Kurve für alles: ease-out, gemessen bei Raycast */
--kurve: cubic-bezier(0, 0, 0.58, 1);
--hover: 240ms; /* Hover 200–300 ms */
--auf: 700ms; /* Aufbau je Kachel */
--versatz: 80ms; /* Stagger 60–100 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; }
/* ---------- Bühne ---------- */
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(16px, 2.4vw, 28px); padding: clamp(16px, 3.4vw, 48px); max-width: 1440px; margin: 0 auto; }
.kopf { display: flex; justify-content: space-between; align-items: end; gap: 16px 24px; flex-wrap: wrap; }
.kopf h1 { font-size: clamp(28px, 3.3vw, 48px); font-weight: 500; letter-spacing: -.035em; line-height: 1; margin-top: 10px; }
.kopf h1 em { color: var(--akzent); }
.modi { display: flex; gap: 4px; }
.steuer { display: flex; gap: 4px; align-items: center; }
/* Zugang (WCAG 2.2.2): Uhr, Projekte, Zitate und Werkzeuge wechseln von selbst, also ein Knopf zum Anhalten */
.anhalten { width: 30px; height: 30px; flex: none; display: grid; place-items: center; padding: 0; color: var(--tinte); background: none; border: 1px solid var(--linie); border-radius: 999px; cursor: pointer; transition: border-color 150ms var(--kurve); }
.anhalten:hover { border-color: rgba(22, 21, 20, .4); }
.anhalten svg { width: 10px; height: 10px; fill: currentColor; }
.anhalten .abspielen, .anhalten[aria-pressed='true'] .pause { display: none; }
.anhalten[aria-pressed='true'] .abspielen { display: block; }
.vorschau .anhalten, .reduziert .anhalten { display: none; }
.angehalten .kapa .monate i.erster { animation-play-state: paused; }
.modi button { letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color 150ms var(--kurve), background 150ms var(--kurve), border-color 150ms var(--kurve); }
.modi button:hover { color: var(--tinte); border-color: rgba(22, 21, 20, .4); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.werte b { color: var(--tinte); font-weight: 500; }
/* ---------- Raster ---------- */
.raster {
display: grid; gap: var(--gap);
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(3, minmax(min-content, 1fr));
min-height: 0;
}
.raster[hidden] { display: none; }
@media (min-width: 721px) and (min-height: 600px) { .buehne { height: 100svh; } }
.raster[data-v='studio'] { grid-template-areas: 'a a b c' 'a a d d' 'e f g g'; }
.raster[data-v='leistungen'] { grid-template-areas: 'b a a c' 'd a a e' 'f f g g'; }
.zelle {
--mx: -999px; --my: -999px;
position: relative; overflow: hidden; isolation: isolate;
border-radius: var(--radius); border: 1px solid var(--linie); background: var(--kachel);
padding: clamp(14px, 1.6rem, 26px);
display: flex; flex-direction: column; justify-content: space-between; gap: 10px;
color: inherit; text-decoration: none; outline: none;
container-type: inline-size; min-height: 150px;
transition: opacity var(--auf) var(--kurve) calc(var(--i) * var(--versatz)), translate var(--auf) var(--kurve) calc(var(--i) * var(--versatz)), scale var(--auf) var(--kurve) calc(var(--i) * var(--versatz)), border-color var(--hover) var(--kurve);
}
.zelle[data-z='a'] { grid-area: a; } .zelle[data-z='b'] { grid-area: b; } .zelle[data-z='c'] { grid-area: c; }
.zelle[data-z='d'] { grid-area: d; } .zelle[data-z='e'] { grid-area: e; } .zelle[data-z='f'] { grid-area: f; } .zelle[data-z='g'] { grid-area: g; }
/* Lichtkegel folgt dem Zeiger über das ganze Raster (auch in Nachbarkacheln) */
.zelle::before { content: ''; position: absolute; inset: 0; z-index: -1; pointer-events: none; background: radial-gradient(260px circle at var(--mx) var(--my), rgba(228, 87, 46, .13), transparent 70%); opacity: 0; transition: opacity var(--hover) var(--kurve); }
.raster.zeigt .zelle::before { opacity: 1; }
.zelle.an { border-color: rgba(22, 21, 20, .45); }
.zelle:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
/* Aufbau: gestaffelt, nur mit JS */
.js .raster.wartet .zelle { opacity: 0; translate: 0 24px; scale: .98; }
.js .raster.sofort .zelle { transition: none; }
.js .raster.geht .zelle { opacity: 0; translate: 0 -10px; transition: opacity 200ms var(--kurve), translate 200ms var(--kurve); }
/* Textbausteine einer Kachel (Raycast: Titel 16px/500, Text 15px gedämpft) */
.kz { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.kz > .mono, .fuss-zeile > .mono { white-space: nowrap; }
.fuss-zeile { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.titel { font-size: 16px; font-weight: 500; line-height: 1.25; letter-spacing: -.01em; }
.text { font-size: 15px; line-height: 1.35; color: var(--leise); margin-top: 3px; }
.pfeil { width: 30px; height: 30px; flex: none; border-radius: 50%; border: 1px solid currentColor; display: grid; place-items: center; transition: background var(--hover) var(--kurve), color var(--hover) var(--kurve), border-color var(--hover) var(--kurve); }
.pfeil svg { width: 12px; height: 12px; transition: translate var(--hover) var(--kurve); }
.zelle.an .pfeil svg { translate: 2px -2px; }
/* ================= STUDIO ================= */
/* a · Arbeiten, Bildwechsel: Visual oben (rund 70 %), Text darunter */
.arbeiten { padding: 8px; gap: 0; }
.arbeiten .bilder { position: relative; flex: 1; min-height: 0; border-radius: calc(var(--radius) - 4px); overflow: hidden; background: #ddd8cf; }
.arbeiten img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; clip-path: inset(100% 0 0 0); scale: 1.08; transition: none; }
.arbeiten img.war { z-index: 1; clip-path: inset(0); scale: 1; }
.arbeiten img.an { z-index: 2; clip-path: inset(0); scale: 1; transition: clip-path 900ms var(--kurve), scale 1600ms var(--kurve); }
.zelle.arbeiten.an img.an { scale: 1.03; }
.arbeiten .unten { padding: clamp(12px, 1.2rem, 20px) clamp(8px, calc(1.6rem - 8px), 18px) clamp(6px, calc(1.6rem - 12px), 14px); display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 10px 12px; }
.arbeiten .oben { grid-column: 1 / -1; display: flex; gap: 4px; }
.arbeiten .oben i { flex: 1; height: 2px; border-radius: 2px; background: rgba(22, 21, 20, .14); overflow: hidden; }
.arbeiten .oben i b { display: block; height: 100%; width: 0; background: var(--tinte); }
.arbeiten .projekt-zeile { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.arbeiten .projekt { font-size: clamp(22px, 5.6cqi, 38px); font-weight: 500; letter-spacing: -.035em; line-height: 1; }
.arbeiten .pfeil { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.zelle.arbeiten.an .pfeil { background: var(--akzent); border-color: var(--akzent); }
/* b · Standort-Uhr */
.uhr .ziffernblatt { flex: none; height: clamp(48px, 26cqi, 96px); width: auto; align-self: center; }
.uhr .status { font-size: 14px; }
.uhr .ziffernblatt line { stroke-linecap: round; }
.uhr .status { display: flex; align-items: center; gap: 7px; }
.uhr .status i { width: 7px; height: 7px; border-radius: 50%; background: #3a9a5b; box-shadow: 0 0 0 3px rgba(58, 154, 91, .18); }
.uhr .status.zu i { background: #b9b2a6; box-shadow: none; }
.uhr .zeit { font-variant-numeric: tabular-nums; }
/* c · Kennzahl (Zentry: Kennzahl mit Zeilenhöhe .72) */
.zahl { background: #c44620; color: #fff7f2; border-color: transparent; } /* Akzent eine Stufe tiefer: kleine Schrift 4,7:1 */
.zahl.an { border-color: transparent; }
.zahl .mono { color: #fff7f2; }
.zahl .wert { font-size: clamp(44px, 25cqi, 96px); font-weight: 600; letter-spacing: -.06em; line-height: .72; font-variant-numeric: tabular-nums; margin-top: auto; padding-top: 6px; }
.zahl .balken { display: flex; align-items: end; gap: 3px; height: clamp(14px, 9cqi, 26px); }
.zahl .balken i { flex: 1; background: rgba(255, 247, 242, .45); border-radius: 1px; height: var(--h); transform-origin: bottom; scale: 1 var(--s, 0); transition: background var(--hover) var(--kurve); }
.zahl.an .balken i:last-child, .zahl .balken i:last-child { background: #fff7f2; }
.zahl.an .balken i { background: rgba(255, 247, 242, .75); }
/* d · Stimmen */
.zitat blockquote { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-size: clamp(22px, 5.4cqi, 42px); line-height: 1.04; letter-spacing: -.015em; max-width: 25ch; text-wrap: balance; }
.zitat .stimme { display: grid; }
.zitat .stimme > * { grid-area: 1 / 1; transition: opacity 600ms var(--kurve), translate 600ms var(--kurve); }
.zitat .stimme > :not(.an) { opacity: 0; translate: 0 14px; pointer-events: none; }
.zitat .wer { display: flex; justify-content: space-between; align-items: end; gap: 12px; }
.zitat .punkte { display: flex; gap: 5px; }
.zitat .punkte i { width: 6px; height: 6px; border-radius: 50%; background: var(--linie); transition: background var(--hover) var(--kurve), width var(--hover) var(--kurve); }
.zitat .punkte i.an { background: var(--tinte); width: 18px; border-radius: 3px; }
/* e · Werkzeugkasten (Raycast: Visual mit Masken-Verlauf) */
.werkzeug .fenster { position: relative; flex: 1; overflow: hidden; margin-inline: -4px; -webkit-mask-image: linear-gradient(transparent, #000 30%, #000 60%, transparent); mask-image: linear-gradient(transparent, #000 30%, #000 60%, transparent); }
.werkzeug ul { list-style: none; padding: 0; position: absolute; left: 4px; right: 4px; top: 0; will-change: transform; }
.werkzeug li { font-size: clamp(17px, 9cqi, 24px); font-weight: 500; letter-spacing: -.025em; line-height: 1.32; white-space: nowrap; color: rgba(22, 21, 20, .3); transition: color 200ms var(--kurve); }
.werkzeug li.mitte { color: var(--tinte); }
/* f · Kapazität */
.kapa .monate { display: grid; grid-template-columns: repeat(12, 1fr); gap: 3px; margin-block: auto; }
.kapa .monate b { font: 500 9px/1 'Geist Mono', monospace; color: var(--leise); text-align: center; grid-row: 2; padding-top: 6px; }
.kapa .monate i { height: clamp(22px, 16cqi, 44px); border-radius: 3px; background: var(--tinte); opacity: 0; scale: .6; transition: opacity 400ms var(--kurve) calc(var(--k) * 60ms), scale 400ms var(--kurve) calc(var(--k) * 60ms); }
.kapa .monate i.frei { background: transparent; box-shadow: inset 0 0 0 1px var(--linie); }
.kapa .monate i.erster { background: var(--akzent); box-shadow: none; }
.kapa.voll .monate i { opacity: 1; scale: 1; }
.kapa .monate i.erster { animation: puls 2.4s var(--kurve) infinite; }
@keyframes puls { 0%, 100% { box-shadow: 0 0 0 0 rgba(228, 87, 46, .45); } 60% { box-shadow: 0 0 0 7px rgba(228, 87, 46, 0); } }
.kapa .monat-name { font-variant-numeric: tabular-nums; }
/* g · Kontakt */
.kontakt { background: var(--tinte); color: var(--papier); border-color: transparent; }
.kontakt.an { border-color: transparent; }
.kontakt .mono { color: rgba(235, 232, 225, .6); }
.kontakt .satz { font-size: clamp(24px, 5.2cqi, 44px); font-weight: 500; letter-spacing: -.035em; line-height: 1; max-width: 17ch; }
.kontakt .satz em { color: var(--akzent-hell); }
.kontakt .mail { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.kontakt .mail span { font-size: 15px; color: rgba(235, 232, 225, .75); background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat; transition: background-size var(--hover) var(--kurve), color var(--hover) var(--kurve); padding-bottom: 2px; }
.kontakt.an .mail span { background-size: 100% 1px; color: var(--papier); }
.kontakt.an .pfeil { background: var(--akzent); border-color: var(--akzent); color: #fff; }
/* ================= LEISTUNGEN ================= */
/* a · Marke: Zeichen konstruiert sich */
.marke .bild { flex: 1; min-height: 0; position: relative; }
.marke .bild svg { position: absolute; inset: 4% 0; width: 100%; height: 92%; overflow: visible; }
.marke .hilfe { fill: none; stroke: var(--akzent); stroke-width: .35; }
.marke .form { fill: var(--tinte); stroke: var(--tinte); stroke-width: .6; stroke-linejoin: round; }
.marke .masse text { font: 500 3px 'Geist Mono', monospace; fill: var(--akzent); letter-spacing: .1em; }
.marke .unten { display: flex; justify-content: space-between; align-items: end; gap: 12px; }
.marke .gross { font-size: clamp(22px, 6.4cqi, 38px); font-weight: 500; letter-spacing: -.035em; line-height: 1; }
/* b · Schrift */
.schrift .ag { font-size: clamp(60px, 40cqi, 140px); line-height: .8; letter-spacing: -.05em; font-weight: 400; margin-block: auto; padding-top: 4px; }
.schrift .wght { font-variant-numeric: tabular-nums; }
/* c · Farbe */
.farbe .streifen { display: flex; gap: 4px; flex: 1; min-height: 40px; }
.farbe .streifen i { flex: var(--g, 1); border-radius: 6px; position: relative; transition: flex-grow 500ms var(--kurve); }
.farbe .streifen i span { position: absolute; left: 6px; bottom: 6px; font: 500 9px/1 'Geist Mono', monospace; letter-spacing: .08em; color: rgba(255, 255, 255, .85); opacity: 0; transition: opacity var(--hover) var(--kurve); white-space: nowrap; }
.farbe .streifen i.hell span { color: rgba(22, 21, 20, .7); }
.farbe .streifen i.breit span { opacity: 1; }
/* d · Bewegung: Kurve mit laufendem Punkt */
.bewegung svg { width: 100%; height: 0; flex: 1 1 auto; min-height: 44px; overflow: visible; }
.bewegung .achse { stroke: var(--linie); stroke-width: 1; fill: none; }
.bewegung .bahn { stroke: var(--tinte); stroke-width: 1.6; fill: none; }
.bewegung .spur { stroke: var(--linie); stroke-dasharray: 2 3; }
/* e · Print */
.print { padding: 0; background: #e9e7e3; }
.print img { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; object-fit: cover; scale: 1.12; will-change: transform; }
.print .unten { margin-top: auto; padding: clamp(14px, 1.6rem, 26px); padding-top: 22px; background: linear-gradient(transparent, rgba(247, 245, 240, .94) 50%); }
.print .oben { margin: clamp(10px, calc(1.6rem - 10px), 16px); align-self: start; background: rgba(247, 245, 240, .92); color: var(--tinte); padding: 6px 8px; border-radius: 4px; }
/* f · Websites: Mini-Browser baut sich auf (Text links, Browser läuft rechts unten aus der Kachel) */
.web { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: 16px; padding-right: 0; padding-bottom: 0; }
.web .spalte { display: flex; flex-direction: column; justify-content: space-between; gap: 10px; padding-bottom: clamp(14px, 1.6rem, 26px); }
.web .browser { min-height: 0; margin-top: 4px; border-radius: 8px 0 0 0; border: 1px solid var(--linie); border-right: 0; border-bottom: 0; background: #fff; overflow: hidden; display: grid; grid-template-rows: auto 1fr; -webkit-mask-image: linear-gradient(#000 70%, transparent); mask-image: linear-gradient(#000 70%, transparent); }
.web .leiste { display: flex; align-items: center; gap: 4px; padding: 7px 9px; border-bottom: 1px solid rgba(22, 21, 20, .08); }
.web .leiste i { width: 6px; height: 6px; border-radius: 50%; background: rgba(22, 21, 20, .15); }
.web .leiste span { margin-left: 8px; font: 500 9px/1 'Geist Mono', monospace; color: var(--leise); letter-spacing: .04em; }
.web .seite { padding: 9px 11px; display: grid; grid-template-columns: 1.2fr 1fr; grid-template-rows: auto auto 1fr; gap: 6px 10px; align-content: start; }
.web .seite > * { opacity: 0; translate: 0 10px; transition: opacity 500ms var(--kurve), translate 500ms var(--kurve); }
.web .seite > .da { opacity: 1; translate: 0 0; }
.web .nav { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; }
.web .nav b { font-size: 10px; font-weight: 600; letter-spacing: -.01em; }
.web .nav span { display: flex; gap: 6px; } .web .nav span i { width: 18px; height: 4px; border-radius: 2px; background: rgba(22, 21, 20, .15); }
.web .h { font-size: clamp(13px, 3.4cqi, 20px); font-weight: 500; letter-spacing: -.03em; line-height: 1; }
.web .h em { color: var(--akzent); }
.web .knopf { justify-self: start; align-self: start; font-size: 9px; font-weight: 500; background: var(--tinte); color: #fff; border-radius: 999px; padding: 5px 9px; transition: opacity 500ms var(--kurve), translate 500ms var(--kurve), scale 150ms var(--kurve), background 150ms var(--kurve); }
.web .knopf.gedrueckt { scale: .92; background: var(--akzent); }
.web .foto { grid-column: 2; grid-row: 2 / 4; border-radius: 4px; background: url('bilder/keramik-stillleben.jpg') center / cover; min-height: 40px; }
.web .maus { position: absolute; width: 12px; height: 12px; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity 200ms var(--kurve); }
/* g · Erstgespräch */
.termin .satz { font-size: clamp(22px, 4.6cqi, 38px); font-weight: 500; letter-spacing: -.035em; line-height: 1; max-width: 18ch; }
.termin .satz em { color: var(--akzent); }
.termin .slots { display: flex; flex-wrap: wrap; gap: 6px; }
.termin .slots span { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .06em; padding: 8px 10px; border-radius: 999px; border: 1px solid var(--linie); transition: background var(--hover) var(--kurve), color var(--hover) var(--kurve), border-color var(--hover) var(--kurve); }
.termin .slots span.an { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.termin .fuss { display: flex; justify-content: space-between; align-items: end; gap: 12px; }
.termin.an .pfeil { background: var(--akzent); border-color: var(--akzent); color: #fff; }
/* Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* ---------- Telefon und schmale Fenster: zwei Spalten, Höhen variieren ---------- */
@media (max-width: 720px) {
.raster { grid-template-columns: 1fr 1fr; grid-template-rows: none; grid-auto-rows: minmax(158px, auto); }
.raster[data-v='studio'] { grid-template-areas: 'a a' 'a a' 'b c' 'd d' 'e f' 'g g'; }
.raster[data-v='leistungen'] { grid-template-areas: 'a a' 'a a' 'b c' 'f f' 'd e' 'g g'; }
.zelle[data-z='a'] { min-height: 340px; }
.uhr .ziffernblatt { height: 76px; }
.kopf h1 { font-size: clamp(26px, 7.4vw, 36px); }
}
@media (max-width: 360px) { .raster { grid-template-columns: 1fr; } .raster[data-v] { grid-template-areas: 'a' 'a' 'b' 'c' 'd' 'e' 'f' 'g'; } }
/* Touch: kein Hover. Lebende Zellen laufen, sobald sie sichtbar sind; Tippen hebt hervor */
@media (hover: none) {
.zelle::before { display: none; }
.modi button { height: 34px; }
}
/* Reduzierte Bewegung: kein Aufbau, keine Schleifen, Endzustand */
:root.reduziert * { transition-duration: .01ms !important; transition-delay: 0s !important; animation: none !important; }
.reduziert .zelle::before { display: none; }
/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .buehne { height: 100svh; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="buehne">
<div class="kopf">
<div>
<p class="mono" data-ueber>Studio Kantig · Köln-Ehrenfeld</p>
<h1 data-titel>Ein Studio, <em>auf einen Blick.</em></h1>
</div>
<div class="steuer">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-wahl="studio" aria-pressed="true">Studio</button>
<button type="button" data-wahl="leistungen" aria-pressed="false">Leistungen</button>
</div>
<button class="anhalten" type="button" data-anhalten aria-pressed="false" aria-label="Bewegung anhalten" title="Bewegung anhalten"><svg class="pause" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="1" width="2.4" height="8" rx=".6"/><rect x="6.1" y="1" width="2.4" height="8" rx=".6"/></svg><svg class="abspielen" viewBox="0 0 10 10" aria-hidden="true"><path d="M2.5 1.2v7.6L9 5z"/></svg></button>
</div>
</div>
<!-- Variante 1: Über-uns-Bento. DOM-Reihenfolge = Lesereihenfolge = Wichtigkeit -->
<div class="raster wartet" data-v="studio" data-raster>
<a class="zelle arbeiten" data-z="a" href="#arbeiten" style="--i:0" data-leben="arbeiten" aria-label="Ausgewählte Arbeiten ansehen">
<div class="bilder" aria-hidden="true">
<img class="an" src="bilder/marke-roestkontor.jpg" alt="">
<img src="bilder/marke-krume.jpg" alt="">
<img src="bilder/typo-plakat.jpg" alt="">
<img src="bilder/flakon.jpg" alt="">
<img src="bilder/marke-eselsohr.jpg" alt="">
</div>
<div class="unten">
<div class="oben" aria-hidden="true"><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i></div>
<div class="projekt-zeile">
<p class="projekt" data-projekt>Röstkontor</p>
<p class="mono" data-projekt-art>Marke & Website · 2025</p>
</div>
<span class="pfeil" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9 9 3M4 3h5v5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></span>
</div>
</a>
<a class="zelle uhr" data-z="b" href="#anfahrt" style="--i:1" data-leben="uhr">
<div class="kz"><p class="mono">Köln</p><p class="mono zeit" data-zeit>--:--</p></div>
<svg class="ziffernblatt" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="48" fill="none" stroke="rgba(22,21,20,.2)"/>
<g stroke="rgba(22,21,20,.45)" stroke-width="2">
<line x1="50" y1="6" x2="50" y2="13"/><line x1="94" y1="50" x2="87" y2="50"/><line x1="50" y1="94" x2="50" y2="87"/><line x1="6" y1="50" x2="13" y2="50"/>
</g>
<line data-h x1="50" y1="50" x2="50" y2="28" stroke="#161514" stroke-width="4"/>
<line data-m x1="50" y1="50" x2="50" y2="15" stroke="#161514" stroke-width="2.6"/>
<line data-s x1="50" y1="58" x2="50" y2="12" stroke="#e4572e" stroke-width="1.4"/>
<circle cx="50" cy="50" r="3" fill="#e4572e"/>
</svg>
<div>
<p class="titel">Venloer Straße 412</p>
<p class="text status" data-status><i></i><span>Geöffnet bis 18 Uhr</span></p>
</div>
</a>
<div class="zelle zahl" data-z="c" style="--i:2" data-leben="zahl">
<p class="mono">Seit 2014</p>
<p class="wert" data-wert>186</p>
<div>
<div class="balken" aria-hidden="true" data-balken></div>
<p class="titel" style="margin-top:8px">Marken entwickelt</p>
</div>
</div>
<div class="zelle zitat" data-z="d" style="--i:3" data-leben="zitat">
<div class="stimme" data-stimmen>
<blockquote class="an">„Sie haben unsere Weinhandlung verstanden, bevor wir sie selbst erklären konnten.“</blockquote>
<blockquote>„Ein Logo, das auf dem Lieferwagen genauso hält wie auf dem Etikett.“</blockquote>
<blockquote>„Klare Worte, klare Termine, klare Gestaltung. Das ist selten.“</blockquote>
</div>
<div class="wer">
<div class="stimme" data-namen>
<p class="mono an">Hanna Roth · Roth & Söhne Weine</p>
<p class="mono">Kemal Aydın · Ofenhaus Bäckerei</p>
<p class="mono">Dr. Clara Weiss · Kanzlei Weiss</p>
</div>
<div class="punkte" aria-hidden="true" data-punkte><i class="an"></i><i></i><i></i></div>
</div>
</div>
<div class="zelle werkzeug" data-z="e" style="--i:4" data-leben="werkzeug">
<p class="mono">Werkzeugkasten</p>
<div class="fenster" aria-hidden="true"><ul data-liste>
<li>Skizzenbuch</li><li>Risografie</li><li>Variable Fonts</li><li>CSS-Grid</li><li>Siebdruck</li><li>Fotografie</li><li>Bewegtbild</li><li>Barrierefreiheit</li>
</ul></div>
<p class="titel">Analog trifft Code</p>
</div>
<div class="zelle kapa" data-z="f" style="--i:5" data-leben="kapa">
<p class="mono">Auslastung</p>
<div class="monate" aria-hidden="true" data-monate></div>
<div>
<p class="titel">Frei ab <span data-frei>März 2027</span></p>
</div>
</div>
<a class="zelle kontakt" data-z="g" href="#kontakt" style="--i:6" data-leben="kontakt">
<p class="mono">Neues Projekt</p>
<p class="satz">Erzählen Sie uns, <em>was Sie vorhaben.</em></p>
<div class="mail"><span>hallo@studio-kantig.de</span><span class="pfeil" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9 9 3M4 3h5v5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></span></div>
</a>
</div>
<!-- Variante 2: Leistungs-Bento mit Mini-UIs -->
<div class="raster wartet" data-v="leistungen" data-raster hidden>
<a class="zelle marke" data-z="a" href="#marke" style="--i:0" data-leben="marke">
<div class="kz"><p class="mono">01 · Marke</p><p class="mono" data-marke-schritt>Raster</p></div>
<div class="bild" aria-hidden="true">
<svg viewBox="0 0 100 100">
<g class="hilfe" data-hilfe>
<rect x="10" y="10" width="80" height="80" pathLength="1"/>
<circle cx="50" cy="50" r="40" pathLength="1"/>
<circle cx="50" cy="50" r="20" pathLength="1"/>
<line x1="10" y1="10" x2="90" y2="90" pathLength="1"/>
<line x1="90" y1="10" x2="10" y2="90" pathLength="1"/>
<line x1="10" y1="50" x2="90" y2="50" pathLength="1"/>
<line x1="36" y1="10" x2="36" y2="90" pathLength="1"/>
</g>
<g class="masse" data-masse><text x="10" y="7">80 × 80</text><text x="76" y="97">r 40 / 20</text></g>
<g data-form>
<path class="form" pathLength="1" d="M22 18h14v64H22z"/>
<path class="form" pathLength="1" d="M36 50 64 18h17L50 53z"/>
<path class="form" pathLength="1" d="M47 49 81 82H63L39 58z"/>
<circle class="form" pathLength="1" cx="72" cy="58" r="6" style="fill:#e4572e;stroke:#e4572e"/>
</g>
</svg>
</div>
<div class="unten">
<div><p class="gross">Zeichen mit Haltung</p><p class="text">Vom ersten Gespräch zum Logo in sechs Wochen.</p></div>
<span class="pfeil" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9 9 3M4 3h5v5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></span>
</div>
</a>
<div class="zelle schrift" data-z="b" style="--i:1" data-leben="schrift">
<p class="mono">02 · Schrift</p>
<p class="ag" data-ag aria-hidden="true">Ag</p>
<div class="fuss-zeile"><p class="titel">Typografie</p><p class="mono wght" data-wght>wght 400</p></div>
</div>
<div class="zelle farbe" data-z="c" style="--i:2" data-leben="farbe">
<p class="mono">03 · Farbe</p>
<div class="streifen" data-streifen aria-hidden="true">
<i style="background:#161514"><span>#161514</span></i>
<i style="background:#e4572e"><span>#E4572E</span></i>
<i class="hell" style="background:#f6c8b7"><span>#F6C8B7</span></i>
<i class="hell" style="background:#d9d3c7"><span>#D9D3C7</span></i>
</div>
<p class="titel">Farbsysteme</p>
</div>
<div class="zelle bewegung" data-z="d" style="--i:3" data-leben="bewegung">
<p class="mono">04 · Bewegung</p>
<svg viewBox="0 0 120 70" aria-hidden="true">
<path class="achse" d="M10 64H112M10 64V4"/>
<path class="bahn" d="M10 64C10 64 68 4 112 4"/>
<line class="spur" data-spur x1="10" y1="64" x2="10" y2="4"/>
<circle data-punkt r="3.6" cx="10" cy="64" fill="#e4572e"/>
</svg>
<div class="fuss-zeile"><p class="titel">Motion</p><p class="mono">ease-out</p></div>
</div>
<div class="zelle print" data-z="e" style="--i:4" data-leben="print">
<img src="bilder/magazin-doppelseite-hoch.jpg" alt="" data-printbild>
<p class="mono oben">05 · Print</p>
<div class="unten"><p class="titel">Editorial</p><p class="text">Magazin, 64 Seiten</p></div>
</div>
<a class="zelle web" data-z="f" href="#websites" style="--i:5" data-leben="web">
<div class="spalte">
<p class="mono">06 · Websites</p>
<p class="titel">Websites, die bleiben</p>
<span class="pfeil" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9 9 3M4 3h5v5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></span>
</div>
<div class="browser" aria-hidden="true">
<div class="leiste"><i></i><i></i><i></i><span>roestkontor.de</span></div>
<div class="seite" data-seite>
<div class="nav"><b>Röstkontor</b><span><i></i><i></i><i></i></span></div>
<p class="h">Hell <em>geröstet.</em></p>
<div class="foto"></div>
<span class="knopf" data-knopf>Tisch reservieren</span>
</div>
</div>
<svg class="maus" data-maus viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#161514" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/></svg>
</a>
<a class="zelle termin" data-z="g" href="#termin" style="--i:6" data-leben="termin">
<p class="mono">Erstgespräch</p>
<p class="satz">30 Minuten, <em>kostenlos</em> und ohne Folien.</p>
<div class="fuss">
<div class="slots" data-slots aria-hidden="true"><span>Di 10:00</span><span>Mi 14:30</span><span>Do 09:00</span></div>
<span class="pfeil" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M3 9 9 3M4 3h5v5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></span>
</div>
</a>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</section>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#161514" stroke="#ebe8e1" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
// Kurve ease-out = cubic-bezier(0, 0, 0.58, 1), auch für JS-Bewegung
function bezier(x1, y1, x2, y2) {
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = a => 3 * a;
const f = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
return x => { if (x <= 0) return 0; if (x >= 1) return 1; let lo = 0, hi = 1, t = x;
for (let i = 0; i < 20; i++) { t = (lo + hi) / 2; f(t, x1, x2) < x ? lo = t : hi = t; } return f(t, y1, y2); };
}
const easeOut = bezier(0, 0, 0.58, 1);
const klemm = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const raster = { studio: $('[data-v="studio"]'), leistungen: $('[data-v="leistungen"]') };
const titel = $('[data-titel]'), ueber = $('[data-ueber]'), werteEl = $('[data-werte]'), zeigerEl = $('[data-zeiger]');
const TITEL = {
studio: ['Studio Kantig · Köln-Ehrenfeld', 'Ein Studio, <em>auf einen Blick.</em>'],
leistungen: ['Studio Kantig · Leistungen', 'Was wir machen, <em>und wie es aussieht.</em>'],
};
let variante = 'studio';
/* ---------- lebende Zellen: je eine Funktion pro Frame, nur wenn sichtbar ---------- */
const zellen = {}; // Name → { el, tick(t), an(bool), x(px,py) }
const sichtbar = new WeakMap();
const io = new IntersectionObserver(es => es.forEach(e => sichtbar.set(e.target, e.isIntersecting)), { threshold: 0.15 });
// Studio · a: Arbeiten wechseln alle 3,2 s
(() => {
const el = $('[data-leben="arbeiten"]'), bilder = $$('img', el), balken = $$('.oben b', el);
const P = [['Röstkontor', 'Marke & Website · 2025'], ['Krume', 'Wortmarke · 2024'], ['Werkschau Licht', 'Plakat · 2026'], ['Ocker 04', 'Packaging · 2025'], ['Eselsohr', 'Corporate Design · 2023']];
const name = $('[data-projekt]', el), art = $('[data-projekt-art]', el);
const DAUER = 3200; let start = 0, nr = 0;
function zeige(i) { const alt = nr; nr = i; bilder.forEach((b, j) => { b.classList.toggle('war', j === alt && j !== i); b.classList.toggle('an', j === i); }); name.textContent = P[i][0]; art.textContent = P[i][1]; }
zellen.arbeiten = { el, reset(t) { start = t; zeige(0); balken.forEach(b => b.style.width = '0'); },
tick(t) {
const z = Math.max(0, t - start), i = Math.floor(z / DAUER) % P.length, p = (z % DAUER) / DAUER;
if (i !== nr) zeige(i);
balken.forEach((b, j) => b.style.width = (j < i ? 100 : j === i ? p * 100 : 0) + '%');
},
ende() { zeige(0); balken.forEach((b, j) => b.style.width = j === 0 ? '100%' : '0'); } };
})();
// Studio · b: Uhr in Köln (Europe/Berlin), Sekundenzeiger gleitet
(() => {
const el = $('[data-leben="uhr"]'), h = $('[data-h]', el), m = $('[data-m]', el), s = $('[data-s]', el);
const zeit = $('[data-zeit]', el), status = $('[data-status]', el);
const fmt = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', second: '2-digit', weekday: 'short', hour12: false });
let letzte = -1, teile = { h: 0, m: 0, s: 0 };
function lesen() {
const o = {}; fmt.formatToParts(new Date()).forEach(p => o[p.type] = p.value);
teile = { h: +o.hour % 24, m: +o.minute, s: +o.second, tag: o.weekday };
zeit.textContent = `${o.hour}:${o.minute}`;
const offen = !/Sa|So/.test(o.weekday) && teile.h >= 9 && teile.h < 18;
status.classList.toggle('zu', !offen);
status.lastChild.textContent = offen ? 'Geöffnet bis 18 Uhr' : 'Antwort ab 9 Uhr';
}
function malen(ms) {
const sek = teile.s + ms / 1000, min = teile.m + sek / 60, std = (teile.h % 12) + min / 60;
h.setAttribute('transform', `rotate(${std * 30} 50 50)`);
m.setAttribute('transform', `rotate(${min * 6} 50 50)`);
s.setAttribute('transform', `rotate(${(reduziert() ? teile.s : sek) * 6} 50 50)`);
}
zellen.uhr = { el, reset() {}, tick() { const d = new Date(); if (d.getSeconds() !== letzte) { letzte = d.getSeconds(); lesen(); } malen(d.getMilliseconds()); },
ende() { lesen(); malen(0); } };
})();
// Studio · c: Kennzahl zählt beim Aufbau hoch, Jahresbalken wachsen gestaffelt
(() => {
const el = $('[data-leben="zahl"]'), wert = $('[data-wert]', el), balken = $('[data-balken]', el);
const JAHRE = [6, 9, 11, 10, 13, 14, 12, 16, 17, 15, 19, 21, 23]; // 2014–2026, Summe 186
const ZIEL = JAHRE.reduce((a, b) => a + b, 0), max = Math.max(...JAHRE);
balken.innerHTML = JAHRE.map(j => `<i style="--h:${(j / max * 100).toFixed(1)}%"></i>`).join('');
const is = $$('i', balken); let start = 0;
const ZAEHLEN = 1400;
zellen.zahl = { el, reset(t) { start = t + 300; },
tick(t) {
const p = easeOut(klemm((t - start) / ZAEHLEN));
wert.textContent = Math.round(ZIEL * p);
is.forEach((b, k) => b.style.setProperty('--s', easeOut(klemm((t - start - k * 60) / 600)).toFixed(3)));
},
ende() { wert.textContent = ZIEL; is.forEach(b => b.style.setProperty('--s', 1)); },
an(ja, t) { if (ja && t - start > ZAEHLEN + 800) start = t; } }; // Hover zählt erneut
})();
// Studio · d: Stimmen wechseln alle 5 s, Hover hält an
(() => {
const el = $('[data-leben="zitat"]'), q = $$('[data-stimmen] > *', el), n = $$('[data-namen] > *', el), pk = $$('[data-punkte] i', el);
const DAUER = 5000; let start = 0, nr = 0, halt = false, halteZeit = 0;
function zeige(i) { nr = i; [q, n, pk].forEach(l => l.forEach((x, j) => x.classList.toggle('an', j === i))); }
zellen.zitat = { el, reset(t) { start = t; zeige(0); },
tick(t) { if (halt) return; const i = Math.floor(Math.max(0, t - start) / DAUER) % q.length; if (i !== nr) zeige(i); },
ende() { zeige(0); },
an(ja, t) { if (ja && !halt) { halt = true; halteZeit = t; } else if (!ja && halt) { halt = false; start += t - halteZeit; } } };
})();
// Studio · e: Werkzeugliste läuft endlos, beim Hover langsamer
(() => {
const el = $('[data-leben="werkzeug"]'), ul = $('[data-liste]', el);
ul.innerHTML += ul.innerHTML; // doppelt für nahtlose Schleife
const li = $$('li', ul), haelfte = li.length / 2;
let pos = 0, tempo = 22, ziel = 22, letzt = 0, zeile = 0; // px pro Sekunde
zellen.werkzeug = { el, reset(t) { letzt = t; },
tick(t) {
const dt = Math.min(64, t - letzt) / 1000; letzt = t;
tempo += (ziel - tempo) * (1 - Math.pow(0.02, dt));
zeile = li[0].offsetHeight || 30;
pos = (pos + tempo * dt) % (zeile * haelfte);
ul.style.transform = `translate3d(0, ${-pos}px, 0)`;
const fenster = ul.parentElement.offsetHeight, mitte = Math.floor((pos + fenster * 0.45) / zeile);
li.forEach((x, k) => x.classList.toggle('mitte', k === mitte));
},
ende() { const f = ul.parentElement.offsetHeight, z = li[0].offsetHeight || 30; ul.style.transform = 'none'; li.forEach((x, k) => x.classList.toggle('mitte', k === Math.floor(f * 0.45 / z))); },
an(ja) { ziel = ja ? 7 : 22; } };
})();
// Studio · f: Auslastung, 12 Monate ab heute, belegte füllen sich gestaffelt
(() => {
const el = $('[data-leben="kapa"]'), box = $('[data-monate]', el), frei = $('[data-frei]', el);
const MON = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const heute = new Date(), belegt = [1, 1, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0]; // erster freier Monat: Index 4
const ersterFrei = belegt.indexOf(0), d = new Date(heute.getFullYear(), heute.getMonth() + ersterFrei, 1);
frei.textContent = `${MON[d.getMonth()]} ${d.getFullYear()}`;
box.innerHTML = belegt.map((b, k) => `<i style="--k:${k}" class="${b ? '' : 'frei'}${k === ersterFrei ? ' erster' : ''}"></i>`).join('')
+ belegt.map((b, k) => `<b>${MON[(heute.getMonth() + k) % 12][0]}</b>`).join('');
zellen.kapa = { el, reset() { el.classList.remove('voll'); setTimeout(() => el.classList.add('voll'), 500); }, tick() {}, ende() { el.classList.add('voll'); } };
})();
zellen.kontakt = { el: $('[data-leben="kontakt"]'), reset() {}, tick() {}, ende() {} };
// Leistungen · a: Zeichen wird konstruiert (Raster → Kontur → Fläche), 6,4 s je Runde
(() => {
const el = $('[data-leben="marke"]'), hilfe = $$('[data-hilfe] > *', el), form = $$('[data-form] > *', el);
const masse = $('[data-masse]', el), schritt = $('[data-marke-schritt]', el);
const RUNDE = 6400; let start = 0, hover = 0, hoverZiel = 0, letzt = 0;
function setze(t) {
const z = Math.max(0, t - start) % RUNDE;
const raus = easeOut(klemm((z - 5600) / 700)); // am Ende ausblenden
hilfe.forEach((x, k) => {
const p = easeOut(klemm((z - k * 110) / 900));
x.style.strokeDasharray = 1; x.style.strokeDashoffset = 1 - p;
x.style.opacity = (1 - Math.max(raus, easeOut(klemm((z - 3400) / 700)) * (1 - hover) * 0.75)).toFixed(3);
});
masse.style.opacity = (klemm((z - 700) / 400) * (1 - raus) * (0.4 + hover * 0.6)).toFixed(3);
form.forEach((x, k) => {
const p = easeOut(klemm((z - 1100 - k * 260) / 1000)), f = easeOut(klemm((z - 2500 - k * 120) / 600));
x.style.strokeDasharray = 1; x.style.strokeDashoffset = 1 - p;
x.style.fillOpacity = f; x.style.opacity = 1 - raus;
});
schritt.textContent = z < 1100 ? 'Raster' : z < 2500 ? 'Kontur' : z < 3400 ? 'Fläche' : 'Zeichen';
}
zellen.marke = { el, reset(t) { start = t; letzt = t; },
tick(t) { const dt = Math.min(64, t - letzt) / 1000; letzt = t; hover += (hoverZiel - hover) * (1 - Math.pow(0.001, dt)); setze(t); },
ende() { hilfe.forEach(x => { x.style.strokeDashoffset = 0; x.style.strokeDasharray = 1; x.style.opacity = .25; }); masse.style.opacity = 0;
form.forEach(x => { x.style.strokeDashoffset = 0; x.style.fillOpacity = 1; x.style.opacity = 1; }); schritt.textContent = 'Zeichen'; },
an(ja) { hoverZiel = ja ? 1 : 0; } };
})();
// Leistungen · b: variable Strichstärke 400–700, pendelt; mit Zeiger: Position bestimmt wght
(() => {
const el = $('[data-leben="schrift"]'), ag = $('[data-ag]', el), anz = $('[data-wght]', el);
let w = 400, ziel = null, letzt = 0;
function malen() { ag.style.fontVariationSettings = `'wght' ${w.toFixed(0)}`; ag.style.fontWeight = w.toFixed(0); anz.textContent = 'wght ' + Math.round(w); }
zellen.schrift = { el, reset(t) { letzt = t; },
tick(t) {
const dt = Math.min(64, t - letzt) / 1000; letzt = t;
const z = ziel ?? 550 - 150 * Math.cos(t / 1600);
w += (z - w) * (1 - Math.pow(0.002, dt)); malen();
},
ende() { w = 600; malen(); },
x(px, r) { ziel = px == null ? null : 400 + 300 * klemm((px - r.left) / r.width); } };
})();
// Leistungen · c: Farbstreifen, einer wird breit und zeigt den Wert
(() => {
const el = $('[data-leben="farbe"]'), st = $$('[data-streifen] i', el);
let nr = -1, zeiger = null;
function breit(i) { if (i === nr) return; nr = i; st.forEach((s, j) => { s.style.setProperty('--g', j === i ? 4 : 1); s.classList.toggle('breit', j === i); }); }
zellen.farbe = { el, reset() {}, tick(t) { breit(zeiger ?? Math.floor(t / 1800) % st.length); },
ende() { breit(1); },
x(px, r) { zeiger = px == null ? null : st.findIndex(s => { const b = s.getBoundingClientRect(); return px >= b.left - 2 && px <= b.right + 2; }); if (zeiger < 0) zeiger = null; } };
el.addEventListener('click', () => { zeiger = ((zeiger ?? nr) + 1) % st.length; }); // Touch: Tippen schaltet weiter
})();
// Leistungen · d: Punkt läuft die Kurve cubic-bezier(0, 0, 0.58, 1) ab, 1,2 s, dann Pause
(() => {
const el = $('[data-leben="bewegung"]'), punkt = $('[data-punkt]', el), spur = $('[data-spur]', el);
const LAUF = 1200, RUNDE = 2400;
function setze(p) {
const x = 10 + 102 * p, y = 64 - 60 * easeOut(p);
punkt.setAttribute('cx', x.toFixed(2)); punkt.setAttribute('cy', y.toFixed(2));
spur.setAttribute('x1', x.toFixed(2)); spur.setAttribute('x2', x.toFixed(2)); spur.setAttribute('y2', y.toFixed(2));
}
zellen.bewegung = { el, reset() {}, tick(t) { setze(klemm((t % RUNDE) / LAUF)); }, ende() { setze(1); } };
})();
// Leistungen · e: Print, Bild schwebt langsam; mit Zeiger leichter Versatz
(() => {
const el = $('[data-leben="print"]'), img = $('[data-printbild]', el);
let zx = 0, zy = 0, x = 0, y = 0, letzt = 0, zeiger = false;
zellen.print = { el, reset(t) { letzt = t; },
tick(t) {
const dt = Math.min(64, t - letzt) / 1000; letzt = t;
if (!zeiger) { zx = Math.sin(t / 2600) * 3; zy = Math.cos(t / 3100) * 3; }
const k = 1 - Math.pow(0.01, dt); x += (zx - x) * k; y += (zy - y) * k;
img.style.transform = `translate3d(${x.toFixed(2)}%, ${y.toFixed(2)}%, 0)`;
},
ende() { img.style.transform = 'none'; },
x(px, r, py) { zeiger = px != null; if (zeiger) { zx = -((px - r.left) / r.width - .5) * 8; zy = -((py - r.top) / r.height - .5) * 8; } } };
})();
// Leistungen · f: Mini-Website baut sich auf, Maus klickt „Tisch reservieren“, 5,6 s je Runde
(() => {
const el = $('[data-leben="web"]'), teile = $$('[data-seite] > *', el), knopf = $('[data-knopf]', el), maus = $('[data-maus]', el);
const RUNDE = 5600; let start = 0;
zellen.web = { el, reset(t) { start = t; },
tick(t) {
const z = Math.max(0, t - start) % RUNDE;
teile.forEach((x, k) => x.classList.toggle('da', z > 200 + k * 380 && z < 5000));
const r = el.getBoundingClientRect(), b = knopf.getBoundingClientRect();
const p = easeOut(klemm((z - 2000) / 900));
const sx = r.width * 0.86, sy = r.height * 0.96, ex = b.left - r.left + b.width * 0.6, ey = b.top - r.top + b.height * 0.6;
maus.style.opacity = z > 1800 && z < 4600 ? 1 : 0;
maus.style.transform = `translate(${sx + (ex - sx) * p}px, ${sy + (ey - sy) * p}px)`;
knopf.classList.toggle('gedrueckt', z > 3100 && z < 3500);
knopf.textContent = z > 3300 && z < 5000 ? 'Reserviert ✓' : 'Tisch reservieren';
},
ende() { teile.forEach(x => x.classList.add('da')); maus.style.opacity = 0; knopf.classList.remove('gedrueckt'); knopf.textContent = 'Tisch reservieren'; } };
})();
// Leistungen · g: freie Termine wechseln, Hover wählt den nächsten
(() => {
const el = $('[data-leben="termin"]'), s = $$('[data-slots] span', el);
zellen.termin = { el, reset() {}, tick(t) { const i = Math.floor(t / 1500) % s.length; s.forEach((x, j) => x.classList.toggle('an', j === i)); },
ende() { s.forEach((x, j) => x.classList.toggle('an', j === 0)); } };
})();
Object.values(zellen).forEach(z => io.observe(z.el));
/* ---------- Takt: ein rAF für alle sichtbaren Zellen der aktiven Variante ---------- */
let rafId = null, angehalten = false;
const aktiveZellen = () => Object.values(zellen).filter(z => raster[variante].contains(z.el));
function takt(t) {
aktiveZellen().forEach(z => { if (sichtbar.get(z.el) !== false) z.tick(t); });
vorfuehrSchritt(t);
rafId = requestAnimationFrame(takt);
}
function laufen() { if (!rafId && !angehalten) rafId = requestAnimationFrame(takt); }
function stoppen() { cancelAnimationFrame(rafId); rafId = null; }
/* ---------- Zeiger: Hover, Lichtkegel, zellenspezifische Reaktion ---------- */
let aktivEl = null;
function zeigerBei(px, py) {
const R = raster[variante], liste = aktiveZellen();
const t = performance.now();
let treffer = null;
if (px != null) liste.forEach(z => { const r = z.el.getBoundingClientRect(); if (px >= r.left && px <= r.right && py >= r.top && py <= r.bottom) treffer = z; });
R.classList.toggle('zeigt', px != null && !reduziert());
liste.forEach(z => {
const r = z.el.getBoundingClientRect();
if (px != null) { z.el.style.setProperty('--mx', (px - r.left) + 'px'); z.el.style.setProperty('--my', (py - r.top) + 'px'); }
if (z.x) z.x(z === treffer ? px : null, r, py);
});
const neu = treffer ? treffer.el : null;
if (neu !== aktivEl) {
const alt = liste.find(z => z.el === aktivEl);
if (aktivEl) aktivEl.classList.remove('an');
if (alt && alt.an) alt.an(false, t);
aktivEl = neu;
if (neu) { neu.classList.add('an'); if (treffer.an) treffer.an(true, t); }
}
}
Object.values(raster).forEach(R => {
R.addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) zeigerBei(e.clientX, e.clientY); });
R.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) zeigerBei(null); });
// Touch: Tippen hebt die Kachel hervor (Hover-Ersatz), zweites Tippen auf einen Link folgt ihm
R.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse') { const r = e.target.closest('.zelle'); if (r) { const b = r.getBoundingClientRect(); zeigerBei(b.left + b.width / 2, b.top + b.height / 2); } } });
R.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); }); // Demo: keine echten Ziele
});
$$('.zelle').forEach(z => {
z.addEventListener('focus', () => { const b = z.getBoundingClientRect(); zeigerBei(b.left + b.width / 2, b.top + b.height / 2); raster[variante].classList.remove('zeigt'); });
z.addEventListener('blur', () => zeigerBei(null));
});
/* ---------- Aufbau und Variantenwechsel ---------- */
function werteZeigen() {
const R = raster[variante], gap = parseFloat(getComputedStyle(R).columnGap) || 0;
const spalten = getComputedStyle(R).gridTemplateColumns.split(' ').length;
werteEl.innerHTML = reduziert()
? `<span>Raster <b>${spalten} Sp. · Gap ${Math.round(gap)} px</b></span><span>Reduzierte Bewegung: <b>Endzustand, keine Schleifen</b></span>`
: `<span>Raster <b>${spalten} Sp. · Gap ${Math.round(gap)} px</b></span><span>Radius <b>.8rem</b></span><span>Rahmen <b>1px · 20 %</b></span><span>Hover <b>240 ms ease-out</b></span><span>Aufbau <b>700 ms · Versatz 80 ms</b></span>`;
}
function aufbauen(t = performance.now()) {
const R = raster[variante];
aktiveZellen().forEach(z => { if (reduziert()) z.ende(); else z.reset(t); });
if (reduziert()) { R.classList.remove('wartet'); return; }
R.classList.add('wartet', 'sofort'); R.classList.remove('geht');
void R.offsetWidth; // Ausgangszustand ohne Übergang festschreiben
R.classList.remove('sofort');
requestAnimationFrame(() => requestAnimationFrame(() => R.classList.remove('wartet')));
}
let wechselUhr = null;
function wechseln(v) {
if (v === variante) { aufbauen(); return; }
$$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
const alt = raster[variante];
zeigerBei(null);
const fertig = () => {
alt.hidden = true; alt.classList.remove('geht');
variante = v; raster[v].hidden = false; raster[v].classList.add('wartet');
ueber.textContent = TITEL[v][0]; titel.innerHTML = TITEL[v][1];
werteZeigen(); aufbauen();
};
clearTimeout(wechselUhr);
if (reduziert()) return fertig();
alt.classList.add('geht');
wechselUhr = setTimeout(fertig, 260);
}
$$('[data-wahl]').forEach(b => b.addEventListener('click', () => { if (html.classList.contains('vorfuehrung')) neustart(); wechseln(b.dataset.wahl); }));
/* ---------- Vorführung: Aufbau, gedachter Zeiger besucht Kacheln, Variante wechselt ---------- */
// Runde 18 s: 0–9 s Studio, 9–18 s Leistungen. Ziele je Variante mit Zeitfenster (ms) und Punkt in der Kachel (0–1).
const PLAN = {
studio: [[1500, 'arbeiten', .55, .45], [3000, 'zahl', .5, .55], [4300, 'zitat', .4, .5], [5700, 'werkzeug', .5, .5], [7000, 'kontakt', .6, .6]],
leistungen: [[1500, 'marke', .5, .45], [3000, 'schrift', .2, .55], [3900, 'schrift', .9, .55], [4800, 'farbe', .75, .5], [6000, 'print', .3, .35], [7100, 'termin', .6, .6]],
};
const HAELFTE = 9000;
let vStart = 0, vAktiv = false, vPause = 0, zx = 0, zy = 0, vHaelfte = -1;
function vorfuehrSchritt(t) {
if (!vAktiv) return;
const z = Math.max(0, t - vStart), h = Math.floor(z / HAELFTE) % 2, lokal = z % HAELFTE;
if (h !== vHaelfte) { vHaelfte = h; wechseln(h === 0 ? 'studio' : 'leistungen'); }
const plan = PLAN[variante];
let ziel = null;
for (const [ab, name, fx, fy] of plan) if (lokal >= ab) ziel = [name, fx, fy];
let tx = innerWidth * 0.92, ty = innerHeight * 1.05;
if (ziel && lokal < 8300 && zellen[ziel[0]]) {
const r = zellen[ziel[0]].el.getBoundingClientRect();
tx = r.left + r.width * ziel[1]; ty = r.top + r.height * ziel[2];
}
const k = 0.09;
zx += (tx - zx) * k; zy += (ty - zy) * k;
zeigerEl.style.transform = `translate(${zx - 2}px, ${zy - 1}px)`;
zeigerBei(zy < innerHeight ? zx : null, zy);
}
function vorfuehren() {
html.classList.add('vorfuehrung');
angehalten = false; anhaltenZeigen(); vAktiv = true; vHaelfte = -1;
vStart = performance.now(); zx = innerWidth * 0.92; zy = innerHeight * 1.05;
if (variante === 'studio') { vHaelfte = 0; aufbauen(); }
laufen();
}
function pause() { if (angehalten) return; angehalten = true; vPause = performance.now(); stoppen(); anhaltenZeigen(); }
function weiter() { if (!angehalten) return; angehalten = false; if (vAktiv) vStart += performance.now() - vPause; laufen(); anhaltenZeigen(); }
/* Knopf „Bewegung anhalten“: dieselbe Pause wie FUNDUS_DEMO.pause() */
const anhaltenKnopf = document.querySelector('[data-anhalten]');
function anhaltenZeigen() { anhaltenKnopf.setAttribute('aria-pressed', angehalten ? 'true' : 'false'); html.classList.toggle('angehalten', angehalten); }
anhaltenKnopf.addEventListener('click', () => { if (angehalten) weiter(); else pause(); });
function neustart() {
vAktiv = false; html.classList.remove('vorfuehrung'); zeigerBei(null);
angehalten = false; anhaltenZeigen();
if (variante !== 'studio') wechseln('studio'); else aufbauen();
laufen();
}
/* ---------- Start: Aufbau, sobald das Raster sichtbar ist ---------- */
werteZeigen();
new IntersectionObserver((es, o) => { if (es[0].isIntersecting) { o.disconnect(); aufbauen(); if (reduziert()) aktiveZellen().forEach(z => z.ende()); else laufen(); } }, { threshold: 0.1 }).observe(raster.studio);
addEventListener('resize', werteZeigen);
// Reduziert-Schalter der Detailseite setzt die Klasse später: Zustand nachziehen
new MutationObserver(() => { werteZeigen(); if (reduziert()) { stoppen(); vAktiv = false; Object.values(raster).forEach(R => R.classList.remove('wartet', 'geht', 'zeigt')); Object.values(zellen).forEach(z => z.ende()); } else laufen(); })
.observe(html, { attributes: true, attributeFilter: ['class'] });
})();
</script>
</body>
</html>