Fortschrittsring
Natives <progress> als Ring: conic-gradient, per @property in 400 ms weich nachgeführt, bei 100 % in Messing.
Ein natives <progress> wird per conic-gradient zum Ring. Der Wert kommt über attr(value type(<number>)) in die Eigenschaft --value. Weil --value per @property als Zahl registriert ist, lässt sie sich animieren: Ändert sich value, läuft der Ring in 400 ms weich nach. Bei 100 % wechselt die Farbe und zeigt den Abschluss.
Ein Ring zeigt Fortschritt auf kleinstem Raum und liest sich auf einen Blick. Die kurze, weiche Nachführung macht jede Änderung sichtbar, ohne zu bremsen. Das native Element bringt die Semantik mit, eine SVG-Rechnung entfällt.
Wann einsetzen
Uploads, mehrstufige Formulare, Auftrags- und Bestellstatus, Kurs- oder Lesefortschritt, Skill-Anzeigen, Hold-to-confirm.
Wann nicht
Bei unbestimmter Wartezeit, dafür gibt es Spinner. Und nie, wenn der Wert nicht ehrlich ist.
Varianten
- Nativer Ring: <progress> mit conic-gradient und @property (Chrome-Leitfaden, Beispiel 150 px, Band 16 px).
- SVG-Ring: Kreis mit pathLength="1" und stroke-dashoffset, klassisch und überall lauffähig (Unseen: r 39, Offset per GSAP).
- Kompakter Ring mit 20 px neben Text oder in Listen (Stripe: r 8, Strich 2).
- Ring im Button beim Absenden (siehe button-zustaende).
- Lesefortschritt am Scroll (siehe scroll-fortschritt).
Umsetzung
@property --value { syntax: '<number>'; inherits: true; initial-value: 0; } progress.ring { --value: attr(value type(<number>), 0); appearance: none; border: 0; inline-size: 150px; aspect-ratio: 1; border-radius: 50%; background: conic-gradient(var(--fill) calc(var(--value) * 1%), var(--track) 0); mask: radial-gradient(farthest-side, #0000 calc(100% - 16.5px), #000 calc(100% - 16px)); } progress.ring::-webkit-progress-bar, progress.ring::-webkit-progress-value { background: transparent; } progress.ring::-moz-progress-bar { background: transparent; } @media (prefers-reduced-motion: no-preference) { progress.ring { transition: --value .4s cubic-bezier(0.4, 0, 0.2, 1); } }. Das Loch in der Mitte schneidet hier eine radiale Maske aus. Der Chrome-Leitfaden nutzt dafür border plus background-clip: border-area. attr() mit Typ nur hinter @supports (x: attr(x type(*))) einsetzen. Ohne diese Unterstützung kopiert JS den Wert nach --value (style.setProperty oder MutationObserver). SVG-Variante: <circle r="39" pathLength="1"> mit stroke-dasharray: 1 2 und stroke-dashoffset: calc(1 - var(--value) / 100), gedreht um -90deg. Die Lücke von 2 verhindert Reste der Linie bei 0 %.
Robust umsetzen
Telefon. Identisch. Ein Ring mit 150 px passt auch auf 390 px Breite.
Ohne Bewegung. Keine Transition: Der Ring springt direkt auf den neuen Wert (Dauer 0).
Barrierefrei. Natives <progress> mit aria-label, ohne Fallback-Inhalt im Element. Den aktuellen Schritt zusätzlich als Text in einer aria-live-Region ausgeben (Stripe nutzt aria-live="polite"). Eine Prozentzahl in der Ringmitte bekommt aria-hidden, sonst wird sie doppelt vorgelesen. Beim SVG-Ring role="progressbar" mit aria-valuenow setzen.
Code
<!DOCTYPE html>
<!--
Fortschrittsring
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/fortschrittsring/
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>Fortschrittsring · 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: '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; }
@font-face { font-family: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 400 900; font-style: italic; font-display: swap; }
/* Welt: Rezept „luxus-manufaktur-stille“ – Anthrazit und Weiß im Wechsel, Messing nur als Linie */
:root {
--anthrazit: #23282f; --weiss: #ffffff; --flaeche: #f4f4f2; --meta: #6a6f76; --linie: #d9dadb; --messing: #a88a5a;
--hell: #f3f2ee; --hell-leise: rgba(243, 242, 238, .58); --hell-linie: rgba(243, 242, 238, .12);
/* Messwert des Musters: Material Standard, 400 ms */
--kurve: cubic-bezier(0.4, 0, 0.2, 1);
--dauer: 400ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--weiss); color: var(--anthrazit); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; }
button { font: inherit; color: inherit; }
.versal { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; }
.mono { font: 500 10.5px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.serif { font-family: 'Bodoni Moda', 'Didot', serif; font-optical-sizing: auto; font-weight: 400; }
/* Kopf */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; padding: 18px clamp(16px, 3.4vw, 44px); border-bottom: 1px solid var(--linie); }
.marke { display: grid; gap: 3px; text-decoration: none; color: inherit; }
.marke b { font: 400 19px/1 'Bodoni Moda', serif; letter-spacing: .26em; }
.marke span { font-size: 9px; letter-spacing: .24em; text-transform: uppercase; color: var(--meta); }
.navi { display: flex; gap: clamp(16px, 2.4vw, 34px); list-style: none; padding: 0; }
.navi a { color: inherit; text-decoration: none; }
.navi a[aria-current] { box-shadow: inset 0 -1px var(--messing); padding-bottom: 4px; }
.termin { justify-self: end; color: inherit; text-decoration: none; border-bottom: 1px solid var(--messing); padding-bottom: 3px; }
/* Bühne: Freisteller links, Servicestatus rechts */
.buehne { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); min-height: 0; }
.produkt { position: relative; background: var(--flaeche); overflow: hidden; min-height: 520px; }
.produkt svg.uhr { position: absolute; left: 50%; top: 50%; height: 124%; width: auto; translate: -50% -52%; }
.produkt figcaption { position: absolute; left: clamp(16px, 3vw, 36px); right: clamp(16px, 3vw, 36px); bottom: clamp(16px, 2.6vw, 30px); display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.produkt figcaption b { color: var(--anthrazit); font-weight: 500; }
.produkt figcaption span, .produkt .ref { background: var(--flaeche); padding: 3px 0; box-shadow: 8px 0 0 var(--flaeche), -8px 0 0 var(--flaeche); }
.produkt .ref { position: absolute; top: clamp(16px, 2.6vw, 30px); left: clamp(16px, 3vw, 36px); }
.sekunde { transform-box: view-box; transform-origin: 0 62px; animation: ticken 60s steps(60) infinite; }
@keyframes ticken { to { rotate: 360deg; } }
.status { background: var(--anthrazit); color: var(--hell); padding: clamp(24px, 3.6vw, 52px) clamp(20px, 3.6vw, 52px); display: grid; align-content: space-between; gap: clamp(20px, 2.8vh, 32px); }
.status .oben { display: grid; gap: clamp(14px, 2vh, 22px); }
.status .mono { color: var(--hell-leise); }
.zeile { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.titel { font-size: clamp(34px, 3.7vw, 54px); line-height: 1.05; letter-spacing: -.01em; text-wrap: balance; }
.titel em { font-style: italic; }
.titel em, .stufe-name, .stufe-text, .abholung { transition: opacity 200ms var(--kurve); }
.wechselt { opacity: 0 !important; }
.anzeige { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: clamp(22px, 3vw, 40px); }
.ring-feld { position: relative; inline-size: 186px; aspect-ratio: 1; display: grid; place-items: center; }
.skala { position: absolute; inset: 0; width: 100%; height: 100%; }
.skala line { stroke: var(--hell-linie); stroke-width: 1; }
.skala line.lang { stroke: rgba(243, 242, 238, .3); }
.ring-feld .mitte { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 6px; pointer-events: none; }
.zahl { font: 400 46px/.9 'Bodoni Moda', serif; font-optical-sizing: auto; font-variant-numeric: tabular-nums lining-nums; letter-spacing: -.02em; }
.zahl small { font-size: .42em; vertical-align: .95em; margin-left: 2px; letter-spacing: 0; }
.mitte .versal { color: var(--hell-leise); font-size: 9.5px; letter-spacing: .14em; }
/* ── Variante A: natives <progress> als Ring (conic-gradient + @property) ── */
@property --value { syntax: '<number>'; inherits: true; initial-value: 0; }
@property --fuellung { syntax: '<color>'; inherits: true; initial-value: #f3f2ee; }
progress.ring {
--value: 0; /* Fallback: JS kopiert value hierher, wenn attr() keinen Typ kann */
--staerke: 16px;
--spur: rgba(243, 242, 238, .1);
appearance: none; -webkit-appearance: none; border: 0; padding: 0;
inline-size: 150px; block-size: auto; aspect-ratio: 1; border-radius: 50%;
background: conic-gradient(var(--fuellung) calc(var(--value) * 1%), var(--spur) 0);
-webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - var(--staerke) - .5px), #000 calc(100% - var(--staerke)));
mask: radial-gradient(farthest-side, #0000 calc(100% - var(--staerke) - .5px), #000 calc(100% - var(--staerke)));
transition: --value 0s, --fuellung 0s;
}
@supports (x: attr(x type(*))) { progress.ring { --value: attr(value type(<number>), 0); } }
progress.ring::-webkit-progress-bar, progress.ring::-webkit-progress-value { background: transparent; }
progress.ring::-moz-progress-bar { background: transparent; }
progress.ring[value='100'] { --fuellung: var(--messing); }
:root:not(.reduziert) progress.ring { transition: --value var(--dauer) var(--kurve), --fuellung var(--dauer) var(--kurve); }
/* ── Variante B: SVG mit pathLength="1" und stroke-dashoffset (Haarlinie) ── */
svg.ring-svg { inline-size: 150px; aspect-ratio: 1; rotate: -90deg; overflow: visible; --value: 0; --fuellung: var(--hell); transition: --value 0s, --fuellung 0s; }
svg.ring-svg circle { fill: none; stroke-width: 1; }
svg.ring-svg .spur { stroke: rgba(243, 242, 238, .16); }
svg.ring-svg .bogen { stroke: var(--fuellung); stroke-width: 2; stroke-dasharray: 1 2; stroke-dashoffset: calc(1 - var(--value) / 100); }
svg.ring-svg.voll { --fuellung: var(--messing); }
:root:not(.reduziert) svg.ring-svg { transition: --value var(--dauer) var(--kurve), --fuellung var(--dauer) var(--kurve); }
[hidden] { display: none !important; }
.stufe { display: grid; gap: 8px; align-content: center; }
.stufe .mono { color: var(--messing); }
.fertig .stufe .mono { color: var(--messing); }
.stufe-name { font-size: clamp(26px, 2.4vw, 34px); line-height: 1.05; }
.stufe-text { font-size: 15px; line-height: 1.47; color: var(--hell-leise); max-width: 34ch; text-wrap: pretty; }
/* Liste mit kompakten 20-px-Ringen (Stripe: r 8, Strich 2, pathLength 1) */
.liste { list-style: none; padding: 0; display: grid; grid-template-columns: 1fr 1fr; column-gap: 28px; border-top: 1px solid var(--hell-linie); }
.liste li { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--hell-linie); font-size: 14px; color: var(--hell-leise); transition: color var(--dauer) var(--kurve); }
.liste li.erledigt, .liste li.aktiv { color: var(--hell); }
.liste time { font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--hell-leise); }
.liste li.aktiv time { color: var(--messing); }
.mini { width: 20px; height: 20px; rotate: -90deg; }
.mini circle { fill: none; stroke-width: 2; }
.mini .spur { stroke: rgba(243, 242, 238, .16); transition: stroke var(--dauer) var(--kurve); }
.mini .bogen { stroke: var(--hell); stroke-dasharray: 1 2; stroke-dashoffset: 1; transition: stroke-dashoffset var(--dauer) var(--kurve), stroke var(--dauer) var(--kurve); }
.liste li.aktiv .mini .spur { stroke: var(--messing); }
.liste li.erledigt .mini .bogen { stroke-dashoffset: 0; }
.fertig .liste .mini .bogen { stroke: var(--messing); }
.abholung { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; font-size: 14px; color: var(--hell-leise); }
.abholung a { color: var(--hell); text-decoration: none; border: 1px solid var(--messing); padding: 12px 18px; letter-spacing: .08em; text-transform: uppercase; font-size: 11px; font-weight: 500; transition: background-color 200ms var(--kurve), color 200ms var(--kurve); }
.abholung a:hover { background: var(--messing); color: var(--anthrazit); }
.abholung a:focus-visible, .steuer button:focus-visible, .navi a:focus-visible, .termin:focus-visible { outline: 1px solid var(--messing); outline-offset: 3px; }
/* Bedienleiste der Demo (dezent) */
.steuer { display: flex; align-items: center; justify-content: space-between; gap: 10px 20px; flex-wrap: wrap; padding: 12px clamp(16px, 3.4vw, 44px); border-top: 1px solid var(--linie); }
.steuer .gruppe { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.steuer .gruppe > span { margin-right: 8px; }
.steuer button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: color 150ms var(--kurve), border-color 150ms var(--kurve); }
.steuer button:hover { color: var(--anthrazit); border-color: var(--meta); }
.steuer button[aria-pressed='true'] { background: var(--anthrazit); color: var(--weiss); border-color: var(--anthrazit); }
.werte { color: var(--meta); }
.werte b { color: var(--anthrazit); font-weight: 500; }
.werte span { white-space: nowrap; }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Reduzierte Bewegung: Ring springt (Dauer 0), Sekundenzeiger steht */
:root.reduziert * { transition-duration: .01ms !important; animation: none !important; }
.ohne-uebergang, .ohne-uebergang * { transition: none !important; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; grid-template-rows: auto minmax(0, 1fr) auto; }
.vorschau .buehne { min-height: 0; overflow: hidden; }
.vorschau .produkt { min-height: 0; }
.vorschau .werte, .vorschau .steuer .aktionen { display: none; }
.vorschau .status { padding-block: 26px; gap: 18px; }
.vorschau .liste { grid-template-columns: 1fr 1fr !important; column-gap: 22px; }
.vorschau .liste li { padding: 9px 0; font-size: 13px; }
.vorschau .titel { font-size: 38px; }
@media (max-width: 1080px) { .navi { display: none; } .kopf { grid-template-columns: 1fr auto; } }
@media (max-width: 1000px) and (min-width: 821px) { .liste { grid-template-columns: 1fr; } }
@media (max-width: 820px) {
.buehne { grid-template-columns: 1fr; }
.produkt { min-height: 0; aspect-ratio: 5 / 4; }
.produkt svg.uhr { height: 130%; }
}
@media (max-width: 520px) {
.produkt { aspect-ratio: 1; }
.anzeige { grid-template-columns: 1fr; justify-items: center; text-align: center; }
.stufe { justify-items: center; }
.liste { grid-template-columns: 1fr; }
.marke span { display: none; }
.produkt figcaption .rechts { display: none; }
}
@media (hover: none) {
.steuer button { height: 38px; padding: 0 14px; }
.abholung a:hover { background: none; color: var(--hell); }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#" aria-label="Guntschna Uhrmacherei Bozen"><b>GUNTSCHNA</b><span>Uhrmacherei · Bozen · seit 1952</span></a>
<ul class="navi versal">
<li><a href="#">Zeitmesser</a></li>
<li><a href="#">Manufaktur</a></li>
<li><a href="#" aria-current="page">Service</a></li>
<li><a href="#">Boutique</a></li>
</ul>
<a class="termin versal" href="#">Termin vereinbaren</a>
</header>
<main class="buehne">
<figure class="produkt" aria-label="G-52 Automatik, Edelstahl, 38,5 mm">
<svg class="uhr" viewBox="-280 -300 560 600" aria-hidden="true">
<defs>
<linearGradient id="stahl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f6f7f8"/><stop offset=".38" stop-color="#c4c8cc"/><stop offset=".55" stop-color="#eceeef"/><stop offset="1" stop-color="#9da2a8"/>
</linearGradient>
<linearGradient id="stahl2" x1="1" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fafafa"/><stop offset="1" stop-color="#a9aeb3"/>
</linearGradient>
<radialGradient id="blatt" cx=".42" cy=".36" r=".75">
<stop offset="0" stop-color="#363d46"/><stop offset="1" stop-color="#1b1f25"/>
</radialGradient>
<linearGradient id="leder" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2a2e34"/><stop offset=".5" stop-color="#363a41"/><stop offset="1" stop-color="#25292e"/>
</linearGradient>
<filter id="schatten" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="18"/></filter>
</defs>
<!-- Skala im Hintergrund, nur Haarlinie -->
<g class="hintergrund-skala" stroke="#d9dadb" fill="none">
<circle r="232" stroke-width="1"/>
<circle r="262" stroke-width="1"/>
<g id="hg-striche"></g>
</g>
<ellipse cx="14" cy="28" rx="150" ry="156" fill="rgba(35,40,47,.22)" filter="url(#schatten)"/>
<!-- Band -->
<path d="M-66 -128 L-62 -300 L62 -300 L66 -128 Z" fill="url(#leder)"/>
<path d="M-66 128 L-62 300 L62 300 L66 128 Z" fill="url(#leder)"/>
<path d="M-57 -140 L-54 -300 M57 -140 L54 -300 M-57 140 L-54 300 M57 140 L54 300" stroke="rgba(243,242,238,.22)" stroke-width="1.2" stroke-dasharray="4 4" fill="none"/>
<!-- Hörner -->
<path d="M-84 -112 L-80 -150 Q-79 -158 -71 -158 L-62 -158 L-60 -118 Z M84 -112 L80 -150 Q79 -158 71 -158 L62 -158 L60 -118 Z M-84 112 L-80 150 Q-79 158 -71 158 L-62 158 L-60 118 Z M84 112 L80 150 Q79 158 71 158 L62 158 L60 118 Z" fill="url(#stahl2)"/>
<!-- Krone -->
<rect x="146" y="-15" width="20" height="30" rx="4" fill="url(#stahl2)"/>
<path d="M152 -13 V13 M156 -13 V13 M160 -13 V13" stroke="rgba(35,40,47,.25)" stroke-width="1"/>
<!-- Gehäuse -->
<circle r="150" fill="url(#stahl)"/>
<circle r="150" fill="none" stroke="rgba(35,40,47,.18)" stroke-width="1"/>
<circle r="141" fill="url(#stahl2)"/>
<circle r="136" fill="#15181c"/>
<circle r="134" fill="url(#blatt)"/>
<g id="minuten" stroke="rgba(243,242,238,.55)" stroke-linecap="round"></g>
<g id="indizes" fill="#f3f2ee"></g>
<text y="-56" text-anchor="middle" fill="#f3f2ee" font-family="'Bodoni Moda', serif" font-size="13" letter-spacing="4.2">GUNTSCHNA</text>
<text y="-42" text-anchor="middle" fill="rgba(243,242,238,.6)" font-family="'Instrument Sans', sans-serif" font-size="6.2" letter-spacing="2.6">BOZEN</text>
<text y="22" text-anchor="middle" fill="rgba(243,242,238,.6)" font-family="'Instrument Sans', sans-serif" font-size="6" letter-spacing="2.4">AUTOMATIK</text>
<!-- kleine Sekunde -->
<circle cy="62" r="25" fill="#1a1e23" stroke="rgba(243,242,238,.14)" stroke-width="1"/>
<g id="sek-striche" stroke="rgba(243,242,238,.45)"></g>
<g class="sekunde"><line x1="0" y1="68" x2="0" y2="42" stroke="#a88a5a" stroke-width="1.2"/><circle cy="62" r="2.2" fill="#a88a5a"/></g>
<!-- Zeiger 10:09 -->
<g transform="rotate(304.5)"><path d="M0 14 L-6.5 0 L0 -82 Z" fill="#e9e8e4"/><path d="M0 14 L6.5 0 L0 -82 Z" fill="#b9bbbd"/></g>
<g transform="rotate(54)"><path d="M0 16 L-5 0 L0 -120 Z" fill="#f3f2ee"/><path d="M0 16 L5 0 L0 -120 Z" fill="#c2c4c6"/></g>
<circle r="4.5" fill="#d8d9da"/><circle r="1.6" fill="#23282f"/>
</svg>
<p class="ref mono">Ref. 52.10 · Kaliber G-07</p>
<figcaption class="mono"><span><b>G-52 Automatik</b> · Edelstahl · 38,5 mm</span><span class="rechts">Eingang 14.10.2026</span></figcaption>
</figure>
<section class="status" aria-labelledby="titel">
<div class="oben">
<div class="zeile mono"><span>Servicestatus</span><span>Auftrag 26-0417</span></div>
<h1 class="titel serif" id="titel">Ihre G-52 ist <em id="titel-wort">in Revision.</em></h1>
</div>
<div class="anzeige">
<div class="ring-feld" data-variante="nativ">
<svg class="skala" viewBox="-93 -93 186 186" aria-hidden="true"><g id="skala"></g></svg>
<!-- A: natives progress-Element -->
<progress class="ring" id="ring-nativ" value="0" max="100" aria-label="Fortschritt der Revision"></progress>
<!-- B: SVG-Ring -->
<svg class="ring-svg" id="ring-svg" viewBox="0 0 80 80" role="progressbar" aria-label="Fortschritt der Revision" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" hidden>
<circle class="spur" cx="40" cy="40" r="39"/>
<circle class="bogen" cx="40" cy="40" r="39" pathLength="1"/>
</svg>
<div class="mitte" aria-hidden="true">
<span class="zahl"><span id="zahl">0</span><small>%</small></span>
<span class="versal" id="schritt-text">0 von 6</span>
</div>
</div>
<div class="stufe" aria-live="polite">
<p class="mono" id="stufe-status">In Arbeit</p>
<p class="stufe-name serif" id="stufe-name">Befund</p>
<p class="stufe-text" id="stufe-text">Eingangsprüfung, Gangwerte gemessen, Zustand fotografiert.</p>
</div>
</div>
<ol class="liste" id="liste" aria-label="Arbeitsschritte"></ol>
<p class="abholung" id="abholung">Fragen zur Revision? Ihre Uhrmacherin ist Marlene Gasser.</p>
</section>
</main>
<div class="steuer mono" role="group" aria-label="Demo-Steuerung">
<div class="gruppe">
<span>Variante</span>
<button type="button" data-v="nativ" aria-pressed="true">Nativ · conic</button>
<button type="button" data-v="svg" aria-pressed="false">SVG · Haarlinie</button>
</div>
<div class="gruppe aktionen">
<button type="button" id="knopf-weiter">Schritt abschließen</button>
<button type="button" id="knopf-null">Zurücksetzen</button>
</div>
<p class="werte" id="werte"></p>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s) => document.querySelector(s);
const NS = 'http://www.w3.org/2000/svg';
const STUFEN = [
{ name: 'Befund', text: 'Eingangsprüfung, Gangwerte gemessen, Zustand fotografiert.', datum: '14.10.' },
{ name: 'Zerlegung', text: 'Werk ausgeschalt und in 168 Einzelteile zerlegt.', datum: '16.10.' },
{ name: 'Reinigung', text: 'Ultraschallbad, jedes Teil unter der Lupe geprüft.', datum: '22.10.' },
{ name: 'Montage', text: 'Zusammenbau von Hand, Ölung an 34 Stellen.', datum: '04.11.' },
{ name: 'Regulierung', text: 'Feinregulierung in fünf Lagen auf ±4 s am Tag.', datum: '12.11.' },
{ name: 'Prüfung', text: 'Dichtigkeit bei 5 bar, danach 72 Stunden Gangtest.', datum: '20.11.' }
];
const START = 3; // drei Schritte erledigt: 50 %
/* Zierstriche erzeugen: Hintergrundskala, Minuterie, Indizes, kleine Sekunde, Skala um den Ring */
const strich = (g, r1, r2, winkel, attr = {}) => {
const a = (winkel - 90) * Math.PI / 180, l = document.createElementNS(NS, 'line');
l.setAttribute('x1', (Math.cos(a) * r1).toFixed(2)); l.setAttribute('y1', (Math.sin(a) * r1).toFixed(2));
l.setAttribute('x2', (Math.cos(a) * r2).toFixed(2)); l.setAttribute('y2', (Math.sin(a) * r2).toFixed(2));
for (const k in attr) l.setAttribute(k, attr[k]);
g.appendChild(l);
};
const hg = $('#hg-striche');
for (let i = 0; i < 120; i++) strich(hg, 232, i % 10 === 0 ? 248 : 239, i * 3, { 'stroke-width': 1 });
const min = $('#minuten');
for (let i = 0; i < 60; i++) if (i % 5) strich(min, 124, 128, i * 6, { 'stroke-width': 1 });
const ind = $('#indizes');
for (let i = 0; i < 12; i++) {
const dw = i === 0 ? [-6, 2] : [-1.6];
dw.forEach((dx) => {
const r = document.createElementNS(NS, 'rect');
r.setAttribute('x', dx); r.setAttribute('y', -122); r.setAttribute('width', i === 0 ? 4 : 3.2); r.setAttribute('height', i % 3 === 0 ? 26 : 20);
r.setAttribute('transform', `rotate(${i * 30})`); ind.appendChild(r);
});
}
const sek = $('#sek-striche');
for (let i = 0; i < 60; i++) {
const a = (i * 6 - 90) * Math.PI / 180, r1 = 21, r2 = i % 5 ? 23 : 18.5, l = document.createElementNS(NS, 'line');
l.setAttribute('x1', Math.cos(a) * r1); l.setAttribute('y1', 62 + Math.sin(a) * r1);
l.setAttribute('x2', Math.cos(a) * r2); l.setAttribute('y2', 62 + Math.sin(a) * r2);
l.setAttribute('stroke-width', i % 5 ? .5 : .9); sek.appendChild(l);
}
const skala = $('#skala');
for (let i = 0; i < 60; i++) strich(skala, 84, i % 5 ? 88 : 92, i * 6, i % 5 ? {} : { class: 'lang' });
/* Liste */
const liste = $('#liste');
STUFEN.forEach((s, i) => {
const li = document.createElement('li');
li.innerHTML = `<svg class="mini" viewBox="0 0 20 20" aria-hidden="true"><circle class="spur" cx="10" cy="10" r="8"/><circle class="bogen" cx="10" cy="10" r="8" pathLength="1"/></svg><span>${s.name}</span><time>${s.datum}</time>`;
liste.appendChild(li);
});
const zeilen = [...liste.children];
const nativ = $('#ring-nativ'), svgRing = $('#ring-svg'), feld = $('.ring-feld');
const attrMitTyp = CSS.supports('x: attr(x type(*))');
let variante = 'nativ', schritt = 0, zahlRaf = 0;
const WERTE = {
nativ: '<b><progress></b> · conic-gradient · @property --value · <b>400 ms</b> cubic-bezier(0.4, 0, 0.2, 1) · Ø 150 px · Band 16 px · Liste: SVG 20 px, r 8, Strich 2',
svg: '<b>SVG</b> r 39 · pathLength 1 · stroke-dashoffset · <b>400 ms</b> cubic-bezier(0.4, 0, 0.2, 1) · Ø 150 px · Strich 2 auf Spur 1 · Liste: SVG 20 px, r 8, Strich 2'
};
function aktiverRing() { return variante === 'nativ' ? nativ : svgRing; }
/* Die Zahl in der Mitte liest den gerade animierten Wert von --value (registriert per @property) */
function zahlNachfuehren() {
cancelAnimationFrame(zahlRaf);
const ziel = Math.round(schritt / 6 * 100), bis = performance.now() + 700;
const tick = () => {
const v = parseFloat(getComputedStyle(aktiverRing()).getPropertyValue('--value')) || 0;
$('#zahl').textContent = Math.round(v);
if (performance.now() < bis && Math.round(v) !== ziel) zahlRaf = requestAnimationFrame(tick);
else $('#zahl').textContent = ziel;
};
if (reduziert()) $('#zahl').textContent = ziel; else tick();
}
function tauschen(el, text) {
if (el.textContent === text) return;
if (reduziert()) { el.textContent = text; return; }
el.classList.add('wechselt');
setTimeout(() => { el.textContent = text; el.classList.remove('wechselt'); }, 200);
}
function setze(n, sofort = false) {
schritt = Math.max(0, Math.min(6, n));
const wert = Math.round(schritt / 6 * 100);
const buehne = $('.buehne');
if (sofort) buehne.classList.add('ohne-uebergang');
nativ.value = wert;
if (!attrMitTyp) nativ.style.setProperty('--value', wert); // Fallback ohne attr()-Typ
svgRing.style.setProperty('--value', wert);
svgRing.setAttribute('aria-valuenow', wert);
svgRing.classList.toggle('voll', wert === 100);
zeilen.forEach((li, i) => { li.classList.toggle('erledigt', i < schritt); li.classList.toggle('aktiv', i === schritt); });
const fertig = schritt === 6;
$('.status').classList.toggle('fertig', fertig);
$('#schritt-text').textContent = `${schritt} von 6`;
const s = STUFEN[Math.min(schritt, 5)];
tauschen($('#stufe-status'), fertig ? 'Abgeschlossen' : `In Arbeit · bis ${s.datum}`);
tauschen($('#stufe-name'), fertig ? 'Abholbereit' : s.name);
tauschen($('#stufe-text'), fertig ? 'Alle sechs Schritte erledigt. Zwei Jahre Garantie auf die Revision.' : s.text);
tauschen($('#titel-wort'), fertig ? 'abholbereit.' : 'in Revision.');
const ab = $('#abholung');
const abFertig = ab.dataset.fertig === '1';
if (fertig !== abFertig) {
ab.dataset.fertig = fertig ? '1' : '0';
const neu = fertig ? 'Boutique Laubengasse 18, Di bis Sa 10–18 Uhr. <a href="#">Abholung vereinbaren</a>' : 'Fragen zur Revision? Ihre Uhrmacherin ist Marlene Gasser.';
if (reduziert() || sofort) ab.innerHTML = neu;
else { ab.classList.add('wechselt'); setTimeout(() => { ab.innerHTML = neu; ab.classList.remove('wechselt'); }, 200); }
}
if (sofort) {
void buehne.offsetWidth;
buehne.classList.remove('ohne-uebergang');
$('#zahl').textContent = wert;
} else zahlNachfuehren();
}
function waehle(v) {
variante = v;
nativ.toggleAttribute('hidden', v !== 'nativ');
svgRing.toggleAttribute('hidden', v !== 'svg'); // SVG-Elemente kennen die Eigenschaft .hidden nicht
feld.dataset.variante = v;
document.querySelectorAll('[data-v]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
$('#werte').innerHTML = WERTE[v].split(' · ').map((t) => `<span>${t}</span>`).join(' · ');
}
document.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', () => { pause(); waehle(b.dataset.v); }));
$('#knopf-weiter').addEventListener('click', () => { pause(); setze(schritt >= 6 ? 0 : schritt + 1); });
$('#knopf-null').addEventListener('click', () => { pause(); setze(0); });
let schleife = null;
const zeit = (fn, ms) => { clearTimeout(schleife); schleife = setTimeout(fn, ms); };
function neustart() {
clearTimeout(schleife);
setze(0, true);
zeit(() => setze(START), reduziert() ? 0 : 350);
}
/* Vorführung: Schritt für Schritt bis 100 %, kurz halten, Variante wechseln, von vorn */
function lauf() {
if (schritt < 6) { setze(schritt + 1); zeit(lauf, schritt === 6 ? 2400 : 950); }
else { waehle(variante === 'nativ' ? 'svg' : 'nativ'); setze(0); zeit(lauf, 1100); }
}
function vorfuehren() {
clearTimeout(schleife);
setze(0, true);
zeit(lauf, 600);
}
function pause() { clearTimeout(schleife); }
function weiter() { zeit(lauf, 300); }
waehle('nativ');
setze(0, true);
addEventListener('load', () => { if (!html.classList.contains('vorschau')) neustart(); });
})();
</script>
</body>
</html>