Zum Inhalt
← Mikroanimationen

Validierung erst nach Interaktion

Felder bleiben beim Tippen still, zeigen erst nach Verlassen oder Absenden einen Fehler und werden sofort grün, sobald die Eingabe stimmt.

Felder zeigen keinen Fehler, solange jemand tippt. :user-invalid greift erst, wenn das Feld nach einer Änderung verlassen oder das Formular abgeschickt wurde. Dann färbt sich der Rahmen rot und ein Fehlertext erscheint. Sobald die Eingabe stimmt, verschwindet der Fehler sofort und :user-valid zeigt Grün, optional mit Häkchen. Passwort-Regeln lassen sich über :has() hervorheben.

Niemand wird beim Tippen angeschrien. Die Entwarnung kommt trotzdem sofort beim Korrigieren. Das wirkt höflich und souverän.

Wann einsetzen

In jedem Formular: Kontakt, Newsletter, Checkout, Login, Registrierung.

Wann nicht

Nicht als einzige Prüfung, wenn der Server entscheidet (dann zusätzlich JS und Serverantwort). Farbe nie als einziges Signal.

Varianten

  • Reines CSS: :user-invalid plus Fehlertext als Geschwister
  • Häkchen zeichnet sich bei :user-valid ein, Warnzeichen bei :user-invalid
  • Passwort-Regelliste per .feld:has(input:user-invalid) einfärben
  • JS-Fallback mit eigenem Dirty-Status (WeakMap), wie in der Chrome-Anleitung
  • Kombiniert mit Floating Label

Umsetzung

input:user-invalid { border-color: #F55252; background: #fbe9e5 } .feld:has(input:user-invalid) .fehler { opacity: 1; translate: 0 } (vorher opacity 0, translate 0 -10px, 300 ms ease-out). input:user-valid { border-color: #2e7d4f }. .feld:has(input:user-invalid) .regeln li:not(.ok) { color: #b42a2a; font-weight: 600 }. Hinweistext über das Feld setzen, dann verdeckt ihn das Autofill-Popover nicht. aria-invalid per kleinem Script synchron halten (input, blur, submit). Shake nur beim Absende-Versuch: @keyframes { 25% { translate: -4px } 75% { translate: 4px } } 300 ms. Formular mit novalidate: Ein Absende-Versuch setzt :user-invalid trotzdem auf allen Feldern. Für Längenregeln pattern statt minlength nehmen, wenn Werte auch per Script gesetzt werden.

Robust umsetzen

Telefon. Identisch. Eingaben mindestens 16 px groß, sonst zoomt iOS.

Ohne Bewegung. Kein Shake, kein Wandern. Farbe, Zeichen und Text bleiben.

Barrierefrei. Hinweise über aria-describedby, Fehler über aria-errormessage verknüpfen. aria-invalid synchron halten. Statuszeile mit role="status" meldet, wie viele Felder offen sind. Farbe nie allein: immer Text und Zeichen dazu. Fehlertext dunkler als die Rahmenfarbe, damit er 4,5 : 1 erreicht.

Code

<!DOCTYPE html>
<!--
  Validierung erst nach Interaktion
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/validierung-nach-interaktion/
  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>Validierung erst nach Interaktion · 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: '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; }
@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; }

:root {
  /* Rezept Manufaktur-Stille: Anthrazit und Weiß im Wechsel, Messing nur als Linie und Fokus */
  --grund: #ffffff; --flaeche: #f5f5f3; --anthrazit: #23282f; --tinte: #23282f;
  --meta: #6a6f76; --leise: #5f656d; --linie: #d9dadb; --rahmen: #b9bdc1; --messing: #a88a5a;
  --nacht-text: #eef0f2; --nacht-leise: rgba(238, 240, 242, .56); --nacht-linie: rgba(238, 240, 242, .16);
  --fehler: #F55252;        /* Rahmen und Zeichen: gemessen bei Ramotion */
  --fehler-text: #b42a2a;   /* Fehlertext: dunkler für Kontrast 4,5 : 1 */
  --fehler-grund: #fbe9e5;  /* Hintergrundwechsel wie bei Vooban */
  --gut: #2e7d4f;
  --kurve: cubic-bezier(0, 0, 0.58, 1);   /* ease-out, gemessen: Ramotion-Tooltip 300 ms */
  --meldung: 300ms; --farbe: 150ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(16px, 3vw, 40px); display: grid; place-items: center; background: var(--grund); }
