Zum Inhalt
← Mikroanimationen

Scroll-Fortschrittsbalken und -ring

Magazinartikel des erfundenen Tidehaus Flensburg: Lesebalken unter der Kopfzeile, Ring um den Nach-oben-Knopf oder Fortschritt je Kapitel, alles rein in CSS an den Scroll gekoppelt.

Ein schmaler Balken oben oder ein Ring wächst mit dem Scrollfortschritt. In CSS reicht animation-timeline: scroll() mit scaleX von 0 nach 1 und transform-origin links. Ohne Unterstützung übernimmt ein kleiner Scroll-Listener, bei rein dekorativem Zweck lässt man den Balken weg. Die ausführliche Fassung mit Kapitelanzeige steht unter fortschritt-kapitelanzeige.

Leser sehen, wie weit sie in einem langen Artikel oder einer Case Study sind und wie viel noch kommt. Das senkt die Hürde, weiterzulesen. Nebenbei ist der Balken ein präzises grafisches Detail, das den Rahmen der Seite lebendig macht.

Wann einsetzen

Lange Artikel, Case Studies, Dokumentationen und lange Landingpages. Außerdem gepinnte Scrollytelling-Strecken und horizontale Sections, bei denen man sonst die Orientierung verliert.

Wann nicht

Auf kurzen Seiten. Wenn eine Scrollspy-Navigation den Fortschritt schon zeigt. Als reine Dekoration ohne Orientierungsnutzen.

Varianten

  • Lesebalken oben: scaleX über scroll(root)
  • Ring um den Nach-oben-Knopf: SVG stroke-dashoffset oder conic-gradient mit scroll-timeline
  • Fortschritt je Abschnitt: view-timeline je Section, mit timeline-scope für den fixen Balken
  • Kapitelnavigation mit aktivem Zustand: IntersectionObserver oder scroll-state, aria-current
  • Kleiner Prozentzähler 00–100 als typografisches Element über @property und counter()
  • Schrittanzeige im Karussell: ::scroll-marker und ::scroll-marker-group
  • JS-Fortschrittsbalken (z. B. mit Motion), wenn CSS nicht reicht

Umsetzung

@media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: scroll()) { @keyframes grow { from { transform: scaleX(0) } to { transform: scaleX(1) } } #progress { position: fixed; inset: 0 0 auto 0; block-size: 3px; background: var(--accent); transform-origin: 0 50%; animation: grow auto linear; animation-timeline: scroll() } } } <div id='progress' aria-hidden='true'></div>

animation-timeline immer nach dem Shorthand animation deklarieren, sonst wird sie zurückgesetzt. Kurzform mit scale: @keyframes grow { from { scale: 0 1 } } und animation-timeline: scroll(root block). Fortschritt je Section: benannte view-timeline auf der Section, timeline-scope auf body, damit der fixe Balken sie referenzieren kann; animation-range: contain 0% contain 100%. Prozentzahl: @property --p { syntax: '<integer>'; inherits: false; initial-value: 0 } animieren und per counter() ausgeben. Einen Polyfill empfiehlt Chrome nicht.

Robust umsetzen

Telefon. Verhält sich wie auf dem Desktop. Einen fixen Balken unter der Browserleiste mit env(safe-area-inset-top) absetzen.

Ohne Bewegung. Zwei Haltungen sind vertretbar. Das Chrome-Beispiel blendet den Balken nur bei no-preference ein. Er darf aber auch bleiben, denn er folgt direkt der eigenen Scrollbewegung und bewegt sich nicht von selbst. Weiches Scrollen beim Nach-oben-Knopf dann abschalten.

Barrierefrei. Ein reiner Fortschrittsbalken ist dekorativ und bekommt aria-hidden='true'. Eine Kapitelnavigation steht in einem nav mit aria-label und markiert das aktive Kapitel mit aria-current. Der Nach-oben-Knopf ist ein button mit aria-label.

Code

<!DOCTYPE html>
<!--
  Scroll-Fortschrittsbalken und -ring
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/scroll-fortschritt/
  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, viewport-fit=cover">
<title>Scroll-Fortschrittsbalken und -ring · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

:root {
  /* Tidehaus Flensburg: Schwarz auf Off-White, Spartenfarbe Kunst */
  --grund: #efede9; --text: #0b0b0b; --leise: #5f5d58; --linie: #0b0b0b; --haar: rgba(11, 11, 11, .16);
  --sparte: #2a7043;          /* Sparte Kunst, AA auf Off-White */
  --kopf: 64px;
  --balken: 4px;              /* Messwert 2–4 px */
  --spalt: 6px;               /* Abstand der Kapitel-Segmente */
  --ui: cubic-bezier(0.215, 0.61, 0.355, 1);   /* Rezept: kurze Out-Kurve ohne Überschwingen */
  --ui-dauer: 200ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body { overflow-x: clip; }
a { color: inherit; }
.mono { font: 500 0.8125rem/1.35 'IBM Plex Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.versal { text-transform: uppercase; letter-spacing: .08em; font-size: .75rem; }

/* ---------- Kopf mit klarer Unterkante (HKW-Prinzip) ---------- */
.kopf { position: sticky; top: 0; z-index: 20; height: calc(var(--kopf) + env(safe-area-inset-top)); padding: env(safe-area-inset-top) clamp(16px, 3vw, 40px) 0; background: var(--grund); border-bottom: 1px solid var(--linie); display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 24px; }
.marke { display: flex; align-items: baseline; gap: 10px; text-decoration: none; }
.marke b { font: 900 1.5rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.035em; }
.marke span { color: var(--leise); }
.nav { display: flex; gap: clamp(16px, 2.2vw, 32px); justify-content: center; font: 600 .9375rem/1 'Schibsted Grotesk', sans-serif; }
.nav a { text-decoration: none; padding: 6px 0; border-bottom: 2px solid transparent; transition: border-color 150ms var(--ui); }
.nav a:hover, .nav a[aria-current] { border-color: var(--text); }
.kapitelzeile { display: none; justify-self: center; gap: 10px; align-items: baseline; white-space: nowrap; overflow: hidden; }
.kapitelzeile b { font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; overflow: hidden; text-overflow: ellipsis; }
.nav, .kapitelzeile { grid-column: 2; grid-row: 1; }
.kopf__rechts { grid-column: 3; display: flex; align-items: center; gap: 18px; }
.kopf__rechts .leise-link { text-decoration: none; color: var(--leise); transition: color 150ms var(--ui); }
.kopf__rechts .leise-link:hover { color: var(--text); }
.menue { display: none; text-decoration: none; }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; background: var(--text); color: var(--grund); border: 1px solid var(--text); font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; text-decoration: none; cursor: pointer; transition: background-color 150ms var(--ui), color 150ms var(--ui); }
.knopf:hover { background: var(--grund); color: var(--text); }

/* ---------- Fortschritt: Balken und Kapitel-Segmente ---------- */
.fortschritt { position: absolute; left: 0; right: 0; top: 100%; height: var(--balken); pointer-events: none; }
.balken, .segmente { position: absolute; inset: 0; transition: opacity var(--ui-dauer) var(--ui); }
.balken { background: var(--sparte); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); }
.segmente { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--spalt); padding: 0 clamp(16px, 3vw, 40px); opacity: 0; }
.segmente span { position: relative; background: var(--haar); overflow: hidden; }
.segmente i, .linie i { position: absolute; inset: 0; background: var(--sparte); transform-origin: 0 50%; transform: scaleX(var(--k, 0)); }

