Hover-Absicht und Zeitlogik
Mega-Menü, Tooltip und Pfeil reagieren erst auf echtes Verweilen, schließen mit kurzem Nachlauf und wechseln bei offenem Menü ohne neue Wartezeit.
Drei Zeitregeln für Hover-Effekte mit großer Wirkung wie Mega-Menü, Bildvorschau oder Hovercard.
- Absicht: Der Effekt startet erst nach kurzem Verweilen (30–250 ms). Wer nur über ein Raster oder eine Navigation streift, löst nichts aus.
- Nachlauf: Beim Verlassen bleibt das Element 100–300 ms offen. So erreicht der Zeiger das geöffnete Menü, auch wenn er kurz eine Lücke überquert.
- Skip-Delay: Ist schon ein Element offen, öffnet der Nachbar sofort. Das Wandern durch eine Navigation bleibt flüssig.
Die Seite reagiert auf Absicht statt auf jede Bewegung. Nichts flackert beim Drüberstreifen, nichts klappt weg, während man hineinfährt. Das bemerkt kaum jemand bewusst, aber die Seite wirkt ruhig und durchdacht statt nervös. Genau das unterscheidet handwerklich gute von bloß animierten Seiten.
Wann einsetzen
Bei allen Hover-Effekten, die Layout überlagern oder Medien laden: Mega-Menüs, Vorschaukarten, Hovercards, Tooltips, Raster mit Bild-Reveal.
Wann nicht
Bei kleinem Feedback wie Farbwechsel, Hintergrundfläche oder Unterstreichung. Dort sofort reagieren, sonst wirkt die Seite träge. Auch nicht bei Klick-Menüs.
Varianten
- Fester Timer mit Skip-Delay: 100 ms rein, 150 ms Nachlauf, 0 ms beim Nachbarn (diese Demo)
- Hover-Intent über die Geschwindigkeit: öffnet erst, wenn der Zeiger abbremst (in der Demo umschaltbar)
- Reines CSS mit transition-delay nur im :hover-Zustand, raus ohne Verzögerung (Stripe, PostHog, 14islands)
- Kurzer Timer gegen Streif-Bewegungen über ein Bildraster, etwa 30 ms (Codrops HoverGrid)
- Nativ mit Interest Invokers: interest-delay, Standard 0,5 s rein und raus, anpassbar z. B. auf 0.2s 0.1s; Skip-Delay über :root:has(:interest-source)
Umsetzung
JS: Beim pointerenter einen Timer starten: setTimeout(open, anyOpen ? 0 : 100). Beim pointerleave den Timer löschen und das Schließen planen: closeT = setTimeout(close, 150). Das geöffnete Menü löscht closeT bei pointerenter und plant es bei pointerleave neu. Ein Panel für alle Menüpunkte, dessen Inhalt beim Skip-Delay wechselt; so bleibt der Rahmen stehen und nur Höhe und Inhalt gleiten.
CSS: In Produktion lösen viele Seiten das mit transition-delay, das nur im Hover-Zustand gilt: .tip { transition: opacity .15s } .btn:hover .tip { transition-delay: .25s }. Hinein mit Verzögerung, hinaus sofort.
Nativ: [interestfor] { interest-delay: .2s .1s } und :root:has(:interest-source) [interestfor] { interest-delay-start: 0s }.
Fallstricke: Fokus darf nicht warten. Ein Klick auf den Auslöser darf ein gerade per Hover geöffnetes Menü nicht sofort wieder schließen. Kleines Feedback wie die Hintergrundfläche des Menüpunkts immer sofort zeigen, nur den großen Effekt verzögern.
Robust umsetzen
Telefon. Auf Touch gibt es kein Hover. Tippen öffnet sofort, ohne Verzögerung und ohne Nachlauf. Zweites Tippen oder Tippen daneben schließt.
Ohne Bewegung. Die Zeitlogik bleibt sinnvoll und unverändert. Nur die Animation des geöffneten Elements wird zur kurzen Überblendung.
Barrierefrei. Tastaturfokus öffnet ohne Verzögerung. Escape schließt und gibt den Fokus an den Auslöser zurück. aria-expanded am Auslöser, Tooltip mit role=tooltip und aria-describedby. Inhalte, die per Hover erscheinen, müssen sich schließen lassen, mit dem Zeiger erreichbar sein und stehen bleiben (WCAG 1.4.13).
Code
<!DOCTYPE html>
<!--
Hover-Absicht und Zeitlogik
Fundus · Hover · https://cecodesign.de/lab/fundus/hover/hover-absicht-timing/
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>Hover-Absicht und Zeitlogik · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; 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: '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 {
--grund: #f8f8ec; --grund-tief: #eef0d8; --navy: #142b50; --nacht: #0e1d38; --himmel: #3fbadf;
--gelb: #ffd60a; --tinte: #17243c; --leise: #566175; --linie: rgba(20, 43, 80, .14); --weiss: #fffffb;
--auf: cubic-bezier(0.16, 1, 0.3, 1); /* Menü öffnet: expo.out 320 ms */
--zu: cubic-bezier(0.4, 0, 0.2, 1); /* Menü schließt: 160 ms */
--feder: cubic-bezier(0.34, 1.56, 0.64, 1);/* kleine Elemente: 350 ms */
--absicht: 100ms; /* wird je Variante per JS gesetzt */
--pfeil-verzug: 200ms;
--rand: clamp(16px, 4vw, 48px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.label { font-weight: 700; font-size: 12.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }
:focus-visible { outline: 2px solid var(--navy); outline-offset: 3px; border-radius: 6px; }
/* ── Kopf ─────────────────────────────────────────── */
.kopf { position: relative; z-index: 10; padding: 18px var(--rand) 16px; }
.kopf-innen { max-width: 1180px; margin: 0 auto; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; }
.marke { display: flex; align-items: center; gap: 12px; justify-self: start; }
.marke svg { width: 40px; height: 40px; flex: none; }
.marke b { display: block; font: 700 22px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 32; letter-spacing: -.02em; color: var(--navy); }
.marke small { display: block; margin-top: 4px; font-size: 12px; color: var(--leise); }
.nav ul { list-style: none; display: flex; gap: 4px; }
.nav li { position: relative; }
.ausloeser, .navlink {
position: relative; display: flex; align-items: center; gap: 6px; padding: 11px 14px; border-radius: 999px;
font-size: 16px; font-weight: 600; color: var(--tinte);
}
.ausloeser svg { width: 10px; height: 10px; transition: rotate .32s var(--auf); }
/* Winziges Feedback sofort: Fläche und Farbe ohne Verzögerung */
.ausloeser.ueber, .navlink.ueber, .ausloeser[aria-expanded="true"] { background: var(--grund-tief); color: var(--navy); }
.ausloeser[aria-expanded="true"] svg { rotate: 180deg; }
/* Absichtslinie: füllt sich in genau der Wartezeit */
.ausloeser::after {
content: ''; position: absolute; left: 14px; right: 14px; bottom: 5px; height: 2px; border-radius: 2px;
background: var(--navy); scale: 0 1; transform-origin: left; opacity: 0;
}
.ausloeser.wartet::after { scale: 1 1; opacity: 1; transition: scale var(--absicht) linear, opacity .1s linear; }
.ausloeser.tempo::after { scale: 1 1; opacity: .35; transition: opacity .1s linear; }
.knopf {
justify-self: end; display: inline-flex; align-items: center; gap: 8px; padding: 12px 20px; border-radius: 999px;
background: var(--navy); color: var(--grund); font-weight: 700; font-size: 15.5px;
transition: scale .35s var(--feder), box-shadow .35s var(--feder);
}
.knopf.ueber { scale: 1.04; box-shadow: 0 6px 0 -1px var(--gelb); }
/* ── Panel (ein Container, drei Ansichten) ──────────── */
.panel {
position: absolute; left: 0; right: 0; top: calc(100% + 8px); margin: 0 auto; width: min(100% - 2 * var(--rand), 860px);
background: var(--weiss); border-radius: 22px; border: 1px solid var(--linie);
box-shadow: 0 24px 60px -24px rgba(14, 29, 56, .35), 0 2px 0 rgba(14, 29, 56, .04);
opacity: 0; translate: 0 -8px; scale: .985; transform-origin: 50% 0; visibility: hidden; pointer-events: none;
transition: opacity .16s var(--zu), translate .16s var(--zu), scale .16s var(--zu), visibility 0s .16s;
}
.panel.offen {
opacity: 1; translate: 0 0; scale: 1; visibility: visible; pointer-events: auto;
transition: opacity .32s var(--auf), translate .32s var(--auf), scale .32s var(--auf), visibility 0s;
}
.panel-innen { position: relative; height: var(--h, 220px); overflow: hidden; }
.panel.wechsel .panel-innen { transition: height .32s var(--auf); }
.ansicht {
position: absolute; left: 0; right: 0; top: 0; padding: 22px 24px 24px;
opacity: 0; visibility: hidden; translate: var(--von, 0px) 0;
}
.ansicht.an { opacity: 1; visibility: visible; translate: 0 0; }
.panel.wechsel .ansicht { transition: opacity .2s linear, translate .32s var(--auf), visibility 0s .2s; }
.panel.wechsel .ansicht.an { transition: opacity .2s linear .06s, translate .32s var(--auf), visibility 0s; }
.ans-kopf { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
/* Klassen */
.ans-klassen { display: grid; grid-template-columns: 1fr 220px; gap: 18px; }
.klassen { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.klasse {
display: grid; gap: 2px; align-content: start; padding: 14px 14px 13px; border-radius: 14px; background: var(--grund);
border: 1px solid transparent; transition: translate .35s var(--feder), border-color .15s linear;
}
.klasse svg { width: 46px; height: 28px; margin-bottom: 6px; color: var(--navy); }
.klasse .code { font: 700 30px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; letter-spacing: -.02em; color: var(--navy); }
.klasse .was { font-weight: 700; font-size: 14.5px; }
.klasse .info { font-size: 13px; color: var(--leise); line-height: 1.35; font-variant-numeric: tabular-nums; }
.klasse.ueber { translate: 0 -3px; border-color: var(--navy); }
.probe { background: var(--gelb); border-radius: 14px; padding: 16px; display: grid; align-content: space-between; gap: 12px; color: var(--navy); }
.probe strong { font: 700 19px/1.1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 32; letter-spacing: -.01em; display: block; }
.probe p { font-size: 13.5px; line-height: 1.4; margin-top: 6px; }
.probe span.mini { font-weight: 700; font-size: 14px; display: inline-flex; gap: 6px; align-items: center; }
/* Standorte */
.ans-orte { display: grid; grid-template-columns: 1fr 1.15fr; gap: 20px; align-items: stretch; }
.karte { width: 100%; height: 100%; min-height: 170px; border-radius: 14px; background: var(--grund-tief); display: block; }
.orte { list-style: none; display: grid; gap: 4px; align-content: start; }
.ort { display: grid; grid-template-columns: 26px 1fr auto; gap: 10px; align-items: baseline; padding: 10px 12px; border-radius: 12px; transition: background-color .15s linear; }
.ort.ueber { background: var(--grund); }
.ort i { font-style: normal; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--navy); color: var(--grund); font-weight: 700; font-size: 12px; translate: 0 4px; }
.ort b { display: block; font-size: 16px; }
.ort span { font-size: 13.5px; color: var(--leise); }
.ort em { font-style: normal; font-size: 13px; color: var(--leise); font-variant-numeric: tabular-nums; text-align: right; }
/* Theorie */
.ans-theorie { display: grid; grid-template-columns: 1fr 210px; gap: 20px; }
.termine { list-style: none; }
.termin { display: grid; grid-template-columns: 92px 54px 1fr auto; gap: 12px; align-items: baseline; padding: 9px 4px; border-top: 1px solid var(--linie); font-variant-numeric: tabular-nums; font-size: 15px; }
.termin:first-child { border-top: 0; }
.termin b { font-weight: 700; }
.termin span { color: var(--leise); font-size: 14px; }
.termin em { font-style: normal; font-size: 12.5px; font-weight: 700; padding: 3px 8px; border-radius: 999px; background: var(--grund-tief); color: var(--navy); }
.termin em.knapp { background: var(--gelb); }
.sprachen { background: var(--grund); border-radius: 14px; padding: 16px; display: grid; gap: 10px; align-content: start; font-size: 14px; line-height: 1.45; }
.sprachen ul { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
.sprachen li { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--linie); font-weight: 700; font-size: 13px; }
/* ── Bühne / Hero ─────────────────────────────────── */
.buehne { padding: clamp(12px, 3vh, 40px) var(--rand) clamp(20px, 4vh, 48px); display: grid; align-items: center; }
.held { max-width: 1180px; width: 100%; margin: 0 auto; display: grid; grid-template-columns: 1.35fr 1fr; gap: clamp(24px, 4vw, 64px); align-items: center; }
.eyebrow { display: flex; align-items: center; gap: 10px; margin-bottom: clamp(12px, 2vh, 20px); }
.eyebrow::before { content: ''; width: 22px; height: 14px; border-radius: 3px; background: var(--gelb); box-shadow: inset 0 0 0 1.5px var(--navy); }
h1 {
font-family: 'Bricolage Grotesque', sans-serif; font-weight: 700; font-variation-settings: 'opsz' 96;
font-size: clamp(2.5rem, min(1rem + 5vw, 9.4svh), 5.6rem); line-height: .98; letter-spacing: -.025em; color: var(--navy);
hyphens: auto; text-wrap: balance;
}
h1 mark { background: linear-gradient(transparent 58%, var(--gelb) 58%, var(--gelb) 92%, transparent 92%); color: inherit; padding: 0 .04em; }
.lead { margin-top: clamp(14px, 2.4vh, 24px); font-size: clamp(16px, 1.25vw, 18px); line-height: 1.6; max-width: 34em; color: var(--tinte); }
.preise { display: flex; flex-wrap: wrap; gap: 10px; margin-top: clamp(16px, 3vh, 28px); }
.preis { position: relative; padding: 12px 16px 12px; border-radius: 16px; background: var(--weiss); border: 1px solid var(--linie); min-width: 132px; }
.preis dt { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--leise); font-weight: 600; }
.preis dd { font: 700 26px/1.1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 32; color: var(--navy); font-variant-numeric: tabular-nums; margin-top: 2px; }
.tipp-knopf {
flex: none; width: 20px; height: 20px; border-radius: 50%; display: inline-grid; place-items: center; font: 700 12px/1 'Atkinson Hyperlegible Next', sans-serif;
color: var(--navy); box-shadow: inset 0 0 0 1.5px var(--navy); transition: background-color .15s linear;
}
.tipp-knopf.ueber, .tipp-knopf[aria-expanded="true"] { background: var(--gelb); }
.tipp {
position: absolute; left: -2px; bottom: calc(100% + 10px); width: 270px; z-index: 4; padding: 12px 14px; border-radius: 12px;
background: var(--navy); color: var(--grund); font-size: 13.5px; line-height: 1.45;
opacity: 0; translate: 0 4px; visibility: hidden; transition: opacity .1s linear, translate .1s linear, visibility 0s .1s;
}
.tipp::after { content: ''; position: absolute; left: 22px; top: 100%; border: 7px solid transparent; border-top-color: var(--navy); }
.tipp.an { opacity: 1; translate: 0 0; visibility: visible; transition: opacity .2s var(--auf), translate .35s var(--feder), visibility 0s; }
.pfeillink { display: inline-flex; align-items: center; gap: 10px; margin-top: clamp(16px, 3vh, 26px); font-weight: 700; font-size: 17px; color: var(--navy); }
.pfeillink .text { background: linear-gradient(var(--navy), var(--navy)) 0 100% / 0 2px no-repeat; padding-bottom: 2px; transition: background-size .15s linear; }
.pfeillink .pfeil { width: 34px; height: 34px; border-radius: 50%; background: var(--gelb); display: grid; place-items: center; }
.pfeillink .pfeil svg { width: 14px; height: 14px; transition: translate .15s linear; }
/* 14islands-Prinzip: der Pfeil reagiert erst nach 200 ms Verweilen, raus sofort */
.pfeillink.ueber .text { background-size: 100% 2px; transition: background-size .35s var(--auf) var(--pfeil-verzug); }
.pfeillink .pfeil { transition: translate .15s linear; }
.pfeillink.ueber .pfeil { translate: 6px 0; transition: translate .35s var(--feder) var(--pfeil-verzug); }
.pfeillink.ueber .pfeil svg { translate: 3px 0; transition: translate .35s var(--feder) var(--pfeil-verzug); }
.pfeillink:focus-visible .text { background-size: 100% 2px; }
.strasse { position: relative; }
.strasse svg { display: block; width: 100%; height: auto; max-height: 52svh; }
/* ── Fahrtenschreiber (Demo-Instrument) ───────────── */
.schreiber { background: var(--nacht); color: var(--grund); padding: 16px var(--rand) 18px; }
.schreiber-innen { max-width: 1180px; margin: 0 auto; display: grid; grid-template-columns: 1fr auto; gap: 12px 24px; align-items: center; }
.schreiber .mono { color: rgba(248, 248, 236, .62); }
.varianten { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: rgba(248, 248, 236, .08); }
.varianten button { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; padding: 8px 12px; border-radius: 999px; color: rgba(248, 248, 236, .7); transition: background-color .15s linear, color .15s linear; }
.varianten button[aria-checked="true"] { background: var(--grund); color: var(--nacht); }
.werte { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; }
.chip { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .06em; padding: 7px 10px; border-radius: 999px; border: 1px solid rgba(248, 248, 236, .18); color: rgba(248, 248, 236, .75); transition: background-color .15s linear, color .15s linear, border-color .15s linear; }
.chip b { font-weight: 600; color: var(--grund); }
.chip.aktiv { background: var(--gelb); border-color: var(--gelb); color: var(--nacht); }
.chip.aktiv b { color: var(--nacht); }
.chip.nach.aktiv { background: var(--himmel); border-color: var(--himmel); }
.spur { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr; gap: 8px; }
.streifen { display: block; width: 100%; height: 66px; }
.status { display: flex; justify-content: space-between; gap: 16px; }
.status [data-status] { color: var(--grund); }
.status [data-quelle] { text-align: right; max-width: 64ch; }
.vorschau .status [data-quelle] { display: none; }
.touch-hinweis { display: none; }
/* ── Vorführung: gezeichneter Zeiger ──────────────── */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 22px; height: 22px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* ── Vorschau 960 × 720 ───────────────────────────── */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .schreiber { padding-top: 12px; padding-bottom: 12px; }
.vorschau .schreiber-innen { gap: 10px 24px; }
.vorschau .buehne { padding-top: 6px; padding-bottom: 14px; }
.vorschau h1 { font-size: 52px; }
.vorschau .lead { font-size: 15.5px; margin-top: 12px; max-width: 30em; }
.vorschau .preise { margin-top: 14px; }
.vorschau .pfeillink { margin-top: 14px; }
.vorschau .eyebrow { margin-bottom: 10px; }
.vorschau .strasse svg { max-height: 330px; }
/* ── Reduzierte Bewegung: Zeitlogik bleibt, Bewegung geht ── */
.reduziert .panel, .reduziert .panel.offen { translate: none; scale: none; }
.reduziert .panel { transition: opacity .15s linear, visibility 0s .15s; }
.reduziert .panel.offen { transition: opacity .15s linear, visibility 0s; }
.reduziert .panel.wechsel .panel-innen, .reduziert .ansicht, .reduziert .panel.wechsel .ansicht { transition: none; translate: none; }
.reduziert .klasse, .reduziert .knopf, .reduziert .ausloeser svg { transition: none; }
.reduziert .klasse.ueber, .reduziert .knopf.ueber { translate: none; scale: none; }
.reduziert .tipp, .reduziert .tipp.an { translate: none; transition: opacity .15s linear, visibility 0s; }
.reduziert .pfeillink.ueber .pfeil svg, .reduziert .pfeillink.ueber .pfeil { translate: none; }
.reduziert .pfeillink.ueber .text { transition: background-size 0s linear var(--pfeil-verzug); }
.reduziert .streifen { display: none; }
/* ── Touch: Tippen öffnet sofort, ohne Verzögerung ── */
@media (hover: none) {
.streifen, .varianten { display: none; }
.touch-hinweis { display: block; }
.pfeillink .text { background-size: 100% 2px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
/* ── Schmal ───────────────────────────────────────── */
@media (max-width: 860px) {
.held { grid-template-columns: 1fr; }
.strasse { order: -1; }
.strasse svg { max-height: 200px; width: auto; margin: 0 auto; }
.ans-klassen, .ans-theorie { grid-template-columns: 1fr; }
.klassen { grid-template-columns: repeat(2, 1fr); }
.ans-orte { grid-template-columns: 1fr; }
.karte { display: none; }
}
@media (max-width: 640px) {
.kopf { padding-top: 14px; padding-bottom: 10px; }
.kopf-innen { grid-template-columns: 1fr auto; gap: 12px; }
.nav { grid-column: 1 / -1; grid-row: 2; }
.nav ul { justify-content: space-between; }
.ausloeser, .navlink { padding: 9px 10px; font-size: 15px; }
.navlink { display: none; }
.knopf { padding: 10px 16px; font-size: 14.5px; }
.ansicht { padding: 16px; }
.ans-kopf p + p { display: none; }
.termin { grid-template-columns: 74px 46px 1fr; font-size: 14px; }
.termin em { display: none; }
.ort em { display: none; }
.ort { grid-template-columns: 26px 1fr; }
.preis { min-width: 0; flex: 1 1 30%; padding: 10px 12px; }
.preis dd { font-size: 22px; }
.tipp { width: min(270px, calc(100vw - 48px)); }
.schreiber-innen { grid-template-columns: 1fr; }
.status { flex-direction: column; gap: 6px; }
}
/* Zugang: 320 px ohne Querscrollen */
@media (max-width: 360px) { .nav ul { gap: 0; } .ausloeser, .navlink { padding: 9px 7px; gap: 4px; font-size: 14.5px; } }
</style>
</head>
<body>
<header class="kopf">
<div class="kopf-innen">
<a class="marke" href="#start" aria-label="Fahrschule Schänzle, Startseite">
<svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="11" fill="#ffd60a"/><path d="M6 28c4-9 9.5-13 14-13s10 4 14 13" fill="none" stroke="#142b50" stroke-width="3.2" stroke-linecap="round"/><path d="M12 28v-5.5M20 28v-9M28 28v-5.5" stroke="#142b50" stroke-width="2.4" stroke-linecap="round"/><path d="M5 31.5h30" stroke="#142b50" stroke-width="3.2" stroke-linecap="round"/></svg>
<span><b>Schänzle</b><small>Fahrschule · Konstanz</small></span>
</a>
<nav class="nav" aria-label="Hauptnavigation">
<ul>
<li><button class="ausloeser" data-menue="klassen" aria-expanded="false" aria-controls="ansicht-klassen">Führerschein <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 3.5 5 7l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></li>
<li><button class="ausloeser" data-menue="orte" aria-expanded="false" aria-controls="ansicht-orte">Standorte <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 3.5 5 7l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></li>
<li><button class="ausloeser" data-menue="theorie" aria-expanded="false" aria-controls="ansicht-theorie">Theorie <svg viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 3.5 5 7l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></li>
<li><a class="navlink" href="#preise" data-hover>Preise</a></li>
</ul>
</nav>
<a class="knopf" href="#anmelden" data-hover>Anmelden</a>
</div>
<div class="panel" data-panel>
<div class="panel-innen" data-panel-innen>
<section class="ansicht" id="ansicht-klassen" data-ansicht="klassen" aria-label="Führerscheinklassen">
<div class="ans-klassen">
<div>
<div class="ans-kopf"><p class="label">Führerscheinklassen</p><p class="label">Preise je 45 Minuten</p></div>
<div class="klassen">
<a class="klasse" href="#b" data-hover><svg viewBox="0 0 46 28" aria-hidden="true"><path d="M4 20v-5l5-1 5-7h16l6 7 5 1.5V20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="12" cy="21" r="3.6" fill="#ffd60a" stroke="currentColor" stroke-width="2.2"/><circle cx="34" cy="21" r="3.6" fill="#ffd60a" stroke="currentColor" stroke-width="2.2"/></svg><span class="code">B</span><span class="was">Auto</span><span class="info">ab 17 · Fahrstunde 68 €</span></a>
<a class="klasse" href="#b197" data-hover><svg viewBox="0 0 46 28" aria-hidden="true"><path d="M4 20v-5l5-1 5-7h16l6 7 5 1.5V20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="12" cy="21" r="3.6" fill="#ffd60a" stroke="currentColor" stroke-width="2.2"/><circle cx="34" cy="21" r="3.6" fill="#ffd60a" stroke="currentColor" stroke-width="2.2"/><text x="23" y="16" text-anchor="middle" font-size="7" font-weight="700" fill="currentColor" font-family="sans-serif">A</text></svg><span class="code">B197</span><span class="was">Automatik</span><span class="info">Automatik-Prüfung, Schalten erlaubt</span></a>
<a class="klasse" href="#a" data-hover><svg viewBox="0 0 46 28" aria-hidden="true"><circle cx="10" cy="20" r="5.5" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="36" cy="20" r="5.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M10 20l8-9h9l4 9M20 11l-2-4h-4M27 11l5-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="23" cy="15" r="2.4" fill="#ffd60a"/></svg><span class="code">A</span><span class="was">Motorrad</span><span class="info">A1, A2, A · Fahrstunde 74 €</span></a>
<a class="klasse" href="#be" data-hover><svg viewBox="0 0 46 28" aria-hidden="true"><path d="M2 20v-4l3-1 3.5-5h10l4 5 3 1v4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="8" cy="21" r="2.8" fill="#ffd60a" stroke="currentColor" stroke-width="2"/><circle cx="21" cy="21" r="2.8" fill="#ffd60a" stroke="currentColor" stroke-width="2"/><path d="M26 18h4M30 10h14v9H30z" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/><circle cx="37" cy="21" r="2.8" fill="#ffd60a" stroke="currentColor" stroke-width="2"/></svg><span class="code">BE</span><span class="was">Anhänger</span><span class="info">bis 3,5 t · Fahrstunde 79 €</span></a>
</div>
</div>
<a class="probe" href="#probe" data-hover>
<span><strong>Probestunde am Samstag</strong><p>45 Minuten auf dem Übungsplatz in Wollmatingen, ganz ohne Verkehr. 39 €.</p></span>
<span class="mini">Termin wählen <svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6h9M6.5 2.5 10 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
</a>
</div>
</section>
<section class="ansicht" id="ansicht-orte" data-ansicht="orte" aria-label="Standorte">
<div class="ans-orte">
<svg class="karte" viewBox="0 0 320 190" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect width="320" height="190" fill="#eef0d8"/>
<path d="M0 118c40-6 70 6 104 2s58-18 92-16 70 16 124 6v80H0z" fill="#3fbadf" opacity=".35"/>
<path d="M-10 128c46-4 78 4 112 0s58-16 94-14 66 12 134 4" fill="none" stroke="#3fbadf" stroke-width="16" stroke-linecap="round"/>
<path d="M40 0c18 40 30 80 26 190M200 0c-6 50 4 90 30 190M0 60c80 10 170 0 320 14" fill="none" stroke="#fffffb" stroke-width="6" stroke-linecap="round"/>
<path d="M118 112c8-14 18-20 30-20s22 6 30 20" fill="none" stroke="#142b50" stroke-width="2.4"/>
<g font-family="sans-serif" font-weight="700" font-size="11" fill="#fffffb" text-anchor="middle">
<circle cx="150" cy="58" r="11" fill="#142b50"/><text x="150" y="62">1</text>
<circle cx="104" cy="160" r="11" fill="#142b50"/><text x="104" y="164">2</text>
<circle cx="262" cy="42" r="11" fill="#142b50"/><text x="262" y="46">3</text>
</g>
<text x="16" y="178" font-family="sans-serif" font-size="9" font-weight="700" letter-spacing="1.2" fill="#142b50" opacity=".7">SEERHEIN</text>
</svg>
<ul class="orte">
<li><a class="ort" href="#petershausen" data-hover><i>1</i><span><b>Petershausen</b><span>Zähringerplatz 9 · Theorieraum</span></span><em>Mo–Do 15–18 Uhr</em></a></li>
<li><a class="ort" href="#paradies" data-hover><i>2</i><span><b>Paradies</b><span>Gottlieber Straße 31</span></span><em>Di + Do 16–19 Uhr</em></a></li>
<li><a class="ort" href="#wollmatingen" data-hover><i>3</i><span><b>Wollmatingen</b><span>Übungsplatz und Büro</span></span><em>Mi 15–18 Uhr</em></a></li>
</ul>
</div>
</section>
<section class="ansicht" id="ansicht-theorie" data-ansicht="theorie" aria-label="Theorie-Termine">
<div class="ans-theorie">
<div>
<div class="ans-kopf"><p class="label">Nächste Theorie</p><p class="label">Einstieg jederzeit</p></div>
<ul class="termine">
<li class="termin"><b>Mo 02.11.</b><span>18:30</span><span>Petershausen · Grundstoff 7</span><em>6 frei</em></li>
<li class="termin"><b>Mi 04.11.</b><span>18:30</span><span>Petershausen · Grundstoff 8</span><em>4 frei</em></li>
<li class="termin"><b>Do 05.11.</b><span>17:00</span><span>Paradies · Zusatzstoff A</span><em>8 frei</em></li>
<li class="termin"><b>Sa 07.11.</b><span>10:00</span><span>Wollmatingen · Doppelblock</span><em class="knapp">2 frei</em></li>
</ul>
</div>
<div class="sprachen">
<p class="label">Unterricht auch auf</p>
<ul><li>Englisch</li><li>Türkisch</li><li>Italienisch</li></ul>
<p>Ferienkurs Grundstoff kompakt: 21. bis 30.12.</p>
</div>
</div>
</section>
</div>
</div>
</header>
<main class="buehne" id="start">
<div class="held">
<div>
<p class="label eyebrow">Fahrschule in Konstanz · seit 2009</p>
<h1>Führerschein am See. <mark>Ohne Herzklopfen.</mark></h1>
<p class="lead">Klasse B, B197, A und BE in Petershausen, im Paradies und in Wollmatingen. Theorie zweimal pro Woche, Fahrstunden auch samstags.</p>
<dl class="preise" id="preise">
<div class="preis">
<dt>Grundbetrag <button class="tipp-knopf" type="button" data-tipp aria-describedby="tipp-grund" aria-expanded="false" aria-label="Was ist im Grundbetrag enthalten?">i</button><span class="tipp" id="tipp-grund" role="tooltip">Anmeldung, 14 Doppelstunden Theorie, Lern-App und Unterlagen. Die Prüfgebühren des TÜV kommen dazu.</span></dt>
<dd>390 €</dd>
</div>
<div class="preis"><dt>Fahrstunde, 45 min</dt><dd>68 €</dd></div>
<div class="preis"><dt>Sonderfahrt</dt><dd>85 €</dd></div>
</dl>
<a class="pfeillink" href="#ablauf" data-hover><span class="text">In fünf Schritten zum Führerschein</span><span class="pfeil"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7h11M7.5 2.5 12 7l-4.5 4.5" fill="none" stroke="#142b50" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></span></a>
</div>
<figure class="strasse" aria-hidden="true">
<svg viewBox="0 0 440 470">
<rect x="0" y="0" width="440" height="470" rx="30" fill="#eef0d8"/>
<clipPath id="karte-clip"><rect x="0" y="0" width="440" height="470" rx="30"/></clipPath>
<g clip-path="url(#karte-clip)">
<path d="M440 268c-58-4-104 28-130 78s-70 92-150 124h280z" fill="#3fbadf"/>
<path d="M330 372c18-6 34-6 52 0M300 420c22-7 44-7 66 0M362 330c14-5 28-5 42 0" fill="none" stroke="#f8f8ec" stroke-width="3" stroke-linecap="round" opacity=".8"/>
<path d="M-30 404c110-6 164-58 190-128 32-86 92-124 176-140 52-10 88-38 124-92" fill="none" stroke="#142b50" stroke-width="54" stroke-linecap="round"/>
<path d="M-30 404c110-6 164-58 190-128 32-86 92-124 176-140 52-10 88-38 124-92" fill="none" stroke="#f8f8ec" stroke-width="3.5" stroke-dasharray="16 14"/>
<g fill="#142b50"><circle cx="66" cy="96" r="22"/><circle cx="100" cy="74" r="15"/><circle cx="54" cy="150" r="12"/><circle cx="380" cy="230" r="14"/><circle cx="252" cy="62" r="11"/></g>
<g transform="translate(206 222) rotate(-56)">
<rect x="-17" y="-28" width="34" height="56" rx="10" fill="#fffffb" stroke="#0e1d38" stroke-width="2.5"/>
<rect x="-12" y="-17" width="24" height="10" rx="3" fill="#0e1d38"/>
<rect x="-12" y="12" width="24" height="7" rx="2.5" fill="#0e1d38" opacity=".7"/>
<rect x="-9" y="-3" width="18" height="10" rx="2" fill="#ffd60a" stroke="#0e1d38" stroke-width="1.6"/>
</g>
</g>
<g transform="translate(250 92)">
<rect width="158" height="58" rx="14" fill="#fffffb" stroke="rgba(20,43,80,.14)"/>
<text x="16" y="24" font-family="Atkinson Hyperlegible Next, sans-serif" font-size="11.5" font-weight="700" letter-spacing=".9" fill="#566175">NÄCHSTE THEORIE</text>
<text x="16" y="45" font-family="Bricolage Grotesque, sans-serif" font-size="17" font-weight="700" fill="#142b50">Mo, 18:30 Uhr</text>
</g>
<g transform="translate(28 300)">
<rect width="120" height="34" rx="8" fill="#ffd60a" stroke="#142b50" stroke-width="2"/>
<rect x="6" y="6" width="14" height="22" rx="3" fill="#142b50"/>
<text x="70" y="23" text-anchor="middle" font-family="Bricolage Grotesque, sans-serif" font-size="16" font-weight="700" letter-spacing="1" fill="#142b50">KN · FS 26</text>
</g>
</svg>
</figure>
</div>
</main>
<aside class="schreiber" aria-label="Zeitlogik dieser Demo">
<div class="schreiber-innen">
<p class="mono">Fahrtenschreiber · Hover-Zeitlogik</p>
<div class="varianten" role="radiogroup" aria-label="Variante">
<button type="button" role="radio" aria-checked="true" data-variante="timer">Timer + Skip</button>
<button type="button" role="radio" aria-checked="false" data-variante="tempo">Tempo</button>
<button type="button" role="radio" aria-checked="false" data-variante="ohne">Ohne</button>
</div>
<div class="werte" data-werte></div>
<div class="spur">
<canvas class="streifen" data-streifen aria-hidden="true"></canvas>
<div class="status"><p class="mono" data-status aria-live="polite">Zeiger auf ein Menü legen</p><p class="mono" data-quelle></p></div>
<p class="mono touch-hinweis">Touch: Tippen öffnet sofort, ohne Verzögerung. Ein zweites Tippen oder Tippen daneben schließt.</p>
</div>
</div>
</aside>
<svg class="zeiger" data-zeiger viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2 18.5 11l-7 1.5-3.4 6.6Z" fill="#142b50" stroke="#f8f8ec" stroke-width="1.4" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, w = document) => w.querySelector(s), $$ = (s, w = document) => [...w.querySelectorAll(s)];
// ── Varianten: tatsächlich verwendete Werte ─────────────────────
const VARIANTEN = {
timer: { absicht: 100, nachlauf: 150, skip: true, tipp: 250, pfeil: 200,
chips: [['absicht', 'Absicht', '100 ms'], ['nachlauf', 'Nachlauf', '150 ms'], ['skip', 'Skip-Delay', '0 ms'], ['tipp', 'Tooltip', '250 / 0 ms'], ['pfeil', 'Pfeil', '200 ms']],
quelle: 'Menü 100 ms wie Stripe · Tooltip 250 ms wie PostHog · Pfeil 200 ms wie 14islands' },
tempo: { absicht: 'tempo', abtasten: 50, schwelle: 5, nachlauf: 150, skip: true, tipp: 250, pfeil: 200,
chips: [['absicht', 'Absicht', '< 5 px je 50 ms'], ['nachlauf', 'Nachlauf', '150 ms'], ['skip', 'Skip-Delay', '0 ms'], ['tipp', 'Tooltip', '250 / 0 ms'], ['pfeil', 'Pfeil', '200 ms']],
quelle: 'Öffnet erst, wenn der Zeiger langsamer als 100 px/s wird' },
ohne: { absicht: 0, nachlauf: 0, skip: false, tipp: 0, pfeil: 0,
chips: [['absicht', 'Absicht', '0 ms'], ['nachlauf', 'Nachlauf', '0 ms'], ['skip', 'Skip-Delay', 'aus'], ['tipp', 'Tooltip', '0 / 0 ms'], ['pfeil', 'Pfeil', '0 ms']],
quelle: 'Zum Vergleich: alles sofort, so flackert es beim Drüberstreifen' },
};
let variante = 'timer', cfg = VARIANTEN.timer;
const ausloeser = $$('[data-menue]');
const panel = $('[data-panel]'), panelInnen = $('[data-panel-innen]');
const ansichten = Object.fromEntries($$('[data-ansicht]').map(a => [a.dataset.ansicht, a]));
const reihenfolge = ['klassen', 'orte', 'theorie'];
const namen = { klassen: 'Führerschein', orte: 'Standorte', theorie: 'Theorie' };
const tippKnopf = $('[data-tipp]'), tipp = $('#tipp-grund');
const werteEl = $('[data-werte]'), statusEl = $('[data-status]'), quelleEl = $('[data-quelle]');
const zeiger = $('[data-zeiger]');
// ── Fahrtenschreiber: Segmente für den Streifen ─────────────────
const FENSTER = 5000;
let segmente = [];
const seg = (spur, art, text) => { const s = { spur, art, text, t0: performance.now(), t1: null }; segmente.push(s); malenAnstossen(); return s; };
const ende = s => { if (s && s.t1 == null) { s.t1 = performance.now(); malenAnstossen(); } return null; };
function chipsZeigen() {
werteEl.innerHTML = cfg.chips.map(([k, n, w]) => `<span class="chip${k === 'nachlauf' ? ' nach' : ''}" data-chip="${k}">${n} <b>${w}</b></span>`).join('');
quelleEl.textContent = cfg.quelle;
html.style.setProperty('--absicht', (typeof cfg.absicht === 'number' ? cfg.absicht : 0) + 'ms');
html.style.setProperty('--pfeil-verzug', cfg.pfeil + 'ms');
}
const chipTimer = {};
function chip(k, an, kurz) {
const c = werteEl.querySelector(`[data-chip="${k}"]`); if (!c) return;
clearTimeout(chipTimer[k]);
c.classList.toggle('aktiv', an);
if (an && kurz) chipTimer[k] = setTimeout(() => c.classList.remove('aktiv'), kurz);
}
// Wichtige Meldungen bleiben kurz stehen; was währenddessen kommt, folgt danach
let statusSperre = 0, statusT = null, statusNach = null;
function status(t, halten = 0) {
const jetzt = performance.now();
if (jetzt < statusSperre && !halten) {
statusNach = t; clearTimeout(statusT);
statusT = setTimeout(() => { if (statusNach) statusEl.textContent = statusNach; statusNach = null; statusSperre = 0; }, statusSperre - jetzt);
return;
}
clearTimeout(statusT); statusNach = null;
statusEl.textContent = t;
statusSperre = halten ? jetzt + halten : 0;
}
// ── Zustand des Menüs ───────────────────────────────────────────
let offen = null, geoeffnetUm = 0, warteId = null, warteT = null, tempoT = null, schliessT = null;
let segZeiger = null, segMenue = null, segWarte = null, segNach = null, segTipp = null, segTippWarte = null;
let pos = { x: 0, y: 0 }, letzteProbe = null, oeffnungen = [];
function hoeheSetzen(id) { panelInnen.style.setProperty('--h', ansichten[id].offsetHeight + 'px'); }
function oeffnen(id, art) {
abbrechenWarten(false);
clearTimeout(schliessT); segNach = ende(segNach); chip('nachlauf', false);
if (offen === id) return;
const vorher = offen, jetzt = performance.now();
offen = id; geoeffnetUm = jetzt;
const richtung = vorher ? Math.sign(reihenfolge.indexOf(id) - reihenfolge.indexOf(vorher)) : 0;
panel.classList.toggle('wechsel', !!vorher);
Object.entries(ansichten).forEach(([k, a]) => {
a.style.setProperty('--von', (k === id ? 24 * richtung : -24 * richtung) + 'px');
if (k !== id) a.classList.remove('an');
});
void panel.offsetWidth;
ansichten[id].classList.add('an');
hoeheSetzen(id);
panel.classList.add('offen');
ausloeser.forEach(b => b.setAttribute('aria-expanded', String(b.dataset.menue === id)));
segMenue = ende(segMenue); segMenue = seg(1, 'offen', (art === 'skip' ? 'Skip · ' : '') + namen[id]); segMenue.skip = art === 'skip';
oeffnungen = oeffnungen.filter(t => jetzt - t < 450); oeffnungen.push(jetzt);
if (art === 'skip') { chip('skip', true, 700); status(`Skip-Delay: ${namen[id]} sofort, ohne neue Wartezeit`, 500); }
else if (art === 'fokus') status(`${namen[id]} per Fokus geöffnet, ohne Verzögerung`);
else if (art === 'tippen') status(`${namen[id]} per Tippen geöffnet`);
else if (variante === 'ohne') status(oeffnungen.length > 1 ? `Flackern: ${oeffnungen.length} Menüs in ${Math.round(jetzt - oeffnungen[0])} ms geöffnet` : `${namen[id]} sofort geöffnet`, oeffnungen.length > 1 ? 1400 : 0);
else if (variante === 'tempo') status(`${namen[id]} geöffnet: Zeiger ist langsam geworden`);
else status(`${namen[id]} geöffnet nach ${cfg.absicht} ms Verweilen`);
}
function schliessen(grund) {
clearTimeout(schliessT); segNach = ende(segNach); chip('nachlauf', false);
if (!offen) return;
const war = offen; offen = null;
panel.classList.remove('offen', 'wechsel');
ausloeser.forEach(b => b.setAttribute('aria-expanded', 'false'));
segMenue = ende(segMenue);
if (grund === 'nachlauf') status(`${namen[war]} geschlossen nach ${cfg.nachlauf} ms Nachlauf`);
else if (variante === 'ohne' && !grund) status(`${namen[war]} sofort wieder zu`);
}
function abbrechenWarten(meldung) {
if (warteId == null) return;
clearTimeout(warteT); clearInterval(tempoT);
const b = ausloeser.find(x => x.dataset.menue === warteId); b && b.classList.remove('wartet', 'tempo');
if (segWarte) { segWarte.abgebrochen = meldung; ende(segWarte); segWarte = null; }
chip('absicht', false);
if (meldung) status(`${namen[warteId]} nur gestreift: nichts geöffnet`, 500);
warteId = null;
}
function schliessenPlanen() {
clearTimeout(schliessT);
if (!offen) return;
if (!cfg.nachlauf) return schliessen();
segNach = ende(segNach); segNach = seg(1, 'nachlauf', `Nachlauf ${cfg.nachlauf}`);
chip('nachlauf', true);
status(`Nachlauf ${cfg.nachlauf} ms: der Weg ins Menü bleibt frei`);
schliessT = setTimeout(() => schliessen('nachlauf'), cfg.nachlauf);
}
function menueBetreten(id) {
clearTimeout(schliessT); segNach = ende(segNach); chip('nachlauf', false);
if (offen === id) return;
if (offen && cfg.skip) return oeffnen(id, 'skip');
if (cfg.absicht === 0) return oeffnen(id);
const b = ausloeser.find(x => x.dataset.menue === id);
warteId = id;
segWarte = seg(1, 'warte', variante === 'tempo' ? 'Tempo' : `Absicht ${cfg.absicht}`);
chip('absicht', true);
if (cfg.absicht === 'tempo') {
b.classList.add('tempo');
status(`${namen[id]}: wartet, bis der Zeiger langsamer wird`);
letzteProbe = { ...pos };
tempoT = setInterval(() => {
const d = Math.hypot(pos.x - letzteProbe.x, pos.y - letzteProbe.y);
letzteProbe = { ...pos };
if (d < cfg.schwelle) oeffnen(id);
}, cfg.abtasten);
} else {
b.classList.add('wartet');
status(`${namen[id]}: Absicht läuft (${cfg.absicht} ms)`);
warteT = setTimeout(() => oeffnen(id), cfg.absicht);
}
}
function menueVerlassen() {
abbrechenWarten(true);
if (offen) schliessenPlanen();
}
function panelBetreten() {
if (schliessT) { clearTimeout(schliessT); if (segNach) status('Im Menü angekommen: bleibt offen'); }
segNach = ende(segNach); chip('nachlauf', false);
}
// ── Tooltip (250 ms rein, sofort raus) und Pfeil (200 ms) ───────
let tippT = null;
function tippZeigen(sofort) {
clearTimeout(tippT); segTippWarte = ende(segTippWarte);
if (tipp.classList.contains('an')) return;
const an = () => {
segTippWarte = ende(segTippWarte); chip('tipp', false);
tipp.classList.add('an'); tippKnopf.setAttribute('aria-expanded', 'true');
segTipp = ende(segTipp); segTipp = seg(2, 'offen', 'Tooltip');
if (!sofort) status(cfg.tipp ? `Tooltip nach ${cfg.tipp} ms Verweilen` : 'Tooltip sofort');
};
if (sofort || !cfg.tipp) return an();
segTippWarte = seg(2, 'warte', `Tooltip ${cfg.tipp}`); chip('tipp', true);
tippT = setTimeout(an, cfg.tipp);
}
function tippVerstecken() {
clearTimeout(tippT); chip('tipp', false);
if (segTippWarte) { segTippWarte.abgebrochen = true; segTippWarte = ende(segTippWarte); }
if (!tipp.classList.contains('an')) return;
tipp.classList.remove('an'); tippKnopf.setAttribute('aria-expanded', 'false');
segTipp = ende(segTipp);
}
function pfeilAn() {
chip('pfeil', true, cfg.pfeil || 150);
const s = cfg.pfeil ? seg(2, 'warte', `Pfeil ${cfg.pfeil}`) : null;
setTimeout(() => { ende(s); if (aktHover.has(pfeilLink)) { seg(2, 'blitz', 'Pfeil').t1 = performance.now() + 380; status(cfg.pfeil ? `Pfeil reagiert nach ${cfg.pfeil} ms` : 'Pfeil sofort'); } }, cfg.pfeil);
}
const pfeilLink = $('.pfeillink');
// ── Ein Zeiger, zwei Quellen (Maus oder Vorführung) ─────────────
let aktHover = new Set(), aktZone = null, imTipp = false;
function zoneVon(el) {
if (!el) return null;
const m = el.closest('[data-menue]'); if (m) return 'm:' + m.dataset.menue;
if (offen && el.closest('[data-panel]')) return 'panel';
return null;
}
function zeigerBei(el) {
// Hover-Klassen für alles mit [data-hover] und die Auslöser
const neu = new Set();
for (let e = el; e && e !== document.body; e = e.parentElement) if (e.hasAttribute && (e.hasAttribute('data-hover') || e.hasAttribute('data-menue'))) neu.add(e);
aktHover.forEach(e => { if (!neu.has(e)) e.classList.remove('ueber'); });
neu.forEach(e => { if (!aktHover.has(e)) { e.classList.add('ueber'); if (e === pfeilLink) pfeilAn(); } });
aktHover = neu;
// Tooltip
const t = !!(el && el.closest('[data-tipp]'));
if (t !== imTipp) { imTipp = t; t ? tippZeigen() : tippVerstecken(); }
// Menü
const z = zoneVon(el);
if (z === aktZone) return;
const alt = aktZone; aktZone = z;
if (alt && alt.startsWith('m:')) { segZeiger = ende(segZeiger); menueVerlassen(); }
else if (alt === 'panel') { segZeiger = ende(segZeiger); schliessenPlanen(); }
if (z && z.startsWith('m:')) { segZeiger = seg(0, 'zeiger', namen[z.slice(2)]); menueBetreten(z.slice(2)); }
else if (z === 'panel') { segZeiger = seg(0, 'panel', 'Menü'); panelBetreten(); }
}
// Maus
let vorfuehrend = false, startMaus = null;
document.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse') return;
if (vorfuehrend) {
if (!startMaus) startMaus = { x: e.clientX, y: e.clientY };
else if (Math.hypot(e.clientX - startMaus.x, e.clientY - startMaus.y) > 40) uebernehmen();
return;
}
pos.x = e.clientX; pos.y = e.clientY;
zeigerBei(e.target);
});
document.documentElement.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !vorfuehrend) zeigerBei(null); });
// Tastatur, Klick, Touch
ausloeser.forEach(b => {
b.addEventListener('focus', () => { if (b.matches(':focus-visible')) oeffnen(b.dataset.menue, 'fokus'); });
b.addEventListener('click', () => {
const id = b.dataset.menue;
if (offen === id && performance.now() - geoeffnetUm > 400) schliessen('klick');
else oeffnen(id, matchMedia('(hover: none)').matches ? 'tippen' : 'fokus');
});
});
tippKnopf.addEventListener('focus', () => { if (tippKnopf.matches(':focus-visible')) tippZeigen(true); });
tippKnopf.addEventListener('blur', () => { if (!imTipp) tippVerstecken(); });
tippKnopf.addEventListener('click', () => tipp.classList.contains('an') && matchMedia('(hover: none)').matches ? tippVerstecken() : tippZeigen(true));
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (tipp.classList.contains('an')) { tippVerstecken(); return; }
if (offen) { const b = ausloeser.find(x => x.dataset.menue === offen); schliessen('esc'); b && b.focus({ preventScroll: true }); status('Mit Escape geschlossen'); }
});
document.addEventListener('focusin', e => { if (offen && !e.target.closest('.kopf')) schliessen('fokus'); });
document.addEventListener('click', e => {
if (e.target.closest('a')) e.preventDefault();
if (offen && !e.target.closest('.kopf')) schliessen('klick');
if (tipp.classList.contains('an') && !e.target.closest('.preis')) tippVerstecken();
});
// Varianten
const variantenKnoepfe = $$('[data-variante]');
function varianteSetzen(v) {
variante = v; cfg = VARIANTEN[v];
variantenKnoepfe.forEach(b => b.setAttribute('aria-checked', String(b.dataset.variante === v)));
abbrechenWarten(false); schliessen('variante'); tippVerstecken();
chipsZeigen();
status(v === 'ohne' ? 'Ohne Zeitlogik: zum Vergleich drüberstreifen' : v === 'tempo' ? 'Tempo: öffnet, wenn der Zeiger abbremst' : 'Zeiger auf ein Menü legen');
}
variantenKnoepfe.forEach(b => b.addEventListener('click', () => varianteSetzen(b.dataset.variante)));
// ── Streifen zeichnen ───────────────────────────────────────────
const cv = $('[data-streifen]'), cx = cv.getContext('2d');
let malT = null, cvB = 0, cvH = 0;
function groesse() {
const r = cv.getBoundingClientRect(), d = Math.min(2, devicePixelRatio || 1);
cvB = r.width; cvH = r.height; cv.width = Math.round(r.width * d); cv.height = Math.round(r.height * d);
cx.setTransform(d, 0, 0, d, 0, 0); malen();
}
function schraffur(farbe) {
const p = document.createElement('canvas'); p.width = p.height = 8;
const c = p.getContext('2d'); c.strokeStyle = farbe; c.lineWidth = 2;
c.beginPath(); c.moveTo(-2, 10); c.lineTo(10, -2); c.moveTo(-2, 2); c.lineTo(2, -2); c.moveTo(6, 10); c.lineTo(10, 6); c.stroke();
return cx.createPattern(p, 'repeat');
}
let mGelb, mHimmel;
function malen() {
if (!cvB) return;
const jetzt = performance.now(), L = 78, B = cvB - L, sh = 16, sg = 7;
const spurY = i => 2 + i * (sh + sg);
cx.clearRect(0, 0, cvB, cvH);
mGelb = mGelb || schraffur('#ffd60a'); mHimmel = mHimmel || schraffur('#3fbadf');
cx.font = '500 9.5px "Geist Mono", ui-monospace, monospace';
cx.textBaseline = 'middle';
['ZEIGER', 'MENÜ', 'DETAIL'].forEach((n, i) => {
cx.fillStyle = 'rgba(248,248,236,.5)'; cx.fillText(n, 0, spurY(i) + sh / 2);
cx.fillStyle = 'rgba(248,248,236,.06)'; cx.fillRect(L, spurY(i), B, sh);
});
// Sekundenraster
cx.fillStyle = 'rgba(248,248,236,.12)';
for (let s = Math.ceil((jetzt - FENSTER) / 1000) * 1000; s <= jetzt; s += 1000) { const x = L + B - (jetzt - s) / FENSTER * B; cx.fillRect(x, 0, 1, cvH - 4); }
segmente = segmente.filter(s => s.t1 == null || jetzt - s.t1 < FENSTER + 500);
for (const s of segmente) {
const t1 = s.t1 == null ? jetzt : s.t1;
let x0 = L + B - (jetzt - s.t0) / FENSTER * B, x1 = L + B - (jetzt - t1) / FENSTER * B;
if (x1 < L) continue; x0 = Math.max(L, x0);
const y = spurY(s.spur), w = Math.max(2, x1 - x0);
let text = s.text, tf = '#0e1d38';
const tx = s.skip ? 10 : 6;
if (s.art === 'zeiger') { cx.fillStyle = '#3fbadf'; cx.fillRect(x0, y, w, sh); }
else if (s.art === 'panel') { cx.strokeStyle = '#3fbadf'; cx.lineWidth = 1.5; cx.strokeRect(x0 + .75, y + .75, w - 1.5, sh - 1.5); tf = '#3fbadf'; }
else if (s.art === 'offen') { cx.fillStyle = '#f8f8ec'; cx.fillRect(x0, y, w, sh); if (s.skip && x0 > L) { cx.fillStyle = '#ffd60a'; cx.fillRect(x0, y - 3, 4, sh + 6); } }
else if (s.art === 'warte') { cx.fillStyle = mGelb; cx.fillRect(x0, y, w, sh); cx.fillStyle = s.abgebrochen ? 'rgba(14,29,56,.55)' : 'rgba(14,29,56,.15)'; cx.fillRect(x0, y, w, sh); tf = '#ffd60a'; text = s.abgebrochen ? '' : text; }
else if (s.art === 'nachlauf') { cx.fillStyle = mHimmel; cx.fillRect(x0, y, w, sh); tf = '#3fbadf'; }
else if (s.art === 'blitz') { cx.fillStyle = '#ffd60a'; cx.fillRect(x0, y, Math.min(w, 4), sh); continue; }
if (text && w > 30) {
cx.save(); cx.beginPath(); cx.rect(x0, y, w, sh); cx.clip();
if (s.art === 'warte' || s.art === 'nachlauf') { cx.fillStyle = '#0e1d38'; const tw = cx.measureText(text.toUpperCase()).width + 8; cx.fillRect(x0 + 2, y + 2, Math.min(tw, w - 4), sh - 4); }
cx.fillStyle = tf; cx.fillText(text.toUpperCase(), x0 + tx, y + sh / 2 + .5); cx.restore();
}
}
// Jetzt-Linie
cx.fillStyle = 'rgba(248,248,236,.5)'; cx.fillRect(L + B - 1, 0, 1, cvH - 4);
}
let letzteAktivitaet = 0;
function malenAnstossen() {
letzteAktivitaet = performance.now();
if (reduziert() || malT) return;
const schleife = () => {
malen();
if (performance.now() - letzteAktivitaet < FENSTER + 600 || offen || vorfuehrend) malT = requestAnimationFrame(schleife);
else malT = null;
};
malT = requestAnimationFrame(schleife);
}
if ('ResizeObserver' in window) new ResizeObserver(groesse).observe(cv); else addEventListener('resize', groesse);
// ── Vorführung: gedachter Zeiger fährt eine feste Route ─────────
const R = el => el.getBoundingClientRect();
const mitte = (el, fx = .5, fy = .5) => { const r = R(el); return { x: r.left + r.width * fx, y: r.top + r.height * fy }; };
const glatt = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const kopf = $('.kopf'), preisLink = $('.navlink'), probeKarte = $('.klasse[href="#b197"]');
// [Zeitpunkt ms, Ziel, Bewegung]
const route = () => {
const tr = ausloeser, k = R(kopf);
const navY = mitte(tr[0]).y;
const rechts = preisLink.offsetParent ? R(preisLink).right + 16 : R(tr[2]).right + 40;
return [
[0, () => mitte($('.strasse'), .45, .62), 'glatt'],
[700, () => ({ x: R(tr[0]).left - 36, y: navY + 2 }), 'glatt'],
[920, () => ({ x: rechts, y: navY - 2 }), 'linear'], // schnell drüberstreifen
[1350, () => ({ x: mitte(tr[0]).x + 50, y: k.bottom + 64 }), 'glatt'], // unten herum zurück
[1700, () => mitte(tr[0], .42, .55), 'glatt'], // auf „Führerschein“ verweilen
[2500, () => mitte(tr[0], .42, .55), 'halt'],
[2950, () => mitte(probeKarte, .55, .5), 'glatt'], // schräg durch die Lücke ins Menü
[3750, () => mitte(probeKarte, .55, .5), 'halt'],
[4200, () => mitte(tr[1], .5, .6), 'glatt'], // hoch zu „Standorte“: Skip
[4900, () => mitte(tr[1], .5, .6), 'halt'],
[5150, () => mitte(tr[2], .5, .6), 'glatt'], // weiter zu „Theorie“: Skip
[5900, () => mitte(tr[2], .5, .6), 'halt'],
[6700, () => mitte(tippKnopf), 'glatt'], // raus, Nachlauf, zum Tooltip
[7650, () => mitte(tippKnopf), 'halt'],
[8050, () => mitte($('.pfeillink .text'), .7, .5), 'glatt'],
[8900, () => mitte($('.pfeillink .text'), .7, .5), 'halt'],
[9600, () => mitte($('.strasse'), .45, .62), 'glatt'],
];
};
const RUNDE = 10000, FOLGE = ['timer', 'ohne', 'tempo'];
let uhr = null, rundeStart = 0, pausiertBei = null, runde = 0;
function zeigerSetzen(p) {
pos.x = p.x; pos.y = p.y;
zeiger.style.transform = `translate(${p.x - 3}px, ${p.y - 2}px)`;
zeigerBei(document.elementFromPoint(p.x, p.y));
}
function schritt(t) {
let e = t - rundeStart;
if (e >= RUNDE) {
runde++; rundeStart = t; e = 0;
varianteSetzen(FOLGE[(FOLGE.indexOf(variante) + 1) % FOLGE.length]);
}
const r = route();
let i = r.findIndex(([z]) => z > e);
let p;
if (i === -1) p = r[r.length - 1][1]();
else if (i === 0) p = r[0][1]();
else {
const [za, fa] = r[i - 1], [zb, fb, art] = r[i];
const a = fa(), b = fb(), q = (e - za) / (zb - za);
const k = art === 'linear' ? q : art === 'halt' ? 0 : glatt(q);
p = { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
}
zeigerSetzen(p);
uhr = requestAnimationFrame(schritt);
}
function vorfuehren() {
pause();
startMaus = null; vorfuehrend = true; html.classList.add('vorfuehrung');
zeigerBei(null);
rundeStart = performance.now(); pausiertBei = null;
malenAnstossen();
uhr = requestAnimationFrame(schritt);
}
function pause() { if (uhr) { cancelAnimationFrame(uhr); uhr = null; pausiertBei = performance.now(); } }
function weiter() {
if (uhr || !vorfuehrend) return;
if (pausiertBei != null) rundeStart += performance.now() - pausiertBei;
pausiertBei = null; uhr = requestAnimationFrame(schritt);
}
function uebernehmen() { // echte Maus übernimmt: Vorführung endet, Variante bleibt
pause(); vorfuehrend = false; html.classList.remove('vorfuehrung'); pausiertBei = null;
zeigerBei(null); status('Jetzt selbst ausprobieren');
}
function neustart() {
pause(); vorfuehrend = false; html.classList.remove('vorfuehrung'); pausiertBei = null;
zeigerBei(null); abbrechenWarten(false); schliessen('neustart'); tippVerstecken();
segmente = []; oeffnungen = [];
varianteSetzen(variante); malen();
}
chipsZeigen(); groesse();
document.fonts && document.fonts.ready.then(() => { groesse(); if (offen) hoeheSetzen(offen); });
addEventListener('resize', () => { if (offen) hoeheSetzen(offen); });
})();
</script>
</body>
</html>