.label { font: 600 11.5px/1.3 'Instrument Sans', system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
.mono { font: 500 10px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.antiqua { font-family: 'Bodoni Moda', 'Didot', Georgia, serif; font-optical-sizing: auto; font-weight: 400; letter-spacing: -.01em; }
.antiqua em, em.antiqua { font-style: italic; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }

.buehne { width: min(1100px, 100%); display: grid; grid-template-columns: minmax(0, .86fr) minmax(0, 1fr); gap: clamp(20px, 3.4vw, 56px); align-items: stretch; }

/* Linke Tafel: Werkstatt, Anthrazit mit Lichtkegel */
.tafel {
  position: relative; overflow: hidden; color: var(--nacht-text); isolation: isolate;
  background: radial-gradient(ellipse 78% 46% at 54% 34%, #3b424c 0%, #2a3038 46%, var(--anthrazit) 78%);
  padding: clamp(22px, 2.6vw, 34px); display: flex; flex-direction: column; gap: 18px; min-height: 600px;
}
.tafel-kopf { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.wortmarke { display: grid; gap: 6px; }
.wortmarke b { font: 400 21px/1 'Bodoni Moda', Georgia, serif; font-optical-sizing: auto; letter-spacing: .32em; margin-right: -.32em; }
.wortmarke span, .tafel-kopf > .label { color: var(--nacht-leise); font-size: 10.5px; }
.tafel-kopf > .label { padding-top: 4px; }

.ringe { position: relative; flex: 1; min-height: 190px; display: grid; place-items: center; margin: 0 -10px; }
.ringe svg { width: min(100%, 450px); height: auto; display: block; overflow: visible; }
.ringe figcaption { position: absolute; left: 10px; right: 10px; bottom: 0; display: flex; justify-content: space-between; gap: 12px; color: var(--nacht-leise); font-size: 12.5px; border-top: 1px solid var(--nacht-linie); padding-top: 9px; }
.ringe figcaption b { font-weight: 500; color: var(--nacht-text); }
.anfrage { color: var(--nacht-text); opacity: 0; translate: 0 -10px; transition: opacity var(--meldung) var(--kurve), translate var(--meldung) var(--kurve); }
.anfrage::before { content: ''; display: inline-block; width: 14px; height: 1px; background: var(--messing); vertical-align: middle; margin-right: 8px; }
.erfolg-ansicht .anfrage { opacity: 1; translate: 0 0; }
.reduziert .anfrage { translate: none; transition: opacity 150ms linear; }

.tafel h1 { font-size: clamp(34px, 3.4vw, 48px); line-height: 1.04; font-weight: 400; }
.phasen { list-style: none; display: grid; border-top: 1px solid var(--nacht-linie); }
.phasen li { display: grid; grid-template-columns: 2.4em 1fr; gap: 8px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--nacht-linie); color: rgba(238, 240, 242, .4); transition: color var(--meldung) var(--kurve); }
.phasen li > .label { color: inherit; font-size: 10.5px; }
.phasen li b { font-weight: 500; font-size: 15.5px; letter-spacing: -.005em; }
.phasen li small { display: block; font-size: 13px; line-height: 1.35; margin-top: 2px; }
.phasen li.an { color: var(--nacht-text); }
.phasen li.an > .label { color: var(--messing); }

/* Formular */
.formular { display: flex; flex-direction: column; padding-top: 2px; }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--linie); margin-bottom: 24px; }
.kopf > .label { color: var(--meta); font-size: 10.5px; }
.umschalter { display: flex; border: 1px solid var(--linie); }
.umschalter button { font: 600 10.5px/1 'Instrument Sans', sans-serif; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); background: none; border: 0; padding: 9px 12px; cursor: pointer; transition: background-color var(--farbe) var(--kurve), color var(--farbe) var(--kurve); }
.umschalter button + button { border-left: 1px solid var(--linie); }
.umschalter button[aria-pressed="true"] { background: var(--anthrazit); color: var(--grund); }
.umschalter button:focus-visible { outline: 1.5px solid var(--messing); outline-offset: 2px; }
form h2 { font-size: clamp(30px, 3vw, 40px); line-height: 1.04; margin-bottom: 10px; }
.vorspann { font-size: 15px; line-height: 1.47; color: var(--leise); max-width: 44ch; margin-bottom: 24px; text-wrap: pretty; }