[data-variante='ring'] .balken, [data-variante='kapitel'] .balken { opacity: 0; }
[data-variante='kapitel'] .segmente { opacity: 1; }

/* ---------- Auftakt ---------- */
.auftakt { padding: clamp(40px, 8vh, 96px) clamp(16px, 3vw, 40px) clamp(32px, 5vh, 56px); display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); gap: clamp(24px, 4vw, 64px); align-items: end; max-width: 1440px; margin: 0 auto; }
.auftakt__oben { grid-column: 1 / -1; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; background: var(--sparte); color: #fff; }
.chip::before { content: ''; width: 8px; height: 8px; background: currentColor; }
h1 { font: 860 clamp(2.75rem, 7vw, 7rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-wrap: balance; max-width: 13ch; }
.vorspann { font-size: clamp(1.1875rem, 1rem + .5vw, 1.375rem); line-height: 1.5; max-width: 52ch; margin-top: clamp(20px, 3vh, 32px); text-wrap: pretty; }
.zeile-meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 22px; color: var(--leise); }
.termin { border-top: 1px solid var(--linie); padding-top: 14px; display: grid; gap: 10px; }
.termin h2 { font: 800 1.75rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.termin dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; }
.termin dt { color: var(--leise); }
.termin .knopf { justify-self: start; margin-top: 6px; }

/* ---------- Schlüsselbild (eigene SVG-Grafik) ---------- */
.tafel { margin: 0 clamp(16px, 3vw, 40px); max-width: 1360px; }
@media (min-width: 1441px) { .tafel { margin-inline: auto; } }
.tafel__flaeche { position: relative; background: var(--sparte); color: var(--grund); aspect-ratio: 21 / 9; overflow: hidden; }
.tafel svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.tafel__ecke { position: absolute; color: var(--grund); }
.tafel__ecke--lo { left: 18px; top: 16px; } .tafel__ecke--ro { right: 18px; top: 16px; text-align: right; }
.tafel__ecke--lu { left: 18px; bottom: 16px; } .tafel__ecke--ru { right: 18px; bottom: 16px; text-align: right; }
.tafel__wort { position: absolute; left: 18px; bottom: 58px; font: 900 clamp(2.5rem, 9vw, 8.5rem)/.82 'Schibsted Grotesk', sans-serif; letter-spacing: -.045em; color: var(--grund); }
.tafel figcaption { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding-top: 10px; color: var(--leise); }

/* ---------- Artikelraster: Inhalt | Text | Randspalte ---------- */
.raster { display: grid; grid-template-columns: 220px minmax(0, 680px) minmax(0, 1fr); gap: clamp(24px, 4vw, 64px); padding: clamp(48px, 9vh, 104px) clamp(16px, 3vw, 40px) 0; max-width: 1440px; margin: 0 auto; }
.inhalt { position: sticky; top: calc(var(--kopf) + 40px); align-self: start; }
.inhalt p { color: var(--leise); margin-bottom: 14px; }
.inhalt ol { list-style: none; display: grid; gap: 4px; }
.inhalt a { display: grid; grid-template-columns: 30px 1fr; gap: 2px 8px; padding: 10px 0 12px; text-decoration: none; color: var(--leise); transition: color 150ms var(--ui); }
.inhalt a:hover { color: var(--text); }
.inhalt a b { font: 700 1.0625rem/1.2 'Schibsted Grotesk', sans-serif; }
.linie { grid-column: 1 / -1; position: relative; height: 2px; background: var(--haar); margin-top: 8px; overflow: hidden; opacity: 0; transition: opacity var(--ui-dauer) var(--ui); }
[data-variante='kapitel'] .linie { opacity: 1; }
[data-variante='kapitel'] .inhalt a[aria-current] { color: var(--text); }

.text { font-size: 1.125rem; line-height: 1.6; }
.text > section { scroll-margin-top: calc(var(--kopf) + 24px); padding-bottom: clamp(56px, 10vh, 104px); }
.text p { max-width: 68ch; text-wrap: pretty; }
.text p + p { margin-top: 1em; }
.kapitel-kopf { display: flex; align-items: baseline; gap: 14px; border-top: 1px solid var(--linie); padding-top: 14px; margin-bottom: 26px; }
.kapitel-kopf .mono { color: var(--sparte); font-weight: 500; }
h3 { font: 800 clamp(1.75rem, 1.2rem + 1.6vw, 2.625rem)/1.05 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.text p:first-of-type::first-line { font-weight: 700; }

.zitat { margin: 40px 0; padding-left: 22px; border-left: 4px solid var(--sparte); }
.zitat p { font: 760 clamp(1.5rem, 1.1rem + 1.2vw, 2.125rem)/1.15 'Schibsted Grotesk', sans-serif; letter-spacing: -.015em; max-width: 24ch; }
.zitat footer { margin-top: 12px; color: var(--leise); }

.zahlen { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--linie); border-bottom: 1px solid var(--linie); margin: 36px 0; }
.zahlen div { padding: 16px 12px 16px 0; }
.zahlen div + div { padding-left: 14px; border-left: 1px solid var(--haar); }
.zahlen b { display: block; font: 800 2.5rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.zahlen span { display: block; margin-top: 6px; color: var(--leise); }

.programm { list-style: none; border-top: 1px solid var(--linie); margin: 32px 0 0; }
.programm li { display: grid; grid-template-columns: 92px 1fr auto; gap: 4px 18px; align-items: baseline; padding: 14px 0; border-bottom: 1px solid var(--haar); }
.programm .ort { grid-column: 2; color: var(--leise); }
.programm strong { font: 700 1.125rem/1.25 'Schibsted Grotesk', sans-serif; }
.programm a { grid-row: 1 / span 2; grid-column: 3; text-decoration-thickness: 1px; text-underline-offset: 4px; }

/* Randspalte: Notizen und Objektgrafik */
.rand { position: relative; }
.notiz { position: absolute; width: min(260px, 100%); color: var(--leise); border-top: 1px solid var(--haar); padding-top: 10px; }
.notiz b { color: var(--text); font-weight: 500; display: block; margin-bottom: 4px; }
.latte { width: 64px; height: auto; display: block; margin-bottom: 12px; }
.latte-inline .latte { width: 40px; margin: 0; flex: none; }

/* ---------- Besuch (Block tauscht Fläche und Schrift) ---------- */
.besuch { background: var(--text); color: var(--grund); margin-top: clamp(24px, 6vh, 64px); }
.besuch__innen { max-width: 1440px; margin: 0 auto; padding: clamp(48px, 9vh, 104px) clamp(16px, 3vw, 40px); display: grid; grid-template-columns: 1.2fr 1fr; gap: clamp(32px, 5vw, 80px); align-items: end; }
.besuch h2 { font: 880 clamp(2.5rem, 6vw, 5.5rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.025em; }
.besuch .mono { color: rgba(239, 237, 233, .7); }
.besuch dl { display: grid; grid-template-columns: auto 1fr; gap: 10px 20px; font-size: 1.125rem; line-height: 1.45; }
.besuch dt { font: 500 .8125rem/1.8 'IBM Plex Mono', monospace; color: rgba(239, 237, 233, .7); }
.besuch .knopf { background: var(--grund); color: var(--text); border-color: var(--grund); margin-top: 24px; }
.besuch .knopf:hover { background: transparent; color: var(--grund); }
.fuss { max-width: 1440px; margin: 0 auto; padding: 28px clamp(16px, 3vw, 40px) 120px; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--leise); }

/* ---------- Ring um den Nach-oben-Knopf ---------- */
.hoch { position: fixed; right: max(20px, env(safe-area-inset-right)); bottom: max(20px, env(safe-area-inset-bottom)); z-index: 30; display: flex; align-items: center; gap: 12px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: opacity 250ms var(--ui), transform 250ms var(--ui); }
[data-variante='ring'] .hoch { opacity: 1; transform: none; pointer-events: auto; }
.hoch__rest { background: var(--grund); padding: 4px 8px; border: 1px solid var(--haar); }
.hoch button { position: relative; width: 56px; height: 56px; border-radius: 50%; border: 0; background: var(--text); color: var(--grund); cursor: pointer; display: grid; place-items: center; transition: background-color 150ms var(--ui); }
.hoch button:hover { background: var(--sparte); }
.hoch button:focus-visible, .knopf:focus-visible, a:focus-visible, .steuer button:focus-visible { outline: 2px solid var(--sparte); outline-offset: 3px; }
.ring { position: absolute; inset: -6px; width: 68px; height: 68px; transform: rotate(-90deg); pointer-events: none; }
.ring circle { fill: none; stroke-width: 2.5; }
.ring .spur { stroke: var(--haar); }
.ring .fuell { stroke: var(--sparte); stroke-dasharray: 1; stroke-dashoffset: calc(1 - var(--p, 0)); }

/* ---------- Scroll-gekoppelt in CSS, wo der Browser es kann ---------- */
@keyframes wachsen { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes schliessen { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@supports (animation-timeline: scroll()) {
  body { timeline-scope: --k1, --k2, --k3, --k4; }
  #k1 { view-timeline: --k1 block; } #k2 { view-timeline: --k2 block; } #k3 { view-timeline: --k3 block; } #k4 { view-timeline: --k4 block; }
  .text > section { view-timeline-inset: var(--kopf) 0; }
  /* timeline nach dem Shorthand setzen, sonst setzt animation sie zurück */
  .balken { animation: wachsen auto linear both; animation-timeline: scroll(root block); }
  .ring .fuell { animation: schliessen auto linear both; animation-timeline: scroll(root block); }
  /* Langschreibweise, damit die Zeitleiste je Kapitel (unten) nicht vom Shorthand überschrieben wird */
  .segmente span i, .linie i { animation-name: wachsen; animation-duration: auto; animation-timing-function: linear; animation-fill-mode: both; animation-range: contain 0% contain 100%; }
  [data-k='1'] i { animation-timeline: --k1; } [data-k='2'] i { animation-timeline: --k2; }
  [data-k='3'] i { animation-timeline: --k3; } [data-k='4'] i { animation-timeline: --k4; }
}

/* ---------- Bedienung der Demo ---------- */
.steuer { position: fixed; left: max(20px, env(safe-area-inset-left)); bottom: max(20px, env(safe-area-inset-bottom)); z-index: 30; background: var(--grund); border: 1px solid var(--linie); padding: 10px 12px; display: grid; gap: 8px; max-width: min(560px, calc(100vw - 140px)); }
.steuer__reihe { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.steuer__reihe > span { color: var(--leise); margin-right: 4px; }
.steuer button { font: inherit; letter-spacing: inherit; text-transform: inherit; height: 28px; padding: 0 10px; background: none; color: var(--text); border: 1px solid var(--haar); cursor: pointer; transition: background-color 150ms var(--ui), color 150ms var(--ui); }
.steuer button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.steuer__werte { color: var(--leise); font-size: .75rem; }
.steuer__werte b { color: var(--text); font-weight: 500; }

/* ---------- Reduzierte Bewegung ---------- */
/* Der Fortschritt bleibt: er folgt 1:1 der eigenen Scrollbewegung und bewegt sich nie von selbst.
   Weg fallen alle Übergänge und das weiche Scrollen. */
:root.reduziert *, :root.reduziert *::before { transition-duration: .01ms !important; }

/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .steuer { display: none; }

/* Desktop: das Pult bleibt in der Spalte des Inhaltsverzeichnisses und verdeckt keinen Lesetext */
@media (min-width: 901px) {
  .steuer { left: max(clamp(16px, 3vw, 40px), calc((100vw - 1440px) / 2 + 40px)); width: calc(220px + clamp(24px, 4vw, 64px) - 16px); max-width: none; }
  .steuer__reihe { gap: 4px; }
  .steuer__reihe > span { flex-basis: 100%; }
  .steuer button { padding: 0 8px; }
}
@media (min-width: 901px) and (max-width: 1100px) { .steuer { width: calc(200px + clamp(24px, 4vw, 64px) - 16px); } }

/* ---------- Breiten ---------- */
@media (max-width: 1100px) {
  .raster { grid-template-columns: 200px minmax(0, 1fr); }
  .rand { display: none; }
  .text .latte-inline { display: flex; }
}
.latte-inline { display: none; gap: 16px; align-items: flex-end; margin: 32px 0; color: var(--leise); }
@media (max-width: 900px) {
  :root { --kopf: 56px; }
  .kopf { grid-template-columns: auto 1fr auto; gap: 12px; }
  .nav, .kopf__rechts .leise-link { display: none; }
  .menue { display: inline; }
  [data-variante='kapitel'] .kapitelzeile { display: flex; }
  .auftakt { grid-template-columns: 1fr; }
  .tafel__flaeche { aspect-ratio: 4 / 3; }
  .raster { grid-template-columns: 1fr; padding-top: 48px; }
  .inhalt { display: none; }
  .besuch__innen { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
  .marke span { display: none; }
  .kapitelzeile .mono { display: none; }
  .knopf { height: 36px; padding: 0 12px; }
  .zahlen { grid-template-columns: repeat(2, 1fr); }
  .zahlen div:nth-child(3) { padding-left: 0; border-left: 0; }
  .zahlen div:nth-child(n+3) { border-top: 1px solid var(--haar); }
  .programm li { grid-template-columns: 76px 1fr; }
  .programm a { grid-row: auto; grid-column: 2; justify-self: start; }
  .tafel__ecke--ro, .tafel__ecke--ru { display: none; }
  .tafel__wort { bottom: 54px; }
  .steuer { left: 12px; right: 12px; bottom: 12px; max-width: none; padding: 8px 10px; }
  .steuer__werte { display: none; }
  .hoch { right: 16px; bottom: 74px; }
  .hoch__rest { display: none; }
  .fuss { padding-bottom: 150px; }
}
@media (hover: none) {
  /* Touch: keine Hover-Zustände, die nach dem Tippen hängen bleiben */
  .knopf:hover { background: var(--text); color: var(--grund); }
  .besuch .knopf:hover { background: var(--grund); color: var(--text); }
  .hoch button:hover { background: var(--text); }
  .steuer button { height: 34px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="balken">
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
          <defs>
            <g id="pegellatte">
              <rect x="10" y="0" width="44" height="300" fill="#f7f6f2" stroke="#0b0b0b" stroke-width="1.5"/>
              <g fill="#0b0b0b">
                <rect x="10" y="10" width="22" height="50"/><rect x="32" y="10" width="22" height="10"/><rect x="32" y="30" width="22" height="10"/><rect x="32" y="50" width="22" height="10"/>
                <rect x="32" y="110" width="22" height="50"/><rect x="10" y="110" width="22" height="10"/><rect x="10" y="130" width="22" height="10"/><rect x="10" y="150" width="22" height="10"/>
                <rect x="10" y="210" width="22" height="50"/><rect x="32" y="210" width="22" height="10"/><rect x="32" y="230" width="22" height="10"/><rect x="32" y="250" width="22" height="10"/>
              </g>
              <rect x="10" y="232" width="44" height="68" fill="#2a7043" opacity=".85"/>
            </g>
          </defs>
</svg>
<header class="kopf">
  <a class="marke" href="#oben" aria-label="Tidehaus Flensburg, Startseite"><b>Tidehaus</b><span class="mono">Flensburg</span></a>
  <nav class="nav" aria-label="Hauptnavigation">
    <a href="#programm">Programm</a><a href="#besuch">Ausstellungen</a><a href="#oben" aria-current="page">Magazin</a><a href="#besuch">Besuch</a>
  </nav>
  <p class="kapitelzeile" aria-live="polite"><span class="mono" data-kz-nr>00/04</span><b data-kz-titel>Auftakt</b></p>
  <div class="kopf__rechts">
    <a class="leise-link mono" href="#text">Vorlesen</a>
    <a class="leise-link mono" href="#oben">EN</a>
    <a class="knopf" href="#besuch">Tickets</a>
    <a class="menue mono" href="#oben">Menü</a>
  </div>
  <div class="fortschritt" aria-hidden="true">
    <div class="balken"></div>
    <div class="segmente"><span data-k="1"><i></i></span><span data-k="2"><i></i></span><span data-k="3"><i></i></span><span data-k="4"><i></i></span></div>
  </div>
</header>

<main id="oben">
  <section class="auftakt">
    <div class="auftakt__oben mono"><span class="chip versal">Kunst</span><span class="versal">Magazin · Hinter der Ausstellung</span></div>
    <div>
      <h1>Wie man einen Wasserstand ausstellt.</h1>
      <p class="vorspann">Für „Mittelwasser“ haben wir vierzehn Monate lang Messbücher gelesen, Künstlerinnen auf die Mole geschickt und eine Halle abgedichtet, die nie nass wird. Ein Blick hinter die Kulissen, in vier Kapiteln.</p>
      <p class="zeile-meta mono"><span>Text: Merle Johannsen</span><span>7 Min. Lesezeit</span><span>01.11.2026</span></p>
    </div>
    <aside class="termin mono" aria-label="Zur Ausstellung">
      <span class="versal">Ausstellung</span>
      <h2>Mittelwasser</h2>
      <dl><dt>Laufzeit</dt><dd>14.11.2026 – 01.03.2027</dd><dt>Ort</dt><dd>Halle 2, Erdgeschoss</dd><dt>Eintritt</dt><dd>9 € · ermäßigt 6 €</dd></dl>
      <a class="knopf" href="#besuch">Tickets</a>
    </aside>
  </section>

  <figure class="tafel">
    <div class="tafel__flaeche">
      <svg data-linien viewBox="0 0 1200 520" preserveAspectRatio="none" aria-hidden="true"></svg>
      <span class="tafel__ecke tafel__ecke--lo mono versal">Mittelwasser</span>
      <span class="tafel__ecke tafel__ecke--ro mono versal">14.11.26 – 01.03.27</span>
      <span class="tafel__wort" aria-hidden="true">Mittel&shy;wasser</span>
      <span class="tafel__ecke tafel__ecke--lu mono versal">Tidehaus · Halle 2</span>
      <span class="tafel__ecke tafel__ecke--ru mono versal">Ausschnitt · 30 von 150</span>
    </div>
    <figcaption class="mono"><span>Schlüsselbild der Ausstellung: Ida Kjær, „Hundertfünfzig Winter“, 2026 (Ausschnitt)</span><span>Jede Linie ein Winter an der Förde</span></figcaption>
  </figure>

  <div class="raster" id="text">
    <nav class="inhalt" aria-label="Kapitel">
      <p class="mono versal">Inhalt · 4 Kapitel</p>
      <ol>
        <li><a href="#k1"><span class="mono">01</span><b>Die Frage</b><span class="linie" data-k="1" aria-hidden="true"><i></i></span></a></li>
        <li><a href="#k2"><span class="mono">02</span><b>Die Leihgaben</b><span class="linie" data-k="2" aria-hidden="true"><i></i></span></a></li>
        <li><a href="#k3"><span class="mono">03</span><b>Der Aufbau</b><span class="linie" data-k="3" aria-hidden="true"><i></i></span></a></li>
        <li><a href="#k4"><span class="mono">04</span><b>Die Eröffnung</b><span class="linie" data-k="4" aria-hidden="true"><i></i></span></a></li>
      </ol>
    </nav>

    <article class="text">
      <section id="k1" aria-labelledby="h-k1">
        <div class="kapitel-kopf"><span class="mono">01</span><h3 id="h-k1">Die Frage</h3></div>
        <p>Es begann mit einem Karton. Im Herbst 2025 brachte uns ein pensionierter Hafenmeister sieben Messbücher vorbei, gebunden in grünes Leinen. Darin, Tag für Tag und in drei verschiedenen Handschriften, der Wasserstand an der Hafenspitze. Manche Einträge haben einen Stern. Daneben steht dann nur ein Wort: „Keller“.</p>
        <p>Wir haben uns gefragt, ob man so etwas ausstellen kann. Eine Zahl, die jeden Tag eine andere ist. Ein Wasser, das in Flensburg fast nie Ebbe und Flut kennt, aber bei Ostwind plötzlich in der Norderstraße steht. Die Antwort war: nicht als Zahl. Nur als Erfahrung.</p>
        <p>So wurde aus einem Archivprojekt eine Ausstellung mit vier künstlerischen Positionen, dreiundsechzig Objekten und einer Halle, in der man den höchsten Wasserstand der Stadtgeschichte am eigenen Körper ablesen kann.</p>
      </section>

      <section id="k2" aria-labelledby="h-k2">
        <div class="kapitel-kopf"><span class="mono">02</span><h3 id="h-k2">Die Leihgaben</h3></div>
        <p>Elf Leihgeber haben uns Dinge anvertraut, die normalerweise niemand sehen will. Eine Pegellatte aus Eichenholz von 1911, unten schwarz vom Teer, oben gebleicht. Das Tagebuch einer Fischerfamilie aus Sonwik. Eine Kinderzeichnung von der Hochwassernacht 1989, mit Buntstift, auf der die Häuser Schwimmflügel tragen.</p>
        <div class="zahlen mono">
          <div><b>63</b><span>Objekte</span></div>
          <div><b>11</b><span>Leihgeber</span></div>
          <div><b>4</b><span>Positionen</span></div>
          <div><b>7</b><span>Messbücher</span></div>
        </div>
        <p>Jedes Objekt bekam ein eigenes Gespräch. Wir wollten nicht nur wissen, woher es kommt, sondern wo es gestanden hat, als das Wasser kam. Diese Sätze stehen jetzt neben den Vitrinen, in Alltagssprache und in Leichter Sprache.</p>
        <div class="latte-inline mono">
          <svg class="latte" viewBox="0 0 64 300" aria-hidden="true"><use href="#pegellatte"/></svg>
          <span><b style="color:var(--text);font-weight:500">Leihgabe 17</b><br>Pegellatte, Eiche, 1911<br>Privatbesitz, Flensburg</span>
        </div>
        <p>Die vier Künstlerinnen und Künstler haben auf diese Sammlung geantwortet. Ida Kjær zeichnet seit einem Jahr jeden Morgen den Wasserstand als Linie. Jonas Petersen-Holm hat die Geräusche der Hafenspitze bei Sturm aufgenommen, vierzig Nächte lang.</p>
      </section>

      <section id="k3" aria-labelledby="h-k3">
        <div class="kapitel-kopf"><span class="mono">03</span><h3 id="h-k3">Der Aufbau</h3></div>
        <p>Halle 2 ist ein ehemaliger Speicher mit einem Betonboden, der seit hundert Jahren trocken ist. Genau das war das Problem. Wie erzählt man von Wasser in einem Raum, der es fernhält? Wir haben uns gegen Wasser entschieden und für Licht.</p>
        <blockquote class="zitat">
          <p>Die Halle wird nie nass. Aber man soll spüren, wie hoch das Wasser schon stand.</p>
          <footer class="mono">Hauke Brodersen, Kurator</footer>
        </blockquote>
        <p>Eine Lichtkante läuft in der Höhe des Rekordpegels durch den ganzen Raum. Wer hindurchgeht, steht bis zur Brust darunter. Kinder verschwinden ganz. Am Eingang hängt eine Tastversion des Grundrisses, und alle Texte gibt es als Audioguide, gelesen von Menschen aus der Nachbarschaft.</p>
        <p>Der Aufbau dauerte neun Wochen. Die längste davon gehörte einer einzigen Vitrine: Das Messbuch von 1872 darf nur bei 50 Lux liegen und nie aufgeschlagen bleiben. Jetzt blättert ein kleiner Motor einmal pro Stunde um.</p>
      </section>

      <section id="k4" aria-labelledby="h-k4">
        <div class="kapitel-kopf"><span class="mono">04</span><h3 id="h-k4">Die Eröffnung</h3></div>
        <p>Am 13. November öffnen wir die Halle für alle, die mitgeholfen haben, und für alle anderen auch. Der Eintritt ist an diesem Abend frei. Danach begleitet ein Programm die Ausstellung bis in den März: Führungen, Gespräche und eine Nacht an der Mole.</p>
        <ul class="programm" id="programm">
          <li><span class="mono">Fr 13.11.<br>19:00</span><strong>Eröffnung „Mittelwasser“</strong><span class="ort mono">Halle 2 · Eintritt frei</span><a class="mono" href="#besuch">Merken</a></li>
          <li><span class="mono">So 15.11.<br>15:00</span><strong>Führung mit dem Kurator</strong><span class="ort mono">Treffpunkt Foyer · 60 Min.</span><a class="mono" href="#besuch">Tickets</a></li>
          <li><span class="mono">Do 19.11.<br>18:30</span><strong>Gespräch: Leben mit dem Hochwasser</strong><span class="ort mono">Saal · mit Gebärdensprache</span><a class="mono" href="#besuch">Tickets</a></li>
          <li><span class="mono">Sa 28.11.<br>11:00</span><strong>Tastführung durch die Halle</strong><span class="ort mono">Halle 2 · mit Anmeldung</span><a class="mono" href="#besuch">Anmelden</a></li>
        </ul>
        <p style="margin-top:28px">Und die Messbücher? Gehen nach der Ausstellung zurück an ihren Besitzer. Er hat versprochen, das achte anzufangen.</p>
      </section>
    </article>

    <aside class="rand mono" aria-label="Randnotizen">
      <div class="notiz" data-zu="k1" style="top:0"><b>Messbuch Nr. 3</b>1951–1968 · Leinen, 212 Seiten · Leihgabe aus Privatbesitz</div>
      <div class="notiz" data-zu="k2">
        <svg class="latte" viewBox="0 0 64 300" aria-hidden="true">
          <use href="#pegellatte"/>
        </svg>
        <b>Leihgabe 17</b>Pegellatte, Eiche, 1911 · unten geteert · Privatbesitz, Flensburg</div>
      <div class="notiz" data-zu="k3"><b>Lichtkante</b>Höhe des Rekordpegels, durchgehend 26 m · LED, 2700 K</div>
      <div class="notiz" data-zu="k4"><b>Barrierefrei</b>Rampe und Aufzug · Tastmodell · Audioguide · Texte in Leichter Sprache</div>
    </aside>
  </div>

  <section class="besuch" id="besuch" aria-labelledby="h-besuch">
    <div class="besuch__innen">
      <div>
        <p class="mono versal">Ausstellung · Halle 2</p>
        <h2 id="h-besuch">Mittelwasser.<br>14.11.2026 –<br>01.03.2027</h2>
      </div>
      <div>
        <dl>
          <dt>Öffnungszeiten</dt><dd>Di–So 11–18 Uhr, Do bis 21 Uhr</dd>
          <dt>Eintritt</dt><dd>9 €, ermäßigt 6 €, bis 18 Jahre frei</dd>
          <dt>Zugang</dt><dd>Stufenlos über die Hafenseite, Aufzug zu allen Ebenen</dd>
        </dl>
        <a class="knopf" href="#oben">Tickets für Mittelwasser</a>
      </div>
    </div>
  </section>
</main>
<footer class="fuss mono"><span>Tidehaus Flensburg · Haus für Kunst, Musik und Film an der Förde</span><span>Newsletter · Leichte Sprache · Barrierefreiheit</span></footer>

<div class="hoch">
  <span class="hoch__rest mono" data-rest>Noch 7 Min.</span>
  <button type="button" aria-label="Nach oben">
    <svg class="ring" viewBox="0 0 68 68" aria-hidden="true"><circle class="spur" cx="34" cy="34" r="32" pathLength="1"/><circle class="fuell" cx="34" cy="34" r="32" pathLength="1"/></svg>
    <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 15V3M3.5 8.5 9 3l5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
  </button>
</div>

<div class="steuer mono" role="group" aria-label="Demo-Steuerung">
  <div class="steuer__reihe"><span class="versal">Variante</span>
    <button type="button" data-v="balken" aria-pressed="true">Balken</button>
    <button type="button" data-v="ring" aria-pressed="false">Ring</button>
    <button type="button" data-v="kapitel" aria-pressed="false">Kapitel</button>
  </div>
  <p class="steuer__werte" data-werte></p>
</div>

<script>
(() => {
  const html = document.documentElement, body = document.body;
  const reduziert = () => html.classList.contains('reduziert');
  const kannCSS = CSS.supports('animation-timeline: scroll()');
  const kapitel = [...document.querySelectorAll('.text > section')];
  const links = [...document.querySelectorAll('.inhalt a')];
  const kz = { nr: document.querySelector('[data-kz-nr]'), titel: document.querySelector('[data-kz-titel]') };
  const rest = document.querySelector('[data-rest]');
  const werteEl = document.querySelector('[data-werte]');
  const LESEZEIT = 7;
  const VARIANTEN = ['balken', 'ring', 'kapitel'];
  const WERTE = {
    balken: '<b>scroll(root block)</b> · linear · scaleX 0 → 1 · Höhe 4 px · origin 0 50 %',
    ring: '<b>scroll(root block)</b> · linear · stroke-dashoffset 1 → 0 · Ring Ø 68 px, Strich 2,5 px',
    kapitel: '<b>view-timeline je Kapitel</b> · timeline-scope · range contain 0–100 % · 4 px, Spalt 6 px'
  };

  /* Schlüsselbild: 150 Winter als Linien, deterministisch erzeugt */
  (function linien() {
    const svg = document.querySelector('[data-linien]');
    let s = 7; const zufall = () => (s = (s * 16807) % 2147483647) / 2147483647;
    const N = 30, B = 1200, schritt = 12;
    let d = '';
    for (let n = 0; n < N; n++) {
      const y0 = 60 + n * 15, a = zufall() * 6.28, f = 0.6 + zufall() * 0.9, sturm = 0.25 + zufall() * 0.6, hoehe = 18 + zufall() * 70 * (n % 7 === 3 ? 1.6 : 1);
      let p = `M0 ${y0}`;
      for (let x = 0; x <= B; x += schritt) {
        const u = x / B;
        const welle = Math.sin(u * 14 * f + a) * 4 + Math.sin(u * 37 + a * 2) * 2;
        const spitze = Math.exp(-Math.pow((u - sturm) / 0.035, 2)) * hoehe;
        p += ` L${x} ${(y0 - welle - spitze).toFixed(1)}`;
      }
      d += `<path d="${p} L${B} 520 L0 520 Z" fill="#2a7043" stroke="rgba(239,237,233,.82)" stroke-width="1.3" vector-effect="non-scaling-stroke"/>`;
    }
    svg.innerHTML = d;
  })();

  /* Randnotizen auf Höhe ihres Kapitels */
  function notizenSetzen() {
    const rand = document.querySelector('.rand');
    if (!rand || getComputedStyle(rand).display === 'none') return;
    const oben = rand.getBoundingClientRect().top;
    rand.querySelectorAll('[data-zu]').forEach(n => {
      const k = document.getElementById(n.dataset.zu);
      n.style.top = (k.getBoundingClientRect().top - oben + 8) + 'px';
    });
  }

  /* Variante */
  let variante = 'balken';
  function setzen(v) {
    variante = v;
    body.dataset.variante = v;
    document.querySelector('.hoch').inert = v !== 'ring';   /* Zugang: unsichtbarer Knopf nicht fokussierbar */
    document.querySelectorAll('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    werteEl.innerHTML = WERTE[v];
    messen();
  }
  document.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.v); }));

  /* Fortschritt messen: aktives Kapitel immer (aria-current), Balken/Ring/Segmente nur ohne CSS-Unterstützung */
  const segEls = { 1: document.querySelectorAll('[data-k="1"]'), 2: document.querySelectorAll('[data-k="2"]'), 3: document.querySelectorAll('[data-k="3"]'), 4: document.querySelectorAll('[data-k="4"]') };
  let wartet = false;
  function messen() {
    wartet = false;
    const max = Math.max(1, html.scrollHeight - innerHeight);
    const y = scrollY, p = Math.min(1, Math.max(0, y / max));
    const kopf = parseFloat(getComputedStyle(html).getPropertyValue('--kopf')) || 64;
    let aktiv = -1;
    kapitel.forEach((k, i) => {
      const r = k.getBoundingClientRect(), top = r.top + y, h = r.height;
      if (r.top - kopf <= innerHeight * 0.35) aktiv = i;
      if (!kannCSS) {
        const a = top - kopf, b = top + h - innerHeight;
        const start = Math.min(a, b), ende = Math.max(a, b);
        const kp = Math.min(1, Math.max(0, (y - start) / Math.max(1, ende - start)));
        segEls[i + 1].forEach(el => el.style.setProperty('--k', kp.toFixed(4)));
      }
    });
    if (!kannCSS) html.style.setProperty('--p', p.toFixed(4));
    links.forEach((l, i) => i === aktiv ? l.setAttribute('aria-current', 'true') : l.removeAttribute('aria-current'));
    if (aktiv >= 0) { kz.nr.textContent = String(aktiv + 1).padStart(2, '0') + '/04'; kz.titel.textContent = links[aktiv].querySelector('b').textContent; }
    else { kz.nr.textContent = '00/04'; kz.titel.textContent = 'Auftakt'; }
    const min = Math.ceil(LESEZEIT * (1 - p));
    rest.textContent = p > 0.985 ? 'Gelesen' : `Noch ${min} Min.`;
  }
  addEventListener('scroll', () => { if (!wartet) { wartet = true; requestAnimationFrame(messen); } }, { passive: true });
  addEventListener('resize', () => { notizenSetzen(); messen(); });

  /* Sprünge: weich nur ohne reduzierte Bewegung */
  const springen = top => scrollTo({ top, behavior: reduziert() ? 'auto' : 'smooth' });
  document.querySelector('.hoch button').addEventListener('click', () => { pause(); springen(0); });
  links.forEach(l => l.addEventListener('click', e => {
    e.preventDefault(); pause();
    const k = document.querySelector(l.getAttribute('href'));
    springen(k.getBoundingClientRect().top + scrollY - (parseFloat(getComputedStyle(html).getPropertyValue('--kopf')) || 64) - 24);
  }));

  /* Vorführung: Seite scrollt von selbst; in der Galerie-Vorschau wechselt jede Runde die Variante */
  const RUNDE = 8200;
  let uhr = null, stufe = null, t0 = 0, versatz = 0, runde = 0;
  const maxY = () => Math.max(0, html.scrollHeight - innerHeight);
  function lauf(t) {
    const verstrichen = t - t0 + versatz;
    const r = Math.floor(verstrichen / RUNDE);
    if (r !== runde) {
      runde = r;
      if (html.classList.contains('vorschau')) setzen(VARIANTEN[(VARIANTEN.indexOf(variante) + 1) % 3]);
    }
    const p = (verstrichen % RUNDE) / RUNDE;
    const q = Math.min(1, Math.max(0, (p - 0.05) / 0.85));
    scrollTo({ top: maxY() * (0.5 - 0.5 * Math.cos(q * Math.PI)), behavior: 'instant' });
    uhr = requestAnimationFrame(lauf);
  }
  function stufenLauf() {
    /* reduziert: in Vierteln springen statt gleiten */
    let i = 0;
    const schritt = () => {
      scrollTo({ top: maxY() * [0, .25, .5, .75, 1][i % 5], behavior: 'instant' });
      i++;
      if (i % 5 === 0 && html.classList.contains('vorschau')) setzen(VARIANTEN[(VARIANTEN.indexOf(variante) + 1) % 3]);
      stufe = setTimeout(schritt, 1400);
    };
    schritt();
  }
  function starten() {
    if (reduziert()) { stufenLauf(); return; }
    t0 = performance.now(); runde = Math.floor(versatz / RUNDE);
    uhr = requestAnimationFrame(lauf);
  }
  function pause() {
    if (uhr) versatz += performance.now() - t0;
    cancelAnimationFrame(uhr); clearTimeout(stufe); uhr = null; stufe = null;
  }
  function vorfuehren() { pause(); versatz = 0;
    /* Galerie-Vorschau: mit Kapitel-Variante beginnen und mitten im Text einsteigen */
    if (html.classList.contains('vorschau')) { setzen('kapitel'); versatz = 1400; }
    scrollTo({ top: 0, behavior: 'instant' }); starten(); }
  function weiter() { if (!uhr && !stufe) starten(); }
  function neustart() { pause(); versatz = 0; setzen(variante); scrollTo({ top: 0, behavior: 'instant' }); }
  /* Eigenes Scrollen beendet die Vorführung */
  ['wheel', 'touchstart', 'keydown'].forEach(ev => addEventListener(ev, () => { if (uhr || stufe) pause(); }, { passive: true }));

  setzen('balken');
  addEventListener('load', () => { notizenSetzen(); messen(); });
  document.fonts && document.fonts.ready.then(() => { notizenSetzen(); messen(); });

})();
</script>
</body>
</html>