Füllung vom Eintrittspunkt
Leistungszeilen und Knöpfe füllen sich dort, wo der Zeiger eintritt: als Kreis ab dem Eintrittspunkt, von der nächsten Kante oder gewölbt von unten; die Textfarbe wechselt genau an der Kante der Füllung.
Leistungslisten und Knöpfe in direkten, ruhigen Rezepten (Handwerk, Agentur, Beratung): Kreis ab Eintritt, nächste Kante oder gewölbt von unten, Unterumschalter Form.
- Beim Betreten wird ermittelt, wo der Zeiger eintritt: als genaue Koordinate oder als nächstgelegene Kante.
- Eine Füllfläche wächst von dort über Button oder Zeile, als Kreis (clip-path: circle() an x/y) oder als Fläche von oben oder unten (clip-path: inset() oder translateY von ±100 %).
- Die Textfarbe wechselt mit, am saubersten über eine deckungsgleiche Kopie des Inhalts in der Füllfläche.
- Beim Verlassen zieht sich die Füllung zur Austrittsseite zurück.
Die Füllung folgt der Hand wie eine Flüssigkeit. Weil sie dort beginnt, wo der Zeiger hereinkommt, fühlt sich die Interaktion echt an. Hohe Wertigkeit bei wenig Aufwand.
Wann einsetzen
Primärbuttons, Zeilen in Menüs und Leistungs- oder Projektlisten, Karten, Pill-Buttons in hellen und dunklen Designs.
Wann nicht
Bei sehr kleinen Buttons, weil die Richtung dort nicht wahrnehmbar ist. Neben anderen Hover-Effekten, die um Aufmerksamkeit konkurrieren. Das Pixelraster nicht in ruhigen Marken (Kanzlei, Praxis, Luxus).
Varianten
- Kreis wächst ab dem genauen Eintrittspunkt: clip-path: circle(0 at x y) → circle(r at x y), r bis zur fernsten Ecke
- Füllung schiebt sich von der nächsten Kante oben oder unten (K72 von Locomotive, closestEdge, 0,6 s expo)
- Füllung startet bei y 75 % und läuft auf 0 (Codrops MagneticButtons Demo 4, 0,5 s power3.out)
- Feste Richtung von unten mit gewölbter Kante, wie auf Produktionsseiten verbreitet (cuberto.com: translateY(101%) → 0 und border-radius 50% 50% 0 0 → 0)
- Laufband-Streifen mit gegenläufigem Innenwrapper (K72)
- Ripple beim Klick statt Hover (siehe klick-ripple)
- Organische Blob-Füllung per SVG-Path-Morph
- Pixelraster: Zellen füllen gestaffelt vom Eintrittspunkt (Prinzip Pixel-Button), clip-path: path() aus Rasterzellen, 6 Stufen à 80 ms
Umsetzung
Füllfläche als eigenes Element (oder ::before) mit position: absolute; inset: 0. Für exakten Farbwechsel eine Kopie des Inhalts mit aria-hidden hineinlegen und beide mit derselben clip-path schneiden. Kreis: btn.addEventListener('pointerenter', e => { const r = btn.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top; … }). Zuerst den Startzustand ohne Transition setzen (fill.style.transition = 'none'; fill.style.clipPath = `circle(0px at ${x}px ${y}px)`), Layout lesen (getBoundingClientRect), dann Transition zurück und Ziel setzen: circle(Rpx at x y) mit R = Abstand zur fernsten Ecke. Ohne diesen Zwischenschritt wandert der Kreismittelpunkt von der alten Stelle herüber. pointerleave setzt circle(0px at Austrittspunkt). Kanten-Variante: Eintritt in der oberen oder unteren Hälfte entscheidet über inset(0 0 100% 0) bzw. inset(100% 0 0 0) als Start, Ziel inset(0). Werte: 0,6 s cubic-bezier(0.16, 1, 0.3, 1). Fallstrick: pageX und offsetTop brechen bei transformierten Eltern, deshalb getBoundingClientRect mit clientX/Y. Für Tastaturfokus ab 50 % / 50 % füllen.
Robust umsetzen
Telefon. Beim Tippen den Kreis vom Tipp-Punkt wachsen lassen (wirkt wie ein Ripple), kurz stehen lassen, dann navigieren.
Ohne Bewegung. Sofortiger Farbwechsel oder ein kurzer Crossfade von 150 ms statt der wachsenden Form.
Barrierefrei. Kontrast in beiden Zuständen prüfen. Die Textfarbe muss synchron zur Füllung wechseln, sonst steht dunkler Text kurz auf dunkler Fläche. :focus-visible bekommt denselben Effekt ab der Mitte. Die Kopie des Inhalts ist aria-hidden und enthält keine fokussierbaren Elemente.
Code
<!DOCTYPE html>
<!--
Füllung vom Eintrittspunkt
Fundus · Hover · https://cecodesign.de/lab/fundus/hover/fuellung-vom-eintrittspunkt/
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>Füllung vom Eintrittspunkt · 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: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
/* Welt: Tischlerei Hasekamp in Osnabrück (Rezept handwerk-werkstatt-klartext).
Materialfarben als Flächen, dunkle Schrift, genau ein Signalton (#FB6100) für Anfrage und Anruf. */
:root {
--grund: #EFEDEA; --zement: #D3CDC2; --holz: #C3B4A5; --salbei: #BDC1A9; --tinte: #1A1A1A; --leise: #5c5852; --linie: rgba(26, 26, 26, .16);
--signal: #FB6100;
--fuell: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, 600 ms (K72, gemessen) */
--ui: cubic-bezier(0.4, 0, 0.2, 1); /* Rezept: 150 ms für einfache Zustände */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.label { font: 600 13px/1.2 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; color: var(--leise); }
.nur-vorlesen { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Kopf */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px clamp(16px, 4vw, 56px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 12px; }
.marke svg { width: 36px; height: 36px; flex: none; }
.marke b { display: block; font: 700 23px/1 'Special Gothic', sans-serif; letter-spacing: -.01em; }
.marke small { display: block; margin-top: 3px; font: 600 12px/1.2 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; gap: 26px; font-size: 16px; }
.nav a { transition: color .15s var(--ui); }
.nav a:hover { color: var(--leise); }
.rechts { display: flex; align-items: center; gap: 10px; }
/* Das Muster: Füllfläche plus deckungsgleiche Kopie des Inhalts, beide mit derselben clip-path geschnitten.
So wechselt die Textfarbe genau an der Kante der Füllung. */
.ziel { position: relative; display: block; isolation: isolate; outline: none; -webkit-tap-highlight-color: transparent; }
.ziel .flaeche { position: absolute; inset: 0; z-index: 1; overflow: hidden; pointer-events: none; clip-path: inset(100% 0 0 0); transition: clip-path .6s var(--fuell); }
.ziel .flaeche > * { height: 100%; }
.ziel:focus-visible { outline: 2px solid var(--signal); outline-offset: 3px; }
/* Leistungen als Liste */
.buehne { display: grid; grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); gap: clamp(28px, 5vw, 80px); padding: clamp(28px, 5vh, 64px) clamp(16px, 4vw, 56px) clamp(20px, 3vh, 36px); align-items: start; }
.einleitung h1 { margin-top: 14px; font: 700 clamp(2.25rem, 1.5rem + 3vw, 3.5rem)/1.05 'Special Gothic', sans-serif; letter-spacing: -.01em; text-wrap: balance; }
.einleitung .lauf { margin-top: 18px; font-size: 1.0625rem; line-height: 1.55; max-width: 30em; text-wrap: pretty; }
.einleitung .knoepfe { margin-top: 28px; display: flex; flex-wrap: wrap; gap: 10px; }
.einleitung .gebiet { margin-top: 22px; font-size: 15px; line-height: 1.5; color: var(--leise); }
.knopf { border-radius: 999px; overflow: hidden; }
.knopf .inhalt { display: inline-flex; align-items: center; gap: 10px; height: 52px; padding: 0 24px; border-radius: inherit; font: 700 16px/1 'Atkinson Hyperlegible Next', sans-serif; white-space: nowrap; }
.knopf .tel { font: 700 17px/1 'Special Gothic', sans-serif; letter-spacing: .01em; font-variant-numeric: tabular-nums; }
.knopf svg { width: 16px; height: 16px; flex: none; }
.knopf.signal .inhalt { background: var(--signal); color: var(--tinte); }
.knopf.signal .flaeche .inhalt { background: var(--tinte); color: #fff; }
.knopf.rahmen .inhalt { box-shadow: inset 0 0 0 1.5px var(--tinte); }
.knopf.rahmen .flaeche .inhalt { background: var(--signal); box-shadow: none; }
.kopf .knopf .inhalt { height: 44px; padding: 0 18px; font-size: 15px; }
.liste { list-style: none; padding: 0; border-bottom: 1px solid var(--linie); }
.liste li { border-top: 1px solid var(--linie); }
.zeile .inhalt { display: grid; grid-template-columns: 2.6em minmax(0, 1fr) auto 68px; align-items: center; gap: 4px 22px; padding: clamp(14px, 2.2vh, 22px) 14px; }
.zeile .nr { align-self: start; padding-top: .55em; }
.zeile h2 { font: 700 clamp(1.5rem, 1rem + 1.5vw, 2.25rem)/1.05 'Special Gothic', sans-serif; letter-spacing: -.01em; }
.zeile p { margin-top: 6px; font-size: 1rem; line-height: 1.45; color: var(--leise); max-width: 36em; }
.zeile .preis { font: 600 13px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; padding: 8px 10px; border-radius: 999px; background: var(--zement); font-variant-numeric: tabular-nums; }
.zeile .plan { width: 68px; height: 50px; color: var(--tinte); }
.zeile .flaeche .inhalt { background: var(--tinte); color: var(--grund); }
.zeile .flaeche .label, .zeile .flaeche p { color: var(--zement); }
.zeile .flaeche .preis { background: var(--signal); color: var(--tinte); }
.zeile .flaeche .plan { color: var(--holz); }
/* Leiste */
.leiste { display: flex; justify-content: space-between; align-items: center; gap: 10px 24px; flex-wrap: wrap; padding: 6px clamp(16px, 4vw, 56px) 22px; }
.modi { display: flex; flex-wrap: wrap; gap: 4px; }
.modi[hidden] { display: none; }
.gruppen { display: flex; flex-wrap: wrap; gap: 8px 14px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 32px; padding: 0 13px; cursor: pointer; transition: color .15s var(--ui), background-color .15s var(--ui), border-color .15s var(--ui); }
.modi button:hover { color: var(--tinte); border-color: rgba(26, 26, 26, .4); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; justify-content: flex-end; font-size: 12px; }
.werte b { color: var(--tinte); }
/* Zahlen und Fuß */
.zahlen { margin: clamp(32px, 6vh, 64px) clamp(16px, 4vw, 56px) 0; display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--linie); border-bottom: 1px solid var(--linie); }
.zahlen div { padding: 22px 0; }
.zahlen div + div { padding-left: 22px; border-left: 1px solid var(--linie); }
.zahlen b { display: block; font: 700 clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem)/1 'Special Gothic', sans-serif; font-variant-numeric: tabular-nums; }
.zahlen span { display: block; margin-top: 8px; }
.fuss { margin-top: clamp(40px, 7vh, 72px); padding: clamp(40px, 6vh, 64px) clamp(16px, 4vw, 56px) 56px; background: var(--salbei); display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 28px; }
.fuss .label { color: var(--tinte); margin-bottom: 10px; }
.fuss p { font-size: 1rem; line-height: 1.6; }
.unten { display: none; }
/* Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
@media (min-width: 861px) { .einleitung { position: sticky; top: 32px; } }
@media (max-width: 1180px) { .nav { display: none; } }
@media (max-width: 1020px) { .zeile .plan { display: none; } .zeile .inhalt { grid-template-columns: 2.2em minmax(0, 1fr) auto; } }
@media (max-width: 860px) {
.buehne { grid-template-columns: 1fr; }
.zahlen { grid-template-columns: 1fr 1fr; }
.zahlen div:nth-child(3) { padding-left: 0; border-left: 0; }
.zahlen div:nth-child(n + 3) { border-top: 1px solid var(--linie); }
}
@media (max-width: 640px) {
.marke small, .kopf .rechts { display: none; }
.zeile .inhalt { grid-template-columns: minmax(0, 1fr) auto; padding-inline: 4px; column-gap: 12px; }
.zeile .nr { display: none; }
.zeile .preis { align-self: start; margin-top: 4px; }
.werte { justify-content: flex-start; }
.werte .lang { display: none; }
/* Mobil unten fest: Anrufen, Anfrage, WhatsApp (Rezept-Regel) */
.unten { position: fixed; z-index: 5; left: 0; right: 0; bottom: 0; display: grid; grid-template-columns: 1fr 1.25fr 1fr; gap: 6px; padding: 8px 8px calc(8px + env(safe-area-inset-bottom)); background: rgba(239, 237, 234, .94); border-top: 1px solid var(--linie); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.unten .knopf .inhalt { width: 100%; justify-content: center; height: 46px; padding: 0 10px; font-size: 15px; }
body { padding-bottom: 70px; }
}
/* Touch: kein Hover. Ein Tippen lässt die Füllung vom Tipp-Punkt wachsen (wie ein Ripple), danach ginge es weiter */
@media (hover: none) {
.modi button { height: 38px; padding: 0 15px; }
}
/* Reduzierte Bewegung: keine wachsende Form, nur ein kurzer Crossfade von 150 ms */
:root.reduziert .ziel .flaeche { clip-path: none !important; opacity: 0; transition: opacity .15s linear !important; }
:root.reduziert .ziel.an .flaeche { opacity: 1; }
:root.reduziert .zeiger { display: none; }
/* ===== Variante „pixel“ (Rezept kultur-programm-haltung, Takt aus token-brutal-hart) =====
Dieselbe deckungsgleiche Kopie, aber geschnitten mit clip-path: path() aus Rasterzellen.
Zellen erscheinen nach Abstand zum Eintrittspunkt in 6 Stufen à 80 ms, mit Zufallsversatz an der Kante. */
html:not([data-v="pixel"]) [data-welt="pixel"], html[data-v="pixel"] [data-welt="basis"] { display: none !important; }
html[data-v="pixel"] { --grund: #efede9; --tinte: #0b0b0b; --leise: #4f4c47; --linie: rgba(11, 11, 11, .2); --signal: #c4421a; }
html[data-v="pixel"] .ziel .flaeche { transition: none; }
.p-mono { font: 500 12.5px/1.35 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
.p-kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px clamp(16px, 4vw, 56px); border-bottom: 2px solid var(--tinte); }
.p-marke { display: flex; align-items: center; gap: 12px; }
.p-marke svg { width: 34px; height: 34px; flex: none; }
.p-marke b { font: 900 26px/1 'Big Shoulders', sans-serif; letter-spacing: .005em; text-transform: uppercase; }
.p-nav { display: flex; gap: 22px; font-size: 16px; }
.p-nav a:hover { text-decoration: underline; text-underline-offset: .2em; }
.p-buehne { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: clamp(28px, 5vw, 72px); padding: clamp(28px, 5vh, 60px) clamp(16px, 4vw, 56px) clamp(20px, 3vh, 36px); align-items: start; }
.p-einleitung h1 { margin-top: 14px; font: 800 clamp(2.75rem, 1.6rem + 4.4vw, 5.75rem)/.95 'Big Shoulders', sans-serif; letter-spacing: .005em; text-transform: uppercase; }
.p-einleitung .lauf { margin-top: 18px; max-width: 32em; font-size: 1.125rem; line-height: 1.6; text-wrap: pretty; }
.p-knoepfe { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
.knopf-p .inhalt { display: inline-flex; align-items: center; gap: 12px; height: 52px; padding: 0 22px; font: 700 17px/1 'Atkinson Hyperlegible Next', sans-serif; white-space: nowrap; }
.knopf-p.primaer .inhalt { background: var(--tinte); color: #fff; }
.knopf-p.primaer .flaeche .inhalt { background: #c4421a; }
.knopf-p.zweit .inhalt { box-shadow: inset 0 0 0 2px var(--tinte); }
.knopf-p.zweit .flaeche .inhalt { background: var(--tinte); color: #fff; }
.knopf-p .p-mono { font-size: 13px; opacity: 1; }
.p-hinweis { margin-top: 18px; font-size: 15px; line-height: 1.5; color: var(--leise); }
.p-liste { list-style: none; padding: 0; border-top: 2px solid var(--tinte); }
.p-liste li { border-bottom: 1px solid var(--linie); }
.p-zeile .inhalt { display: grid; grid-template-columns: 7.2em minmax(0, 1fr) 7.4em 4.2em; align-items: center; gap: 6px 18px; padding: 16px 12px; }
.p-zeile .zeit { align-self: start; padding-top: .3em; }
.p-zeile h2 { font: 800 clamp(1.625rem, 1.1rem + 1.4vw, 2.375rem)/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .01em; }
.p-zeile p { margin-top: 6px; font-size: 1rem; line-height: 1.45; color: var(--leise); }
.p-zeile .sparte { display: inline-flex; align-items: center; gap: 8px; }
.p-zeile .sparte::before { content: ''; flex: none; width: 10px; height: 10px; background: var(--sparte); }
.p-zeile .preis { text-align: right; }
.p-zeile .flaeche .inhalt { background: var(--sparte); color: #fff; }
.p-zeile .flaeche p { color: #fff; }
.p-zeile .flaeche .sparte::before { background: #fff; }
.p-fuss { margin: clamp(28px, 5vh, 56px) clamp(16px, 4vw, 56px) 0; padding: 22px 0 48px; border-top: 2px solid var(--tinte); display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 20px; font-size: 1rem; line-height: 1.55; }
.p-fuss .p-mono { display: block; margin-bottom: 6px; color: var(--leise); }
@media (max-width: 1020px) { .p-zeile .inhalt { grid-template-columns: 6.4em minmax(0, 1fr) auto; } .p-zeile .sparte { grid-column: 2; grid-row: 2; } .p-zeile .preis { grid-row: 1; grid-column: 3; } .p-nav { display: none; } }
@media (max-width: 860px) { .p-buehne { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 640px) {
.p-zeile .inhalt { grid-template-columns: minmax(0, 1fr) auto; padding: 14px 4px; }
.p-zeile .zeit { grid-column: 1; grid-row: 1; padding: 0; }
.p-zeile .preis { grid-column: 2; grid-row: 1; }
.p-zeile > .inhalt > div { grid-column: 1 / -1; grid-row: 2; }
.p-zeile .sparte { grid-column: 1 / -1; grid-row: 3; }
.p-kopf .p-datum { display: none; }
}
@media (max-width: 360px) { .knopf-p .inhalt { padding: 0 16px; } }
@media (forced-colors: active) { .ziel.knopf-p { border: 1px solid ButtonText; } }
.vorschau .p-einleitung .lauf, .vorschau .p-hinweis, .vorschau .p-fuss { display: none; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; padding-bottom: 0; }
.vorschau .leiste, .vorschau .zahlen, .vorschau .fuss, .vorschau .einleitung .lauf, .vorschau .einleitung .gebiet, .vorschau .unten { display: none; }
</style>
</head>
<body>
<header class="kopf" data-welt="basis">
<a class="marke" href="#" aria-label="Tischlerei Hasekamp, Startseite">
<svg viewBox="0 0 36 36" aria-hidden="true"><rect width="36" height="36" rx="4" fill="#1A1A1A"/><path d="M9 27V9M27 27V9M9 18h18" stroke="#C3B4A5" stroke-width="3.2"/><path d="M14 27h8" stroke="#FB6100" stroke-width="3.2"/></svg>
<span><b>Hasekamp</b><small>Tischlerei · Osnabrück-Eversburg</small></span>
</a>
<nav class="nav" aria-label="Hauptnavigation"><a href="#">Leistungen</a><a href="#">Referenzen</a><a href="#">So läuft ein Auftrag</a><a href="#">Team & Ausbildung</a><a href="#">Kontakt</a></nav>
<div class="rechts">
<a class="ziel knopf rahmen" href="tel:+495411274300" data-ziel><span class="inhalt tel">0541 127 43 00</span></a>
<a class="ziel knopf signal" href="#anfrage" data-ziel><span class="inhalt">Anfrage stellen</span></a>
</div>
</header>
<header class="p-kopf" data-welt="pixel">
<a class="p-marke" href="#" aria-label="Kanalrauschen, Startseite">
<svg viewBox="0 0 34 34" aria-hidden="true"><rect width="34" height="34" fill="#0b0b0b"/><path d="M5 5h6v6H5zM11 11h6v6h-6zM17 5h6v6h-6zM23 11h6v6h-6zM5 17h6v6H5zM17 17h6v6h-6zM11 23h6v6h-6zM23 23h6v6h-6z" fill="#efede9"/><path d="M23 23h6v6h-6z" fill="#c4421a"/></svg>
<b>Kanalrauschen</b>
</a>
<nav class="p-nav" aria-label="Hauptnavigation"><a href="#">Programm</a><a href="#">Orte</a><a href="#">Tickets</a><a href="#">Barrierefrei besuchen</a><a href="#">English</a></nav>
<span class="p-mono p-datum">14.–23. Mai 2027 · Siegen</span>
</header>
<main>
<section class="buehne" aria-labelledby="titel" data-welt="basis">
<div class="einleitung">
<p class="label">Leistungen · seit 1987 in Eversburg</p>
<h1 id="titel">Was wir für Sie bauen.</h1>
<p class="lauf">Vom Schrank unter der Dachschräge bis zur Haustür. Wir messen bei Ihnen auf, zeichnen, fertigen in unserer Werkstatt an der Wersener Straße und bauen selbst ein.</p>
<div class="knoepfe">
<a class="ziel knopf signal" href="#anfrage" data-ziel><span class="inhalt">Anfrage stellen <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 8h11M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="2"/></svg></span></a>
<a class="ziel knopf rahmen" href="tel:+495411274300" data-ziel><span class="inhalt">Anrufen</span></a>
</div>
<p class="gebiet">Wir arbeiten in Osnabrück und im Umkreis von 40 km. Erster Termin vor Ort kostenlos.</p>
</div>
<ul class="liste">
<li><a class="ziel zeile" href="#schraenke" data-ziel><div class="inhalt"><span class="label nr">01</span><div><h2>Einbauschränke & Dachschrägen</h2><p>Jeder Zentimeter genutzt, auch unter der Schräge. Fronten in Eiche, Esche oder lackiert.</p></div><span class="preis">ab 2.900 €</span>
<svg class="plan" viewBox="0 0 68 50" aria-hidden="true"><path d="M4 46V20L28 4h36v42Z M28 4v42 M46 4v42 M4 30h60" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M24 26v6M42 26v6M60 26v6" stroke="currentColor" stroke-width="1.5"/></svg></div></a></li>
<li><a class="ziel zeile" href="#kuechen" data-ziel><div class="inhalt"><span class="label nr">02</span><div><h2>Küchen nach Maß</h2><p>Korpusse aus der eigenen Werkstatt, Geräte nach Ihrer Wahl. Geplant wird bei Ihnen in der Küche.</p></div><span class="preis">ab 14.500 €</span>
<svg class="plan" viewBox="0 0 68 50" aria-hidden="true"><path d="M2 22h64M4 22v24h60V22M24 22v24M44 22v24M4 4h24v12H4ZM40 4h24v12H40Z" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M12 30h4M32 30h4M52 30h4" stroke="currentColor" stroke-width="1.5"/></svg></div></a></li>
<li><a class="ziel zeile" href="#treppen" data-ziel><div class="inhalt"><span class="label nr">03</span><div><h2>Treppen & Geländer</h2><p>Neue Holztreppen und das Aufarbeiten alter Stufen. Mit Statik aus einer Hand.</p></div><span class="preis">ab 6.800 €</span>
<svg class="plan" viewBox="0 0 68 50" aria-hidden="true"><path d="M4 46h12V36h12V26h12V16h12V6h12" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10 30 64 0M10 46V30M22 23v13M34 16v10M46 9v7" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></div></a></li>
<li><a class="ziel zeile" href="#tueren" data-ziel><div class="inhalt"><span class="label nr">04</span><div><h2>Haus- und Innentüren</h2><p>Haustüren in RC2, Innentüren samt Zarge. Der Einbau dauert meist einen Tag.</p></div><span class="preis">ab 690 €</span>
<svg class="plan" viewBox="0 0 68 50" aria-hidden="true"><path d="M20 48V4h28v44M24 48V8h20v40M2 48h64" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M39 28v4" stroke="currentColor" stroke-width="2"/></svg></div></a></li>
<li><a class="ziel zeile" href="#reparatur" data-ziel><div class="inhalt"><span class="label nr">05</span><div><h2>Reparatur & Fenster</h2><p>Klemmt, zieht, quietscht? Wir kommen meist innerhalb von zwei Wochen.</p></div><span class="preis">68 € je Stunde</span>
<svg class="plan" viewBox="0 0 68 50" aria-hidden="true"><path d="M14 4h40v42H14ZM18 8h32v34H18ZM34 8v34M18 25h32" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M30 22v6M38 22v6" stroke="currentColor" stroke-width="1.5"/></svg></div></a></li>
</ul>
</section>
<section class="p-buehne" aria-labelledby="p-titel" data-welt="pixel">
<div class="p-einleitung">
<p class="p-mono">Festival für Klang und Medienkunst · 7. Ausgabe</p>
<h1 id="p-titel">Zehn Tage Klang, Licht, Rauschen.</h1>
<p class="lauf">43 Veranstaltungen an neun Orten in der Siegener Oberstadt. Viele davon kostenlos, alle Orte stufenlos erreichbar, Programmhefte in Leichter Sprache an jeder Kasse.</p>
<div class="p-knoepfe">
<a class="ziel knopf-p primaer" href="#pass" data-ziel><span class="inhalt">Festivalpass <span class="p-mono">79 €</span></span></a>
<a class="ziel knopf-p zweit" href="#tage" data-ziel><span class="inhalt">Tagestickets</span></a>
</div>
<p class="p-hinweis">Ermäßigt 39 € für Schüler:innen, Studierende und mit Siegen-Pass.</p>
</div>
<ol class="p-liste" aria-label="Programm, erste Tage">
<li><a class="ziel p-zeile" href="#grundrauschen" data-ziel style="--sparte:#c4421a"><div class="inhalt"><span class="p-mono zeit">Fr 14.05.<br>20:00</span><div><h2>Grundrauschen</h2><p>Eröffnungskonzert für 40 Radios, Hüttensaal</p></div><span class="p-mono sparte">Musik</span><span class="p-mono preis">18 €</span></div></a></li>
<li><a class="ziel p-zeile" href="#bildstoerung" data-ziel style="--sparte:#b0186b"><div class="inhalt"><span class="p-mono zeit">Sa 15.05.<br>15:00</span><div><h2>Bildstörung</h2><p>Kurzfilme aus Fehlern und Störbildern, Kino Rubin</p></div><span class="p-mono sparte">Film</span><span class="p-mono preis">9 €</span></div></a></li>
<li><a class="ziel p-zeile" href="#lichtsatz" data-ziel style="--sparte:#2f7d4a"><div class="inhalt"><span class="p-mono zeit">Sa 15.05.<br>21:30</span><div><h2>Lichtsatz</h2><p>Raumarbeit mit 600 LED-Punkten, Alte Färberei</p></div><span class="p-mono sparte">Kunst</span><span class="p-mono preis">frei</span></div></a></li>
<li><a class="ziel p-zeile" href="#funkstille" data-ziel style="--sparte:#3d5f9a"><div class="inhalt"><span class="p-mono zeit">So 16.05.<br>18:00</span><div><h2>Funkstille</h2><p>Tanzstück ohne Musik, Studiobühne am Markt</p></div><span class="p-mono sparte">Tanz</span><span class="p-mono preis">16 €</span></div></a></li>
<li><a class="ziel p-zeile" href="#schaltkreis" data-ziel style="--sparte:#c4421a"><div class="inhalt"><span class="p-mono zeit">Mi 19.05.<br>17:00</span><div><h2>Schaltkreis-Labor</h2><p>Synthesizer selbst löten, ab 14 Jahren</p></div><span class="p-mono sparte">Musik</span><span class="p-mono preis">25 €</span></div></a></li>
</ol>
</section>
<div class="leiste">
<div class="gruppen">
<div class="modi label" role="group" aria-label="Variante" data-v-gruppe>
<button type="button" data-v-wahl="basis" aria-pressed="true">Clip-Path</button>
<button type="button" data-v-wahl="pixel" aria-pressed="false">Pixelraster</button>
</div>
<div class="modi label" role="group" aria-label="Form" data-m-gruppe>
<button type="button" data-modus="kreis" aria-pressed="true">Kreis ab Eintritt</button>
<button type="button" data-modus="kante" aria-pressed="false">Nächste Kante</button>
<button type="button" data-modus="welle" aria-pressed="false">Von unten, gewölbt</button>
</div>
</div>
<p class="werte label" data-werte></p>
</div>
<section class="zahlen" aria-label="Zahlen" data-welt="basis">
<div><b>1987</b><span class="label">gegründet von Heinz Hasekamp</span></div>
<div><b>11</b><span class="label">Leute in der Werkstatt</span></div>
<div><b>3</b><span class="label">Azubis, Start jedes Jahr am 1. August</span></div>
<div><b>40 km</b><span class="label">Einzugsgebiet um Osnabrück</span></div>
</section>
<footer class="fuss" id="anfrage" data-welt="basis">
<div><p class="label">Tischlerei Hasekamp</p><p>Wersener Straße 118<br>49090 Osnabrück</p></div>
<div><p class="label">Werkstatt</p><p>Mo bis Do 7 bis 16:30 Uhr<br>Fr 7 bis 13 Uhr</p></div>
<div><p class="label">Meisterbetrieb</p><p>Inhaber Jens Wöstmann, Tischlermeister<br>Handwerksrolle Osnabrück</p></div>
</footer>
<footer class="p-fuss" data-welt="pixel">
<div><span class="p-mono">Festivalzentrum</span>Alte Färberei, Kornmarkt 6<br>57072 Siegen</div>
<div><span class="p-mono">Kasse</span>Ab 3. Mai täglich 12 bis 19 Uhr<br>und eine Stunde vor Beginn am Ort</div>
<div><span class="p-mono">Zugang</span>Rollstuhlplätze, Induktionsschleife im Hüttensaal, Ruheraum</div>
</footer>
<nav class="unten" aria-label="Schnellkontakt" data-welt="basis">
<a class="ziel knopf rahmen" href="tel:+495411274300" data-ziel><span class="inhalt">Anrufen</span></a>
<a class="ziel knopf signal" href="#anfrage" data-ziel><span class="inhalt">Anfrage</span></a>
<a class="ziel knopf rahmen" href="#" data-ziel><span class="inhalt">WhatsApp</span></a>
</nav>
</main>
<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="#1A1A1A" stroke="#EFEDEA" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const werte = $('[data-werte]'), zeiger = $('[data-zeiger]');
// Werte: 600 ms expo.out (K72, gemessen), Kantenversatz 100 %, Kreis bis zur entferntesten Ecke
const DAUER = 600;
const RUECKZUG = 600;
const TOUCH_HALTEN = 250; // nach der Füllung kurz stehen lassen, dann ginge es zur Zielseite
let modus = 'kreis';
const ziele = [...document.querySelectorAll('[data-ziel]')];
// Füllfläche mit deckungsgleicher Kopie des Inhalts anlegen (aria-hidden, nicht fokussierbar)
ziele.forEach(z => {
const f = document.createElement('span');
f.className = 'flaeche'; f.setAttribute('aria-hidden', 'true');
const kopie = z.querySelector('.inhalt').cloneNode(true);
kopie.querySelectorAll('[id]').forEach(n => n.removeAttribute('id'));
f.appendChild(kopie); z.appendChild(f);
z._f = f;
});
function setze(z, start, ende) {
const f = z._f;
if (start !== null) {
f.style.transition = 'none';
f.style.clipPath = start;
f.getBoundingClientRect(); // Startzustand festschreiben
f.style.transition = '';
}
f.style.clipPath = ende;
}
const px = v => Math.round(v) + 'px';
function radius(r, x, y) { return Math.ceil(Math.max(Math.hypot(x, y), Math.hypot(r.width - x, y), Math.hypot(x, r.height - y), Math.hypot(r.width - x, r.height - y))) + 2; }
// Eintritt: x, y relativ zum Element (getBoundingClientRect + clientX/Y, robust bei transformierten Eltern)
// Pixelraster: Zellen 8 px (Knöpfe) bzw. 16 px (Zeilen), 6 Stufen à 80 ms (token-brutal-hart: pixel takt 80, gesamt 480)
const ZELLE_KNOPF = 8, ZELLE_ZEILE = 16, STUFEN = 6, TAKT = 80, STREU = 0.7;
function pixel(z, x, y, an) {
const r = z.getBoundingClientRect(), zelle = z.classList.contains('p-zeile') ? ZELLE_ZEILE : ZELLE_KNOPF;
const sp = Math.ceil(r.width / zelle), ze = Math.ceil(r.height / zelle), n = sp * ze;
let p = z._px;
if (!p || p.sp !== sp || p.ze !== ze) { if (p) clearTimeout(p.uhr); p = z._px = { sp, ze, zelle, auf: new Uint8Array(n), stufe: new Int8Array(n), uhr: 0 }; }
p.zelle = zelle;
const d = new Float32Array(n); let max = 1;
for (let i = 0; i < n; i++) { d[i] = Math.hypot(((i % sp) + .5) * zelle - x, (Math.floor(i / sp) + .5) * zelle - y); if (d[i] > max) max = d[i]; }
for (let i = 0; i < n; i++) {
const q = an ? d[i] / max : 1 - d[i] / max; // rein: nahe Zellen zuerst; raus: ferne zuerst, Rest zieht sich zum Austritt
p.stufe[i] = Math.max(0, Math.min(STUFEN - 1, Math.round(q * (STUFEN - 1) + (Math.random() * 2 - 1) * STREU)));
}
clearTimeout(p.uhr);
let k = 0;
const takt = () => {
for (let i = 0; i < n; i++) if (p.stufe[i] <= k) p.auf[i] = an ? 1 : 0;
zeichnePixel(z, p);
if (++k < STUFEN) p.uhr = setTimeout(takt, TAKT);
};
takt();
}
// Zellen einer Reihe zu Streifen zusammenfassen, alles als ein path() (nonzero: Vereinigung)
function zeichnePixel(z, p) {
let s = '';
for (let y = 0; y < p.ze; y++) for (let x = 0; x < p.sp;) {
if (!p.auf[y * p.sp + x]) { x++; continue; }
let e = x; while (e < p.sp && p.auf[y * p.sp + e]) e++;
s += `M${x * p.zelle} ${y * p.zelle}h${(e - x) * p.zelle}v${p.zelle}h${(x - e) * p.zelle}Z`;
x = e;
}
z._f.style.clipPath = s ? `path('${s}')` : 'inset(100% 0 0 0)';
}
function pixelAus(z) { if (z._px) { clearTimeout(z._px.uhr); z._px.auf.fill(0); } }
function rein(z, x, y) {
const r = z.getBoundingClientRect();
z.classList.add('an');
if (reduziert()) return;
if (modus === 'pixel') return pixel(z, x, y, true);
if (modus === 'kreis') {
setze(z, `circle(0px at ${px(x)} ${px(y)})`, `circle(${radius(r, x, y)}px at ${px(x)} ${px(y)})`);
} else if (modus === 'kante') {
const oben = y < r.height / 2;
setze(z, oben ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)', 'inset(0 0 0 0)');
} else {
setze(z, 'inset(100% 0 0 0 round 50% 50% 0 0)', 'inset(0 0 0 0 round 0 0 0 0)');
}
}
function raus(z, x, y) {
const r = z.getBoundingClientRect();
z.classList.remove('an');
if (reduziert()) return;
if (modus === 'pixel') return pixel(z, Math.min(r.width, Math.max(0, x)), Math.min(r.height, Math.max(0, y)), false);
if (modus === 'kreis') {
// zieht sich zur Austrittsstelle zurück
setze(z, null, `circle(0px at ${px(Math.min(r.width, Math.max(0, x)))} ${px(Math.min(r.height, Math.max(0, y)))})`);
} else if (modus === 'kante') {
setze(z, null, y < r.height / 2 ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)');
} else {
setze(z, null, 'inset(0 0 100% 0 round 0 0 50% 50%)');
}
}
function lokal(z, e) { const r = z.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
ziele.forEach(z => {
z.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !vorf) rein(z, ...lokal(z, e)); });
z.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !vorf) raus(z, ...lokal(z, e)); });
// Tastatur: Füllung ab Mitte
z.addEventListener('focus', () => { if (z.matches(':focus-visible')) { const r = z.getBoundingClientRect(); rein(z, r.width / 2, r.height / 2); } });
z.addEventListener('blur', () => { if (z.classList.contains('an')) { const r = z.getBoundingClientRect(); raus(z, r.width / 2, r.height / 2); } });
// Touch: Füllung wächst vom Tipp-Punkt wie ein Ripple, bleibt kurz, dann ginge es zur Seite
z.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') return;
const [x, y] = lokal(z, e);
rein(z, x, y);
clearTimeout(z._uhr);
z._uhr = setTimeout(() => raus(z, x, y), (modus === 'pixel' ? STUFEN * TAKT : DAUER) + TOUCH_HALTEN);
});
z.addEventListener('click', e => { if (!z.href.startsWith('tel:')) e.preventDefault(); });
});
function zeigeWerte() {
const teil = reduziert() ? '<span>Reduziert: <b>Crossfade 150 ms linear</b></span>' : {
kreis: '<span>clip-path <b>circle(0 → r at x y)</b></span><span class="lang">r <b>bis zur fernsten Ecke</b></span><span class="lang">Rückzug <b>zur Austrittsstelle</b></span>',
kante: '<span>clip-path <b>inset(100 % → 0)</b></span><span class="lang">Kante <b>oben oder unten, je nach Eintritt</b></span><span class="lang">Austritt <b>zur nächsten Kante</b></span>',
welle: '<span>clip-path <b>inset(100 % 0 0 0 round 50 % 50 % 0 0) → inset(0)</b></span><span class="lang">Austritt <b>nach oben</b></span>',
pixel: '<span>clip-path <b>path() aus Rasterzellen</b></span><span>Zelle <b>8 px Knopf · 16 px Zeile</b></span><span class="lang">Staffel <b>Abstand zum Eintritt ± 0,7 Stufen</b></span><span class="lang">Austritt <b>ferne Zellen zuerst</b></span>',
}[modus];
werte.innerHTML = teil + (reduziert() ? '' : modus === 'pixel' ? '<span>Takt <b>6 Stufen × 80 ms = 480 ms</b></span><span>Kurve <b>keine (Stufen)</b></span>' : `<span>Dauer <b>${DAUER} ms</b></span><span>Kurve <b>expo.out (0.16, 1, 0.3, 1)</b></span>`);
}
function setzeModus(neu) {
modus = neu;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
ziele.forEach(z => { z.classList.remove('an'); pixelAus(z); setze(z, 'inset(100% 0 0 0)', 'inset(100% 0 0 0)'); });
zeigeWerte();
}
document.querySelector('[data-m-gruppe]').addEventListener('click', e => {
const b = e.target.closest('[data-modus]'); if (!b) return;
stopVorfuehrung(); setzeModus(b.dataset.modus);
});
// Varianten nach Regel 13: ?v=basis|pixel (unbekannt oder leer: basis)
const VARIANTEN = ['basis', 'pixel'];
const vKnoepfe = [...document.querySelectorAll('[data-v-wahl]')], mGruppe = document.querySelector('[data-m-gruppe]');
let welt = 'basis', letzterModus = 'kreis';
function setzeWelt(v, url = true) {
welt = v; html.dataset.v = v;
vKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.vWahl === v)));
mGruppe.hidden = v === 'pixel';
if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
if (modus !== 'pixel') letzterModus = modus;
setzeModus(v === 'pixel' ? 'pixel' : letzterModus);
}
vKnoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeWelt(b.dataset.vWahl); }));
/* Vorführung: gedachter Zeiger fährt durch die Liste und über einen Knopf; je Variante eine Runde */
let vorf = false, vorfPause = false, vorfStart = 0, pausiertBei = 0, uhr = null, aktiv = null, phaseAlt = -1;
const RUNDE = 6200, REIHE = ['kreis', 'kante', 'welle'];
const glatt = t => t * t * (3 - 2 * t);
function punkte() {
// Wegpunkte in Seitenkoordinaten: von rechts unten in die letzte Zeile, schräg nach oben durch die Liste,
// seitlich hinaus, dann von links in den Anfrage-Knopf
const zeilen = [...document.querySelectorAll(welt === 'pixel' ? '.p-zeile' : '.zeile')], a = zeilen[0].getBoundingClientRect(), e = zeilen[zeilen.length - 1].getBoundingClientRect();
const k = document.querySelector(welt === 'pixel' ? '.p-knoepfe .primaer' : '.einleitung .knopf.signal').getBoundingClientRect();
return [
[e.right - e.width * 0.18, Math.min(innerHeight - 8, e.bottom + 70), 0],
[e.left + e.width * 0.66, e.top + e.height * 0.55, 0.16],
[e.left + e.width * 0.42, a.top + (e.top - a.top) * 0.42, 0.42],
[a.left + a.width * 0.2, a.top + a.height * 0.5, 0.58],
[a.left - 30, a.top + a.height * 0.5, 0.66],
[k.left - 50, k.top + k.height * 0.7, 0.72],
[k.left + k.width * 0.3, k.top + k.height * 0.62, 0.8],
[k.left + k.width * 0.4, k.bottom + 60, 0.96],
[k.left + k.width * 0.4, k.bottom + 60, 1],
];
}
function ort(q) {
const P = punkte();
for (let i = 1; i < P.length; i++) if (q <= P[i][2]) {
const t = glatt((q - P[i - 1][2]) / (P[i][2] - P[i - 1][2] || 1));
return [P[i - 1][0] + (P[i][0] - P[i - 1][0]) * t, P[i - 1][1] + (P[i][1] - P[i - 1][1]) * t];
}
return P[P.length - 1];
}
function treffer(x, y) {
return ziele.find(z => { if (!z.offsetParent && getComputedStyle(z).position !== 'fixed') return false; const r = z.getBoundingClientRect(); return r.width && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; }) || null;
}
function vorfuehrSchritt() {
if (vorfPause) { uhr = requestAnimationFrame(vorfuehrSchritt); return; }
const reihe = welt === 'pixel' ? ['pixel'] : REIHE;
const t = performance.now() - vorfStart, n = Math.floor(t / RUNDE), q = (t % RUNDE) / RUNDE;
if (n !== phaseAlt) { phaseAlt = n; if (aktiv) aktiv = null; setzeModus(reihe[n % reihe.length]); }
const [x, y] = ort(q);
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
const z = treffer(x, y);
if (z !== aktiv) {
if (aktiv) { const r = aktiv.getBoundingClientRect(); raus(aktiv, x - r.left, y - r.top); }
if (z) { const r = z.getBoundingClientRect(); rein(z, x - r.left, y - r.top); }
aktiv = z;
}
uhr = requestAnimationFrame(vorfuehrSchritt);
}
function vorfuehren() {
stopVorfuehrung();
if (reduziert()) { setzeModus(welt === 'pixel' ? 'pixel' : 'kreis'); return; }
vorf = true; vorfPause = false; vorfStart = performance.now(); phaseAlt = -1; aktiv = null;
html.classList.add('vorfuehrung');
uhr = requestAnimationFrame(vorfuehrSchritt);
}
function stopVorfuehrung() {
if (!vorf) return;
cancelAnimationFrame(uhr); vorf = false; vorfPause = false; aktiv = null;
html.classList.remove('vorfuehrung');
ziele.forEach(z => z.classList.remove('an'));
}
function pause() { if (vorf && !vorfPause) { vorfPause = true; pausiertBei = performance.now(); } }
function weiter() {
if (vorf && vorfPause) { vorfStart += performance.now() - pausiertBei; vorfPause = false; }
else if (!vorf && html.classList.contains('vorschau')) vorfuehren();
}
function neustart() { stopVorfuehrung(); setzeModus(modus); }
['pointerdown', 'wheel', 'keydown'].forEach(t => addEventListener(t, () => { if (vorf && !html.classList.contains('vorschau')) { stopVorfuehrung(); setzeModus(modus); } }, { passive: true, capture: true }));
const ausUrl = new URLSearchParams(location.search).get('v');
setzeWelt(VARIANTEN.includes(ausUrl) ? ausUrl : VARIANTEN[0], false);
})();
</script>
</body>
</html>