Magnetische Buttons und Elemente
Buttons und Icons ziehen sich zum Zeiger hin, der Text folgt versetzt und federt beim Verlassen zurück.
Ein Button oder Icon zieht sich zum Zeiger hin, sobald dieser nahe kommt. Das Element wandert einen Teil des Abstands zwischen Zeiger und Mitte mit. Der Text darin bewegt sich schwächer oder gegenläufig, das erzeugt Tiefe. Beim Verlassen federt das Element elastisch zurück. Oft kombiniert mit einer Füllung oder einem Text-Roll beim Eintritt.
Der Button scheint geklickt werden zu wollen. Die Anziehung wirkt physisch und taktil, das Element fühlt sich lebendig an. Das ist dezenter als ein eigener Cursor und kommt ohne Ersatz für den System-Cursor aus. Seit Cuberto ist es ein Signatur-Detail kreativer Studios.
Wann einsetzen
Primäre CTAs, runde Icon-Buttons, Social-Links, Menü-Button, Scroll-Hinweise. Sparsam, ein bis drei pro Ansicht.
Wann nicht
Dichte Formulare und Formular-Buttons, Navigationen mit vielen eng stehenden Links, Fließtext-Links, alles mit Präzisionsklick. Dort springen die Ziele weg.
Varianten
- Gezogen mit GSAP (Dennis Snellenberg): Versatz = (relative Zeigerposition − 0,5) × Stärke, Stärke 24 bis 50 px, Text 12 bis 25 px; hin 1,5 s power4.out, zurück 1,5 s elastic.out(1, 0.3)
- Radius mit Lerp ohne Bibliothek (Codrops): Anziehung ab 0,7 × Breite um die Mitte, Versatz 0,3 × Abstand, Lerp 0,1, Text gegenläufig × 0,6
- Feder (Antinomy, Framer Motion useSpring 600/100/0,2) oder Olivier Larose (Spring 150/15/0,1 bzw. GSAP quickTo 1 s elastic.out(1, 0.3)): Element folgt der vollen Distanz zur Mitte
- Mit Deko-Kreis, der beim Eintritt auf 1,3 skaliert (Codrops)
- Mit 3D-Kippen (rotateX/rotateY) zusätzlich zur Verschiebung (Codrops, Radius 0,8 × Breite)
- Seitenhintergrund wechselt beim Hover die Farbe (Codrops)
- Wackelnde Rückkehr mit CustomWiggle (GSAP-Demo)
- Cursor wird mit angezogen oder klebt am Button (siehe kontextueller-cursor, sticky-morph-cursor)
- Auf beliebige Elemente angewandt: Icons, Social-Links, Menüpunkte
Umsetzung
Das <a> oder <button> bleibt als feste Trefferfläche stehen und trägt den Fokus. Bewegt wird nur eine innere Hülle, der Text darin noch einmal eigens. Sonst wandert die Trefferfläche mit und das Element zittert an der Kante. Rect bei jedem Bild oder bei Scroll und Resize neu messen.
Lerp-Variante: d = hypot(mx - cx, my - cy); if (d < rect.width * .7) { tx = (mx - cx) * .3; ty = (my - cy) * .3 } else { tx = ty = 0 }; x = lerp(x, tx, .1); huelle.style.transform = translate3d(x, y, 0); text.style.transform = translate3d(-x * .6, -y * .6, 0).
GSAP-Variante: rx = (e.clientX - r.left) / r.width - .5; gsap.to(huelle, { x: rx * 50, y: ry * 50, duration: 1.5, ease: 'power4.out' }); beim Verlassen gsap.to(huelle, { x: 0, y: 0, duration: 1.5, ease: 'elastic.out(1, 0.3)' }). Alternativ gsap.quickTo(huelle, 'x', { duration: 1, ease: 'elastic.out(1, 0.3)' }).
Nur bei (hover: hover) and (pointer: fine) aktivieren. Dennis Snellenberg schaltet zusätzlich unter 540 px Fensterbreite ab.
Robust umsetzen
Telefon. Abschalten, Magnetismus nur bei (hover: hover) and (pointer: fine). Beim Tippen stattdessen Druck-Feedback mit scale .96.
Ohne Bewegung. Kein Magnetismus. Ein Farb- oder Füllwechsel beim Hover genügt.
Barrierefrei. Echtes <button> oder <a> verwenden. Den Fokus gleichwertig gestalten, etwa mit derselben Füllung bei :focus-visible. Bei Tastaturfokus ist keine Bewegung nötig. Die Trefferfläche bleibt stabil, der Fokusring liegt auf dem festen Element.
Code
<!DOCTYPE html>
<!--
Magnetische Buttons und Elemente
Fundus · Hover · https://cecodesign.de/lab/fundus/hover/magnetische-elemente/
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>Magnetische Buttons und Elemente · 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: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
:root {
--papier: #eeebe4; --tinte: #141413; --leise: rgba(20, 20, 19, .64); --linie: rgba(20, 20, 19, .15);
--ziegel: #c64a22;
--kurve: cubic-bezier(0.22, 1, 0.36, 1); /* power4.out: Füllung und Farbwechsel */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* ---------- Seite (erfundenes Studio) ---------- */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: clamp(16px, 2.4vw, 28px) clamp(20px, 4vw, 48px); }
.marke { font-size: 19px; font-weight: 600; letter-spacing: -.03em; }
.marke sup { font: 500 9px 'Geist Mono', monospace; letter-spacing: .1em; vertical-align: top; margin-left: 3px; color: var(--leise); }
.nav { display: flex; gap: 28px; margin-left: auto; }
.nav a:hover { color: var(--tinte); }
.held {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 35vw); gap: clamp(24px, 4vw, 64px);
align-items: end; padding: clamp(8px, 2vw, 24px) clamp(20px, 4vw, 48px) clamp(24px, 4vw, 48px);
}
.text { display: grid; gap: clamp(18px, 2.4vw, 30px); padding-bottom: 8px; }
.zeile-oben { display: flex; align-items: center; gap: 10px; }
.punkt { width: 7px; height: 7px; border-radius: 50%; background: #3f9a5b; box-shadow: 0 0 0 4px rgba(63, 154, 91, .16); }
h1 { font-size: clamp(46px, 7.2vw, 124px); font-weight: 600; line-height: .92; letter-spacing: -.048em; max-width: 9.5ch; }
h1 em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.02em; color: var(--ziegel); padding-right: .06em; }
.lead { font-size: clamp(16px, 1.45vw, 19px); line-height: 1.45; color: var(--leise); max-width: 36ch; }
.aktionen { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
.bild { position: relative; justify-self: end; width: 100%; }
.bild img { display: block; width: 100%; aspect-ratio: 4 / 5; max-height: 64svh; object-fit: cover; border-radius: 4px; }
.bild figcaption { display: flex; justify-content: space-between; margin-top: 10px; }
.bild .kreis { position: absolute; left: 0; bottom: 18%; translate: -52% 0; }
/* ---------- Magnetische Elemente ----------
Das <a>/<button> selbst ist die feste Trefferfläche und trägt den Fokus.
Bewegt wird nur die Hülle darin, der Text noch einmal eigens (Parallaxe). */
.magnet { position: relative; display: inline-block; flex: none; border: 0; padding: 0; background: none; font: inherit; color: inherit; cursor: pointer; border-radius: 999px; -webkit-tap-highlight-color: transparent; }
.magnet:focus { outline: none; }
.magnet:focus-visible { outline: 2px solid var(--ziegel); outline-offset: 5px; }
.huelle { position: absolute; inset: 0; display: grid; place-items: center; border-radius: inherit; overflow: hidden; will-change: transform; transition: scale .15s var(--kurve), border-color .3s var(--kurve); }
.mtext { position: relative; z-index: 1; display: flex; align-items: center; gap: 8px; will-change: transform; transition: color .5s var(--kurve); }
/* Füllung steigt beim Eintritt von unten auf */
.fuellung { position: absolute; left: -10%; top: 0; width: 120%; height: 100%; border-radius: 50% 50% 0 0 / 30% 30% 0 0; translate: 0 102%; transition: translate .7s var(--kurve), border-radius .7s var(--kurve); }
.magnet.an .fuellung, .magnet:focus-visible .fuellung { translate: 0 0; border-radius: 0; }
/* sichtbarer Anziehungsradius (nur Variante „Radius“) */
.ring { position: absolute; left: 50%; top: 50%; width: min(140%, 100vw); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%; border: 1px dashed rgba(20, 20, 19, .32); opacity: 0; scale: .9; pointer-events: none; transition: opacity .4s var(--kurve), scale .7s var(--kurve); }
[data-variante='radius'] .magnet.nah .ring { opacity: 1; scale: 1; }
/* Vorführung: Ruhelage gestrichelt, damit der Versatz auch im Standbild lesbar ist */
.magnet::after { content: ''; position: absolute; inset: 0; border-radius: inherit; border: 1px dashed rgba(20, 20, 19, .4); opacity: 0; pointer-events: none; transition: opacity .3s var(--kurve); }
.vorfuehrung .magnet.an::after { opacity: 1; }
.kreis { width: clamp(150px, 15vw, 210px); aspect-ratio: 1; border-radius: 50%; }
.kreis .huelle { background: var(--tinte); color: var(--papier); }
.kreis .fuellung { background: var(--ziegel); }
.kreis .mtext { flex-direction: column; gap: 10px; font-size: clamp(16px, 1.5vw, 20px); font-weight: 500; letter-spacing: -.02em; line-height: 1.05; text-align: center; }
.kreis .pfeil { width: 18px; height: 18px; }
.pille { height: 56px; width: 212px; }
.pille .huelle { border: 1px solid rgba(20, 20, 19, .4); }
.pille .fuellung { background: var(--tinte); }
.pille .mtext { font-size: 16px; font-weight: 500; letter-spacing: -.01em; }
.pille.an .mtext, .pille:focus-visible .mtext { color: var(--papier); }
.rund { width: 56px; height: 56px; }
.rund .huelle { border: 1px solid var(--linie); }
.rund .fuellung { background: var(--tinte); }
.rund svg { width: 19px; height: 19px; display: block; }
.rund.an .mtext, .rund:focus-visible .mtext { color: var(--papier); }
.trenner { width: 1px; height: 28px; background: var(--linie); margin: 0 6px; }
.menue { width: 112px; height: 46px; }
.menue .huelle { background: var(--tinte); color: var(--papier); }
.menue .fuellung { background: var(--ziegel); }
.menue .mtext { font-size: 15px; font-weight: 500; gap: 10px; }
.menue i { display: grid; gap: 4px; }
.menue i::before, .menue i::after { content: ''; display: block; width: 16px; height: 1.5px; background: currentColor; }
/* ---------- Demo-Leiste ---------- */
.leiste { display: flex; align-items: center; gap: 12px 28px; flex-wrap: wrap; padding: 14px clamp(20px, 4vw, 48px) clamp(16px, 2.4vw, 24px); border-top: 1px solid var(--linie); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background .15s, border-color .15s; }
.modi button:hover { color: var(--tinte); border-color: rgba(20, 20, 19, .3); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.modi button:focus-visible { outline: 2px solid var(--ziegel); outline-offset: 2px; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.werte b { color: var(--tinte); font-weight: 500; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 20; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .nav { display: none; }
/* Touch: kein Magnetismus, nur Druck-Feedback */
@media (hover: none) {
.magnet:active .huelle { scale: .96; }
.ring { display: none; }
}
/* Reduzierte Bewegung: kein Magnetismus, Füllung blendet nur über */
.reduziert .huelle, .reduziert .mtext { transform: none !important; }
.reduziert .fuellung { translate: 0 0; border-radius: 0; opacity: 0; transition: opacity .15s linear; }
.reduziert .magnet.an .fuellung, .reduziert .magnet:focus-visible .fuellung { opacity: 1; }
.reduziert .mtext { transition: color .15s linear; }
.reduziert .ring { display: none; }
.reduziert .modi { display: none; }
@media (max-width: 860px) {
.nav { display: none; }
.held { grid-template-columns: 1fr; align-items: start; }
.bild { width: min(78%, 360px); margin-top: 8px; }
.bild img { max-height: none; aspect-ratio: 5 / 4; }
.bild .kreis { bottom: auto; top: 50%; translate: -58% -50%; }
}
@media (max-width: 560px) {
.kreis { width: 132px; }
.kreis .mtext { font-size: 15px; gap: 6px; }
.pille { width: 100%; }
.aktionen { gap: 10px; }
.trenner { display: none; }
.leiste { gap: 10px; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="gezogen">
<header class="kopf">
<a class="marke" href="#start">Wendt Gestaltung<sup>Leipzig</sup></a>
<nav class="nav mono" aria-label="Hauptnavigation"><a href="#arbeiten">Arbeiten</a><a href="#studio">Studio</a><a href="#journal">Journal</a></nav>
<button class="magnet menue" type="button" data-magnet data-staerke="40" data-staerke-text="20" aria-label="Menü öffnen">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext">Menü <i aria-hidden="true"></i></span></span>
</button>
</header>
<main class="held" id="start">
<div class="text">
<p class="zeile-oben mono"><span class="punkt" aria-hidden="true"></span>Frei für Projekte ab Januar 2027</p>
<h1>Marken, die man <em>anfassen</em> möchte.</h1>
<p class="lead">Wir gestalten Erscheinungsbilder, Editorials und Websites für Häuser mit Haltung. Klein im Team, groß im Detail.</p>
<div class="aktionen">
<a class="magnet pille" href="#arbeiten" data-magnet data-staerke="40" data-staerke-text="20">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext">Arbeiten ansehen</span></span>
</a>
<span class="trenner" aria-hidden="true"></span>
<a class="magnet rund" href="#mail" data-magnet data-staerke="24" data-staerke-text="12" aria-label="E-Mail schreiben">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2.5" y="4.5" width="15" height="11" rx="1.5"/><path d="m3 5.5 7 5.5 7-5.5"/></svg></span></span>
</a>
<a class="magnet rund" href="#telefon" data-magnet data-staerke="24" data-staerke-text="12" aria-label="Anrufen">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M4.2 2.8h3l1.4 3.6-1.9 1.2a9 9 0 0 0 4.7 4.7l1.2-1.9 3.6 1.4v3a1.6 1.6 0 0 1-1.7 1.6A14 14 0 0 1 2.6 4.5a1.6 1.6 0 0 1 1.6-1.7Z"/></svg></span></span>
</a>
<a class="magnet rund" href="#anfahrt" data-magnet data-staerke="24" data-staerke-text="12" aria-label="Anfahrt">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><path d="M10 18s5.5-5.2 5.5-9.5a5.5 5.5 0 0 0-11 0C4.5 12.8 10 18 10 18Z"/><circle cx="10" cy="8.5" r="2"/></svg></span></span>
</a>
</div>
</div>
<figure class="bild">
<img src="bilder/fenster-licht-hoch.jpg" alt="Holzbank mit schwarzer Vase und kahlen Zweigen vor salbeigrüner Wand, Sonnenlicht fällt durchs Fenster">
<figcaption><span class="mono">Haus Sommerlicht · Website</span><span class="mono">2025</span></figcaption>
<a class="magnet kreis" href="#anfrage" data-magnet data-staerke="50" data-staerke-text="25">
<span class="ring" aria-hidden="true"></span>
<span class="huelle"><span class="fuellung"></span><span class="mtext">Projekt<br>anfragen<svg class="pfeil" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M4 14 14 4M6 4h8v8"/></svg></span></span>
</a>
</figure>
</main>
<footer class="leiste">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-v="gezogen" aria-pressed="true">Gezogen</button>
<button type="button" data-v="radius" aria-pressed="false">Radius</button>
<button type="button" data-v="feder" aria-pressed="false">Feder</button>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</footer>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#141413" stroke="#eeebe4" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const feinerZeiger = matchMedia('(hover: hover) and (pointer: fine)');
const zeiger = document.querySelector('[data-zeiger]');
const werte = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-v]')];
// Drei gemessene Varianten:
// gezogen Dennis Snellenberg: Versatz = (Zeigerposition im Element − 0,5) × Stärke,
// hin gsap 1,5 s power4.out, zurück 1,5 s elastic.out(1, 0.3). Nur über dem Element.
// radius Codrops MagneticButtons: Radius 0,7 × Breite ab Mitte, Versatz 0,3 × Abstand,
// Lerp 0,1 pro Bild, Text gegenläufig × 0,6. Anziehung beginnt vor dem Rand.
// feder Antinomy Studio: Feder stiffness 600, damping 100, mass 0,2 auf volle Distanz
// Zeiger ↔ Mitte. Nur über dem Element, beim Verlassen zurück auf 0.
const RADIUS = 0.7, FAKTOR = 0.3, LERP = 0.1, GEGEN = -0.6;
const FEDER = { k: 600, c: 100, m: 0.2 };
const HIN = { duration: 1.5, ease: 'power4.out' }, ZURUECK = { duration: 1.5, ease: 'elastic.out(1, 0.3)' };
let variante = 'gezogen';
const teile = [...document.querySelectorAll('[data-magnet]')].map(a => ({
a, h: a.querySelector('.huelle'), t: a.querySelector('.mtext'),
s: +a.dataset.staerke, st: +a.dataset.staerkeText,
x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, drin: false,
}));
function zeigeWerte() {
if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>kein Magnetismus, Füllung blendet in 150 ms über</b></span>'; return; }
if (!feinerZeiger.matches && !html.classList.contains('vorfuehrung')) { werte.innerHTML = '<span>Touch: <b>kein Magnetismus, Druck-Feedback scale 0.96 · 150 ms</b></span>'; return; }
werte.innerHTML = {
gezogen: '<span>Hin <b>power4.out 1500 ms</b></span><span>Zurück <b>elastic.out(1, 0.3) 1500 ms</b></span><span>Stärke <b>50 / 40 / 24 px</b></span><span>Text <b>25 / 20 / 12 px</b></span>',
radius: '<span>Radius <b>0,7 × Breite</b></span><span>Versatz <b>0,3 × Abstand</b></span><span>Lerp <b>0,1 je Bild</b></span><span>Text <b>× −0,6 gegenläufig</b></span>',
feder: '<span>Feder <b>stiffness 600 · damping 100 · mass 0,2</b></span><span>Versatz <b>volle Distanz zur Mitte</b></span><span>Text <b>fest</b></span>',
}[variante];
}
function setzeVariante(v) {
variante = v;
document.body.dataset.variante = v;
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
teile.forEach(p => {
gsap.killTweensOf([p.h, p.t]);
gsap.set([p.h, p.t], { x: 0, y: 0 });
p.x = p.y = p.vx = p.vy = p.tx = p.ty = 0; p.drin = false;
p.a.classList.remove('an', 'nah');
});
zeigeWerte();
starten();
}
// Zeigerposition (echte Maus oder gedachter Zeiger der Vorführung)
const zg = { x: -1e4, y: -1e4, neu: false };
function zeigerAuf(x, y) { zg.x = x; zg.y = y; zg.neu = true; starten(); }
function schreiben(p) {
p.h.style.transform = `translate3d(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px, 0)`;
p.t.style.transform = `translate3d(${p.tx.toFixed(2)}px, ${p.ty.toFixed(2)}px, 0)`;
}
let letzte = performance.now(), laeuft = false;
function bild(t) {
const dtMs = Math.min(64, t - letzte); letzte = t;
let ruhig = true;
teile.forEach(p => {
const r = p.a.getBoundingClientRect(); // Trefferfläche bewegt sich nie
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = zg.x - cx, dy = zg.y - cy;
const ueber = zg.x >= r.left && zg.x <= r.right && zg.y >= r.top && zg.y <= r.bottom;
const imRadius = Math.hypot(dx, dy) < r.width * RADIUS;
const drin = variante === 'radius' ? imRadius : ueber;
p.a.classList.toggle('an', drin);
p.a.classList.toggle('nah', Math.hypot(dx, dy) < r.width * RADIUS * 1.5);
if (reduziert() || !aktivBewegung()) { p.drin = drin; return; }
if (variante === 'gezogen') {
if (drin && zg.neu) {
const rx = (zg.x - r.left) / r.width - 0.5, ry = (zg.y - r.top) / r.height - 0.5;
gsap.to(p.h, { x: rx * p.s, y: ry * p.s, ...HIN, overwrite: 'auto' });
gsap.to(p.t, { x: rx * p.st, y: ry * p.st, ...HIN, overwrite: 'auto' });
} else if (!drin && p.drin) {
gsap.to([p.h, p.t], { x: 0, y: 0, ...ZURUECK, overwrite: 'auto' });
}
} else if (variante === 'radius') {
const zx = drin ? dx * FAKTOR : 0, zy = drin ? dy * FAKTOR : 0;
const k = 1 - Math.pow(1 - LERP, dtMs / 16.667); // Lerp 0,1 bei 60 Bildern/s
p.x += (zx - p.x) * k; p.y += (zy - p.y) * k;
p.tx = p.x * GEGEN; p.ty = p.y * GEGEN;
schreiben(p);
if (Math.abs(zx - p.x) > 0.05 || Math.abs(zy - p.y) > 0.05) ruhig = false;
} else {
const zx = drin ? dx : 0, zy = drin ? dy : 0;
// steife Feder: in 1-ms-Schritten rechnen (semi-implizit)
for (let i = 0, n = Math.max(1, Math.round(dtMs)); i < n; i++) {
const ax = (FEDER.k * (zx - p.x) - FEDER.c * p.vx) / FEDER.m;
const ay = (FEDER.k * (zy - p.y) - FEDER.c * p.vy) / FEDER.m;
p.vx += ax * 0.001; p.vy += ay * 0.001;
p.x += p.vx * 0.001; p.y += p.vy * 0.001;
}
p.tx = p.ty = 0;
schreiben(p);
if (Math.abs(zx - p.x) > 0.05 || Math.abs(zy - p.y) > 0.05) ruhig = false;
}
p.drin = drin;
});
zg.neu = false;
if (ruhig && !uhr) { laeuft = false; return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
const aktivBewegung = () => feinerZeiger.matches || html.classList.contains('vorfuehrung');
addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && !uhr) zeigerAuf(e.clientX, e.clientY); });
document.documentElement.addEventListener('pointerleave', () => { if (!uhr) zeigerAuf(-1e4, -1e4); });
document.addEventListener('click', e => { if (e.target.closest('a[href^="#"], .menue')) e.preventDefault(); });
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-v]');
if (!b) return;
const lief = !!uhr;
setzeVariante(b.dataset.v);
if (lief) vorfuehren(true);
});
// ---------- Vorführung: ein gedachter Zeiger besucht die Elemente ----------
const box = el => { const r = el.getBoundingClientRect(); return { cx: r.left + r.width / 2, cy: r.top + r.height / 2, w: r.width, h: r.height }; };
function weg() {
const k = box(teile.find(p => p.a.classList.contains('kreis')).a);
const pi = box(teile.find(p => p.a.classList.contains('pille')).a);
const runde = teile.filter(p => p.a.classList.contains('rund')).map(p => box(p.a));
const m = box(teile.find(p => p.a.classList.contains('menue')).a);
const P = (x, y, d) => ({ x, y, d });
const a = runde[0], z = runde[runde.length - 1];
return [
P(k.cx - k.w * 1.25, k.cy + k.h * 0.95, 0),
P(k.cx - k.w * 0.62, k.cy + k.h * 0.42, 700), // nähert sich (Radius greift schon hier)
P(k.cx - k.w * 0.22, k.cy + k.h * 0.16, 700),
P(k.cx + k.w * 0.28, k.cy - k.h * 0.26, 900),
P(k.cx + k.w * 0.12, k.cy + k.h * 0.32, 800),
P(k.cx - k.w * 0.30, k.cy - k.h * 0.18, 800),
P(k.cx + k.w * 1.15, k.cy + k.h * 1.0, 520), // schnell raus: Rückfedern
P(k.cx + k.w * 1.2, k.cy + k.h * 1.05, 1300),
P(pi.cx - pi.w * 0.85, pi.cy + pi.h * 1.6, 900),
P(pi.cx - pi.w * 0.28, pi.cy + pi.h * 0.12, 650),
P(pi.cx + pi.w * 0.34, pi.cy - pi.h * 0.28, 900),
P(a.cx - a.w * 0.1, a.cy + a.h * 0.2, 700),
P(a.cx + (z.cx - a.cx) / 2, a.cy - a.h * 0.25, 650),
P(z.cx + z.w * 0.2, z.cy + z.h * 0.25, 650),
P(z.cx + z.w * 1.2, z.cy + z.h * 2.2, 480),
P(m.cx - m.w * 1.4, m.cy + m.h * 2.4, 1100),
P(m.cx - m.w * 0.2, m.cy + m.h * 0.1, 650),
P(m.cx + m.w * 0.3, m.cy - m.h * 0.2, 800),
P(m.cx - m.w * 2.4, m.cy + m.h * 3.2, 520),
P(m.cx - m.w * 2.4, m.cy + m.h * 3.2, 1100),
P(k.cx - k.w * 1.25, k.cy + k.h * 0.95, 1200),
];
}
const sanft = q => q * q * (3 - 2 * q);
const VARIANTEN = ['gezogen', 'radius', 'feder'];
let uhr = null, start = 0, punkte = [], gesamt = 0, runde = -1, wechseln = true, angehalten = 0;
function vorfuehren(festeVariante) {
pause();
wechseln = festeVariante !== true;
html.classList.add('vorfuehrung');
punkte = weg(); gesamt = punkte.reduce((s, p) => s + p.d, 0);
start = performance.now(); runde = -1;
zeigeWerte();
laufen();
}
function laufen() {
const schritt = t => {
const zeit = t - start, n = Math.floor(zeit / gesamt);
if (n !== runde) {
runde = n; punkte = weg();
if (wechseln) setzeVariante(VARIANTEN[n % 3]);
}
let rest = zeit % gesamt, i = 1;
while (i < punkte.length && rest > punkte[i].d) { rest -= punkte[i].d; i++; }
const b = punkte[Math.min(i, punkte.length - 1)], a = punkte[i - 1];
const q = i < punkte.length ? sanft(rest / b.d) : 1;
const x = a.x + (b.x - a.x) * q, y = a.y + (b.y - a.y) * q;
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
zeigerAuf(x, y);
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { if (uhr) angehalten = performance.now(); cancelAnimationFrame(uhr); uhr = null; }
function weiter() {
if (uhr || !html.classList.contains('vorfuehrung')) return;
start += performance.now() - angehalten;
laufen();
}
function neustart() { pause(); html.classList.remove('vorfuehrung'); zeigerAuf(-1e4, -1e4); setzeVariante(variante); }
setzeVariante('gezogen');
})();
</script>
</body>
</html>