.wahl { border: 0; margin-bottom: 22px; }
.wahl legend { margin-bottom: 8px; }
.wahl legend .label { font-size: 11px; }
.wahl legend small { font-size: 12.5px; color: var(--meta); margin-left: 6px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips label { position: relative; display: inline-flex; align-items: baseline; gap: 6px; font-size: 14px; padding: 8px 12px; border: 1px solid var(--linie); cursor: pointer; transition: border-color var(--farbe) var(--kurve), background-color var(--farbe) var(--kurve); }
.chips label small { font-size: 12px; color: var(--meta); }
.chips input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.chips label:has(input:checked) { border-color: var(--anthrazit); box-shadow: inset 0 -2px 0 var(--messing); }
.chips label:has(input:focus-visible) { outline: 1.5px solid var(--messing); outline-offset: 2px; }

.feld { position: relative; padding-bottom: 34px; }
.feld > label { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; font-size: 11px; }
.hinweis { font-size: 13px; line-height: 1.4; color: var(--leise); margin: -2px 0 8px; }   /* über dem Feld: Autofill verdeckt ihn nicht */
.eingabe { position: relative; }
.eingabe input {
  width: 100%; font: 400 17px/1.2 'Instrument Sans', system-ui, sans-serif; color: var(--tinte);
  background: var(--flaeche); border: 1px solid var(--rahmen); border-radius: 2px; padding: 14px 46px 14px 15px; outline: none;
  transition: border-color var(--farbe) var(--kurve), background-color var(--farbe) var(--kurve), box-shadow var(--farbe) var(--kurve);
}
.eingabe input::placeholder { color: rgba(35, 40, 47, .34); }
.eingabe input:focus-visible, .feld.fokus input { border-color: var(--anthrazit); background: var(--grund); box-shadow: 0 0 0 3px rgba(168, 138, 90, .26); }

/* Kern: erst nach Verlassen oder Absenden */
.eingabe input:user-invalid { border-color: var(--fehler); background: var(--fehler-grund); }
.eingabe input:user-invalid:focus-visible, .feld.fokus input:user-invalid { box-shadow: 0 0 0 3px rgba(245, 82, 82, .18); }
.eingabe input:user-valid { border-color: var(--gut); }
.fehler {
  position: absolute; left: 0; right: 0; top: calc(100% - 28px); display: flex; gap: 6px; align-items: baseline;
  font-size: 13.5px; line-height: 1.3; color: var(--fehler-text); font-weight: 500;
  opacity: 0; visibility: hidden; translate: 0 -10px;
  transition: opacity var(--meldung) var(--kurve), translate var(--meldung) var(--kurve), visibility 0s linear var(--meldung);
}
.feld:has(input:user-invalid) .fehler { opacity: 1; visibility: visible; translate: 0 0; transition-delay: 0s; }

/* Zeichen im Feld (Varianten Häkchen und Regelliste) */
.zeichen { position: absolute; right: 14px; top: 50%; width: 20px; height: 20px; margin-top: -10px; pointer-events: none; display: none; }
[data-variante="haken"] .zeichen, [data-variante="regeln"] .zeichen { display: block; }
.zeichen .haken { fill: none; stroke: var(--gut); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 22; stroke-dashoffset: 22; transition: stroke-dashoffset var(--meldung) var(--kurve); }
.zeichen .ring { fill: none; stroke: var(--gut); stroke-width: 1.4; opacity: 0; transition: opacity var(--meldung) var(--kurve); }
.zeichen .warn { opacity: 0; scale: .6; transform-origin: 10px 10px; transition: opacity var(--meldung) var(--kurve), scale var(--meldung) var(--kurve); }
.feld:has(input:user-valid) .haken { stroke-dashoffset: 0; }
.feld:has(input:user-valid) .ring { opacity: .35; }
.feld:has(input:user-invalid) .warn { opacity: 1; scale: 1; }

/* Regelliste (Variante Regelliste) */
.regeln { list-style: none; display: none; gap: 4px 18px; grid-template-columns: auto auto; justify-content: start; margin-top: 10px; font-size: 13.5px; color: var(--leise); }
[data-variante="regeln"] .regeln { display: grid; }
[data-variante="regeln"] .feld-weiten .fehler { display: none; }
[data-variante="regeln"] .feld-weiten { padding-bottom: 22px; }
.regeln li { display: flex; gap: 7px; align-items: center; transition: color var(--farbe) var(--kurve); }
.regeln li::before { content: ''; width: 14px; height: 14px; border-radius: 50%; border: 1.4px solid currentColor; flex: none; transition: background-color var(--farbe) var(--kurve), border-color var(--farbe) var(--kurve); }
.regeln li.ok { color: var(--gut); }
.regeln li.ok::before { background: var(--gut) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Cpath d='M3.6 7.2 6 9.5l4.4-4.6' fill='none' stroke='%23ffffff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 14px no-repeat; border-color: var(--gut); }
.feld:has(input:user-invalid) .regeln li:not(.ok) { color: var(--fehler-text); font-weight: 600; }

/* Absenden */
.absenden { display: flex; align-items: center; gap: 18px; margin-top: 2px; }
.knopf { font: 600 12.5px/1 'Instrument Sans', sans-serif; letter-spacing: .08em; text-transform: uppercase; color: var(--grund); background: var(--anthrazit); border: 0; border-radius: 0; padding: 19px 26px; cursor: pointer; display: inline-flex; gap: 12px; align-items: center; flex: none; transition: scale var(--farbe) var(--kurve), background-color var(--farbe) var(--kurve); }
.knopf:hover { background: #363c45; }
.knopf:active, .knopf.gedrueckt { scale: .97; }
.knopf:focus-visible { outline: 1.5px solid var(--messing); outline-offset: 3px; }
.status { font-size: 14px; line-height: 1.4; color: var(--leise); min-height: 1.4em; text-wrap: pretty; }
.status.problem { color: var(--fehler-text); }
.status.erfolg { color: var(--gut); }
.erfolg-ansicht form .knopf { background: var(--gut); }

.werte { margin-top: auto; padding-top: 22px; color: var(--meta); display: flex; flex-wrap: wrap; gap: 4px 14px; }
.werte b { font-weight: 500; color: var(--tinte); }

/* Shake nur beim Absende-Versuch */
@keyframes ruetteln { 25% { translate: -4px 0; } 75% { translate: 4px 0; } }
.feld.ruetteln .eingabe { animation: ruetteln 300ms var(--kurve); }

/* Reduzierte Bewegung: kein Shake, kein Zeichnen, kein Wandern; Farbe, Zeichen und Text bleiben */
.reduziert .feld.ruetteln .eingabe { animation: none; }
.reduziert .fehler { translate: none; transition: opacity 150ms linear, visibility 0s linear 150ms; }
.reduziert .feld:has(input:user-invalid) .fehler { transition-delay: 0s; }
.reduziert .zeichen .haken { transition: none; }
.reduziert .zeichen .warn { scale: 1; transition: opacity 150ms linear; }
.reduziert .knopf.gedrueckt, .reduziert .knopf:active { scale: 1; }

/* Touch: Verhalten identisch; 16 px+ gegen Zoom, größere Ziele */
@media (hover: none) {
  .umschalter button { padding: 12px 12px; }
  .chips label { padding: 11px 13px; }
  .knopf:hover { background: var(--anthrazit); }
}
@media (max-width: 780px) {
  .buehne { grid-template-columns: 1fr; }
  .tafel { min-height: 0; gap: 16px; }
  .ringe { min-height: 0; padding-bottom: 34px; }
  .ringe svg { width: min(100%, 340px); }
  .tafel h1 { font-size: 38px; }
  .phasen li { padding: 8px 0; }
  .phasen li small { display: none; }
  .kopf { flex-wrap: wrap; }
}
@media (max-width: 420px) {
  .kopf > .label { display: none; }
  .umschalter { width: 100%; }
  .umschalter button { flex: 1; padding-inline: 6px; }
  .absenden { flex-direction: column; align-items: stretch; }
  .knopf { justify-content: center; }
  .ringe figcaption { font-size: 11.5px; }
  .tafel h1 { font-size: 34px; }
  .chips { display: grid; grid-template-columns: 1fr 1fr; }
}

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; padding: 28px; }
.vorschau .tafel { min-height: 0; }
.vorschau .werte, .vorschau .vorspann { display: none; }
.vorschau form h2 { margin-bottom: 18px; }
.vorschau .kopf { margin-bottom: 18px; padding-bottom: 14px; }
.vorschau .wahl { margin-bottom: 18px; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<main class="buehne">
  <aside class="tafel">
    <header class="tafel-kopf">
      <p class="wortmarke"><b>AUREL</b><span class="label">Goldschmiede · Pforzheim</span></p>
      <p class="label">Trauringe</p>
    </header>

    <figure class="ringe">
      <svg viewBox="0 0 420 230" role="img" aria-label="Zwei Trauringe aus Platin, der vordere mit feiner Goldfuge">
        <defs>
          <linearGradient id="band" x1="0" x2="1" y1="0" y2="0">
            <stop offset="0" stop-color="#2c3138"/><stop offset=".14" stop-color="#7c838b"/><stop offset=".3" stop-color="#e6e9ec"/>
            <stop offset=".42" stop-color="#8d949c"/><stop offset=".6" stop-color="#5c636b"/><stop offset=".78" stop-color="#c8cdd2"/>
            <stop offset=".9" stop-color="#6f767e"/><stop offset="1" stop-color="#2a2f36"/>
          </linearGradient>
          <linearGradient id="band-hoehe" x1="0" x2="0" y1="0" y2="1">
            <stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity=".35"/>
          </linearGradient>
          <linearGradient id="kante" x1="0" x2="1" y1="0" y2="0">
            <stop offset="0" stop-color="#6a7179"/><stop offset=".35" stop-color="#f3f5f7"/><stop offset=".62" stop-color="#9ba2a9"/><stop offset="1" stop-color="#d7dbdf"/>
          </linearGradient>
          <linearGradient id="wand" x1="0" x2="0" y1="0" y2="1">
            <stop offset="0" stop-color="#b9bfc5"/><stop offset=".35" stop-color="#7a8189"/><stop offset="1" stop-color="#3a4048"/>
          </linearGradient>
          <linearGradient id="wand-quer" x1="0" x2="1" y1="0" y2="0">
            <stop offset="0" stop-color="#000" stop-opacity=".45"/><stop offset=".3" stop-color="#000" stop-opacity="0"/><stop offset=".62" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-color="#000" stop-opacity=".5"/>
          </linearGradient>
          <clipPath id="loch-a"><ellipse cx="160" cy="104" rx="79" ry="28.5"/></clipPath>
          <clipPath id="loch-b"><ellipse cx="262" cy="140" rx="65" ry="22.6"/></clipPath>
          <radialGradient id="schatten"><stop offset="0" stop-color="#0c0e11" stop-opacity=".75"/><stop offset="1" stop-color="#0c0e11" stop-opacity="0"/></radialGradient>
        </defs>
        <!-- Schatten auf der Ablage -->
        <ellipse cx="160" cy="150" rx="118" ry="30" fill="url(#schatten)"/>
        <ellipse cx="262" cy="178" rx="96" ry="24" fill="url(#schatten)"/>
        <!-- Ring A: breit, liegend -->
        <g>
          <g clip-path="url(#loch-a)"><path d="M81 104 A79 28.5 0 1 1 239 104 A79 28.5 0 1 1 81 104 Z M81 132 A79 28.5 0 1 0 239 132 A79 28.5 0 1 0 81 132 Z" fill="url(#wand)" fill-rule="evenodd"/><path d="M81 104 A79 28.5 0 1 1 239 104 A79 28.5 0 1 1 81 104 Z M81 132 A79 28.5 0 1 0 239 132 A79 28.5 0 1 0 81 132 Z" fill="url(#wand-quer)" fill-rule="evenodd"/></g>
          <path d="M72 104 A88 33 0 0 0 248 104 L248 132 A88 33 0 0 1 72 132 Z" fill="url(#band)"/>
          <path d="M72 104 A88 33 0 0 0 248 104 L248 132 A88 33 0 0 1 72 132 Z" fill="url(#band-hoehe)"/>
          <path d="M72 104 A88 33 0 1 1 248 104 A88 33 0 1 1 72 104 Z M81 104 A79 28.5 0 1 0 239 104 A79 28.5 0 1 0 81 104 Z" fill="url(#kante)" fill-rule="evenodd"/>
        </g>
        <!-- Ring B: schmaler, davor, mit Goldfuge -->
        <g>
          <g clip-path="url(#loch-b)"><path d="M197 140 A65 22.6 0 1 1 327 140 A65 22.6 0 1 1 197 140 Z M197 162 A65 22.6 0 1 0 327 162 A65 22.6 0 1 0 197 162 Z" fill="url(#wand)" fill-rule="evenodd"/><path d="M197 140 A65 22.6 0 1 1 327 140 A65 22.6 0 1 1 197 140 Z M197 162 A65 22.6 0 1 0 327 162 A65 22.6 0 1 0 197 162 Z" fill="url(#wand-quer)" fill-rule="evenodd"/></g>
          <path d="M190 140 A72 26 0 0 0 334 140 L334 162 A72 26 0 0 1 190 162 Z" fill="url(#band)"/>
          <path d="M190 140 A72 26 0 0 0 334 140 L334 162 A72 26 0 0 1 190 162 Z" fill="url(#band-hoehe)"/>
          <path d="M190 151 A72 26 0 0 0 334 151" fill="none" stroke="#a88a5a" stroke-width="1.3"/>
          <path d="M190 140 A72 26 0 1 1 334 140 A72 26 0 1 1 190 140 Z M197 140 A65 22.6 0 1 0 327 140 A65 22.6 0 1 0 197 140 Z" fill="url(#kante)" fill-rule="evenodd"/>
        </g>
      </svg>
      <figcaption>
        <span class="ziffern"><b>Modell Linde</b> · Platin 950, 4,5 mm</span>
        <span class="anfrage ziffern" data-anfrage aria-hidden="true">Anfrage 2611-047</span>
      </figcaption>
    </figure>

    <h1 class="antiqua">Zwei Ringe,<br> <em>aus einem Barren.</em></h1>

    <ol class="phasen" data-phasen aria-hidden="true">
      <li class="an"><span class="label">01</span><div><b>Beim Tippen: Ruhe</b><small>Kein Rot, solange jemand noch schreibt.</small></div></li>
      <li><span class="label">02</span><div><b>Nach dem Absenden: Hinweis</b><small>Erst jetzt Rahmen, Zeichen und Text.</small></div></li>
      <li><span class="label">03</span><div><b>Beim Korrigieren: Entwarnung</b><small>Sobald es stimmt, sofort grün.</small></div></li>
    </ol>
  </aside>

  <section class="formular" aria-labelledby="titel">
    <div class="kopf">
      <p class="label">Variante</p>
      <div class="umschalter" role="group" aria-label="Variante wählen">
        <button type="button" data-v="rahmen" aria-pressed="false">Nur CSS</button>
        <button type="button" data-v="haken" aria-pressed="true">Häkchen</button>
        <button type="button" data-v="regeln" aria-pressed="false">Regelliste</button>
      </div>
    </div>

    <form data-form data-variante="haken" novalidate>
      <h2 id="titel" class="antiqua">Termin in der Werkstatt <em>anfragen.</em></h2>
      <p class="vorspann">Das erste Gespräch dauert etwa eine Stunde. Sie probieren Breiten und Oberflächen an, wir skizzieren mit Ihnen das Paar.</p>

      <fieldset class="wahl">
        <legend><span class="label">Material</span><small>optional</small></legend>
        <div class="chips">
          <label><input type="radio" name="material" value="gelbgold">Gelbgold <small class="ziffern">750</small></label>
          <label><input type="radio" name="material" value="rotgold">Rotgold <small class="ziffern">750</small></label>
          <label><input type="radio" name="material" value="weissgold">Weißgold <small class="ziffern">750</small></label>
          <label><input type="radio" name="material" value="platin" checked>Platin <small class="ziffern">950</small></label>
        </div>
      </fieldset>

      <div class="feld">
        <label for="f-name" class="label">Ihr Name</label>
        <div class="eingabe">
          <input id="f-name" name="name" type="text" required autocomplete="name" placeholder="Lena Hofmann" aria-errormessage="e-name" aria-invalid="false">
          <svg class="zeichen" viewBox="0 0 20 20" aria-hidden="true"><circle class="ring" cx="10" cy="10" r="9"/><path class="haken" d="M5.6 10.4 8.6 13.3 14.6 7"/><g class="warn"><circle cx="10" cy="10" r="9.2" fill="#F55252"/><path d="M10 5.4v5.4M10 13.6v.9" stroke="#fff" stroke-width="2" stroke-linecap="round"/></g></svg>
        </div>
        <p class="fehler" id="e-name">Wie dürfen wir Sie ansprechen? Ein Name reicht.</p>
      </div>

      <div class="feld">
        <label for="f-mail" class="label">E-Mail</label>
        <div class="eingabe">
          <input id="f-mail" name="email" type="email" required autocomplete="email" placeholder="lena@beispiel.de" aria-errormessage="e-mail" aria-invalid="false">
          <svg class="zeichen" viewBox="0 0 20 20" aria-hidden="true"><circle class="ring" cx="10" cy="10" r="9"/><path class="haken" d="M5.6 10.4 8.6 13.3 14.6 7"/><g class="warn"><circle cx="10" cy="10" r="9.2" fill="#F55252"/><path d="M10 5.4v5.4M10 13.6v.9" stroke="#fff" stroke-width="2" stroke-linecap="round"/></g></svg>
        </div>
        <p class="fehler" id="e-mail">Da fehlt noch etwas. So sieht es richtig aus: name@anbieter.de</p>
      </div>

      <div class="feld feld-weiten">
        <label for="f-weiten" class="label">Ringweiten</label>
        <p class="hinweis" id="h-weiten">Ihre und die Ihres Partners, z. B. 54 / 58. Eine Schätzung genügt, wir messen beim Termin nach.</p>
        <div class="eingabe">
          <input id="f-weiten" name="ringweiten" type="text" inputmode="text" required pattern="\s*(4[4-9]|[56]\d|7[0-2])\s*/\s*(4[4-9]|[56]\d|7[0-2])\s*" autocomplete="off" spellcheck="false" placeholder="54 / 58" aria-describedby="h-weiten r-weiten" aria-errormessage="e-weiten" aria-invalid="false">
          <svg class="zeichen" viewBox="0 0 20 20" aria-hidden="true"><circle class="ring" cx="10" cy="10" r="9"/><path class="haken" d="M5.6 10.4 8.6 13.3 14.6 7"/><g class="warn"><circle cx="10" cy="10" r="9.2" fill="#F55252"/><path d="M10 5.4v5.4M10 13.6v.9" stroke="#fff" stroke-width="2" stroke-linecap="round"/></g></svg>
        </div>
        <ul class="regeln" id="r-weiten" data-regeln>
          <li data-regel="zwei">Zwei Weiten, getrennt mit /</li>
          <li data-regel="bereich">Jeweils 44 bis 72</li>
        </ul>
        <p class="fehler" id="e-weiten">Bitte zwei Weiten zwischen 44 und 72, etwa 54 / 58.</p>
      </div>

      <div class="absenden">
        <button class="knopf" type="submit" data-knopf>Termin anfragen <span aria-hidden="true">→</span></button>
        <p class="status" data-status role="status" aria-live="polite"></p>
      </div>
    </form>

    <p class="werte mono" data-werte></p>
  </section>
</main>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const form = document.querySelector('[data-form]');
  const felder = [...form.querySelectorAll('.feld')];
  const inputs = felder.map(f => f.querySelector('input'));
  const [name, mail, weiten] = inputs;
  const knopf = document.querySelector('[data-knopf]');
  const status = document.querySelector('[data-status]');
  const phasen = [...document.querySelectorAll('[data-phasen] li')];
  const anfrage = document.querySelector('[data-anfrage]');
  const werteZeile = document.querySelector('[data-werte]');
  const schalter = [...document.querySelectorAll('[data-v]')];
  const VARIANTEN = ['haken', 'regeln', 'rahmen'];
  const KNOPF_TEXT = knopf.innerHTML;

  const WERTE = {
    gemeinsam: '<span><b>ease-out</b> cubic-bezier(0, 0, 0.58, 1)</span><span>Meldung <b>300 ms</b> · −10 px → 0</span><span>Rahmen <b>150 ms</b></span><span>Shake <b>300 ms</b> · ±4 px</span><span>Fehler <b>#F55252</b></span>',
    rahmen: '<span>:user-invalid + Geschwister-Text</span>',
    haken: '<span>Häkchen zeichnet <b>300 ms</b></span>',
    regeln: '<span>Regeln per :has()</span>'
  };

  function phase(n) { phasen.forEach((li, i) => li.classList.toggle('an', i === n)); }

  // aria-invalid folgt :user-invalid (blur, input, Absenden)
  function sync() { inputs.forEach(i => i.setAttribute('aria-invalid', i.matches(':user-invalid') ? 'true' : 'false')); }
  function regeln() {
    const v = weiten.value;
    const zahlen = v.match(/\d+/g) || [];
    form.querySelector('[data-regel="zwei"]').classList.toggle('ok', /^\s*\d+\s*\/\s*\d+\s*$/.test(v));
    form.querySelector('[data-regel="bereich"]').classList.toggle('ok', zahlen.length > 0 && zahlen.every(z => +z >= 44 && +z <= 72));
  }
  let versucht = false;
  form.addEventListener('input', e => {
    if (e.target.type === 'radio') return;
    regeln(); sync();
    if (versucht && !form.classList.contains('fertig')) phase(2);
    if (status.classList.contains('problem')) {
      const offen = inputs.filter(i => !i.validity.valid).length;
      status.textContent = offen ? `${offen === 1 ? 'Ein Feld braucht' : offen + ' Felder brauchen'} noch Aufmerksamkeit.` : 'Alles in Ordnung.';
      status.classList.toggle('problem', offen > 0);
    }
  });
  form.addEventListener('focusout', () => setTimeout(sync));

  function ruetteln(feld) {
    if (reduziert()) return;
    feld.classList.remove('ruetteln'); void feld.offsetWidth; feld.classList.add('ruetteln');
  }
  felder.forEach(f => f.addEventListener('animationend', () => f.classList.remove('ruetteln')));

  form.addEventListener('submit', e => {
    e.preventDefault();
    versucht = true; sync();
    const falsch = felder.filter(f => !f.querySelector('input').validity.valid);
    status.classList.remove('erfolg');
    if (falsch.length) {
      falsch.forEach(ruetteln);
      status.textContent = `${falsch.length === 1 ? 'Ein Feld braucht' : falsch.length + ' Felder brauchen'} noch Aufmerksamkeit.`;
      status.classList.add('problem');
      phase(1);
      if (!html.classList.contains('vorfuehrung')) falsch[0].querySelector('input').focus();
    } else {
      status.classList.remove('problem'); status.classList.add('erfolg');
      const vorname = name.value.trim().split(/\s+/)[0];
      status.textContent = `Danke, ${vorname}. Wir melden uns innerhalb von zwei Werktagen mit Terminvorschlägen.`;
      knopf.innerHTML = 'Angefragt <span aria-hidden="true">✓</span>';
      form.classList.add('fertig'); document.body.classList.add('erfolg-ansicht');
      anfrage.removeAttribute('aria-hidden');
      phase(2);
    }
  });

  function zuruecksetzen() {
    form.reset();
    versucht = false;
    form.classList.remove('fertig'); document.body.classList.remove('erfolg-ansicht');
    anfrage.setAttribute('aria-hidden', 'true');
    knopf.innerHTML = KNOPF_TEXT; knopf.classList.remove('gedrueckt');
    status.textContent = ''; status.className = 'status';
    felder.forEach(f => f.classList.remove('fokus', 'ruetteln'));
    regeln(); sync(); phase(0);
  }

  function variante(v) {
    form.dataset.variante = v;
    schalter.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    werteZeile.innerHTML = WERTE.gemeinsam + WERTE[v];
  }
  schalter.forEach(b => b.addEventListener('click', () => { if (html.classList.contains('vorfuehrung')) neustart(); variante(b.dataset.v); }));
  variante('haken');

  // Vorführung: ein gedachtes Paar fragt einen Termin an (ca. 11 s je Runde)
  let zeitgeber = [], runde = 0, laeuft = false;
  const nach = (ms, f) => zeitgeber.push(setTimeout(f, ms));
  function fokus(i) { felder.forEach((f, j) => f.classList.toggle('fokus', j === i)); }
  function tippen(t0, input, text, ms = 70) {
    [...text].forEach((z, k) => nach(t0 + k * ms, () => { input.value += z; input.dispatchEvent(new Event('input', { bubbles: true })); }));
    return t0 + text.length * ms;
  }
  function druecken(t) {
    nach(t, () => knopf.classList.add('gedrueckt'));
    nach(t + 150, () => { knopf.classList.remove('gedrueckt'); fokus(-1); form.requestSubmit(); });
  }
  function eineRunde() {
    zuruecksetzen();
    variante(VARIANTEN[runde++ % VARIANTEN.length]);
    let t = 350;
    nach(t, () => fokus(0)); t = tippen(t + 150, name, 'Lena Hofmann', 55);
    nach(t + 150, () => fokus(1)); t = tippen(t + 300, mail, 'lena.hofmann@', 55);   // unfertig, aber kein Rot
    nach(t + 250, () => fokus(-1));
    druecken(t + 450);                                                                 // Absende-Versuch
    t += 2700;
    nach(t, () => fokus(1)); t = tippen(t + 150, mail, 'beispiel.de', 80);             // sofort grün, sobald gültig
    nach(t + 300, () => fokus(2)); t = tippen(t + 500, weiten, '54 / 58', 150);        // Regeln haken sich ab
    nach(t + 300, () => fokus(-1));
    druecken(t + 900);
    nach(t + 3600, eineRunde);
  }
  function vorfuehren() {
    neustart();
    html.classList.add('vorfuehrung'); laeuft = true;
    eineRunde();
  }
  function pause() { zeitgeber.forEach(clearTimeout); zeitgeber = []; laeuft = false; }
  function neustart() { pause(); html.classList.remove('vorfuehrung'); zuruecksetzen(); }

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