Kontextueller Cursor (wächst, dockt an, zeigt Label)
Ein Cursor, der über Medien zum Label wächst („Abspielen“, „Ansehen“, „Ziehen“) und an Buttons andockt.
Über bestimmten Bereichen wie Projektkacheln, Slidern oder Videos wächst ein kleiner Punkt am Zeiger zu einem Kreis oder einer Pille mit kurzem Label: „Ansehen“, „Ziehen“, „Abspielen“. Der Text kommt per data-cursor-Attribut vom Ziel. Wechselt das Ziel, morphen Größe und Text. An Buttons und Icons dockt der Cursor an: Er springt in die Mitte, folgt dem Zeiger nur noch ein wenig und dehnt sich in seine Richtung. Der Cursor folgt dem Zeiger mit leichter Verzögerung.
Der Cursor erklärt die Interaktion genau dort, wo sie passiert, etwa „Ziehen“ bei einem Slider. Das ist die funktional sinnvollste Form eigener Cursor. Galerien bekommen einen klaren Fokus, die Seite eine eigene Stimme. Das Andocken lässt Cursor und Bedienelement verschmelzen, wie beim iPadOS-Zeiger. Das fühlt sich präzise und gebaut an.
Wann einsetzen
Portfolio- und Studio-Seiten, Projekt-Grids, Bildgalerien, Drag-Slider, Video-Teaser und Case-Study-Links. Andocken passt zu Navigationen mit wenigen Links, Icon-Buttons und Toolbars.
Wann nicht
In Formularen, Shops, Textseiten und dichten Oberflächen mit langen Linklisten. Nicht über Textlinks im Fließtext. Nie als Ersatz für sichtbare Bedienelemente, denn das Label sehen nur Nutzer mit Maus. Den System-Cursor nie seitenweit verstecken.
Varianten
- Kreis mit Text (Standard): Punkt wächst zum Kreis mit Label
- Pille mit Icon und Text: Breite richtet sich nach dem Label
- Morph (iOS-Zeiger): der Cursor wird zur Hervorhebungsfläche des Buttons, der Inhalt wandert mit
- Sticky Cursor: springt in die Button-Mitte, dehnt sich Richtung Zeiger (scaleX bis 1,3, scaleY bis 0,8) und dreht sich per atan2
- Geschwindigkeitsabhängiges Strecken im freien Flug
- Label mit Laufschrift innerhalb des Cursors
- Blend-Mode-Kreis, der über einer Überschrift wächst (30 → 400 px, mix-blend-mode: difference)
- Text-Cursor als Balken über markierbarem Text
- Mehrere nachlaufende Kreise (Multi-follow)
Umsetzung
Ein fixes Element (position: fixed; top/left 0; pointer-events: none; aria-hidden) wird per gsap.quickTo(el, 'x', { duration: .55, ease: 'expo.out' }) bewegt, ebenso y. Alternativ per requestAnimationFrame mit Lerp 1/12. Das Ziel unter dem Zeiger liefert document.elementFromPoint(x, y).closest('[data-cursor], [data-dock]'). Label-Ziele tragen den Text in data-cursor, Andock-Ziele das Attribut data-dock. Beim Andocken gilt: Position = Mitte + (Zeiger − Mitte) × 0,15. Die Dehnung ergibt sich aus dem Abstand zur Mitte, die Drehung aus atan2. Die Transform-Reihenfolge bleibt fest: translate, dann rotate, dann scale. Für reine Kreise reicht scale statt width/height. Bei Pille und Morph ändert sich die Form, dort Breite, Höhe und Radius animieren. Rects bei jedem Zeiger- und Scroll-Ereignis neu messen. Nur aktivieren bei matchMedia('(hover: hover) and (pointer: fine)'). Den System-Cursor nicht verstecken; in der Morph-Variante nur über den Andock-Zielen. z-index über dem Inhalt, aber unter Modals.
Robust umsetzen
Telefon. Auf Touch entfällt der Cursor komplett. Die Information der Labels muss als sichtbares Element vorhanden sein, etwa als Play-Knopf, Pfeile oder „Wischen“-Hinweis.
Ohne Bewegung. Kein Nachlaufen und keine Dehnung. Der System-Cursor bleibt, das Label erscheint statisch in der Kachelecke oder als Tooltip.
Barrierefrei. Das Cursor-Element ist aria-hidden. Die Bedeutung, etwa „Video abspielen“, gehört in den zugänglichen Namen des Ziels. Tastaturfokus braucht eigene sichtbare Fokus-Stile, unabhängig vom Cursor. Den System-Cursor nicht entfernen, weil Vergrößerungssoftware und präzises Zeigen davon abhängen.
Code
<!DOCTYPE html>
<!--
Kontextueller Cursor (wächst, dockt an, zeigt Label)
Fundus · Cursor · https://cecodesign.de/lab/fundus/cursor/kontextueller-cursor/
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>Kontextueller Cursor · 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: #efece5; --tinte: #151413; --leise: rgba(21, 20, 19, .64); --linie: rgba(21, 20, 19, .14);
--signal: #f0532a; /* Mennige: Farbe des Label-Cursors */
--signal-schrift: #e65028; /* Mennige als große Schrift: 3,2:1 auf Papier */
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, 550 ms (Cuberto) */
--rand: clamp(16px, 3.4vw, 48px);
}
* { 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; padding: clamp(14px, 2.6vh, 28px) var(--rand) clamp(14px, 2.4vh, 24px); display: grid; grid-template-rows: auto 1fr auto; gap: clamp(18px, 4vh, 44px); overflow-x: hidden; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
img { display: block; width: 100%; height: 100%; object-fit: cover; }
:focus-visible { outline: 2px solid var(--tinte); outline-offset: 3px; }
/* Kopf: Navigation mit Andock-Zielen */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
.marke { display: flex; align-items: baseline; gap: 12px; }
.marke b { font-size: 22px; font-weight: 600; letter-spacing: -.04em; }
.nav { display: flex; gap: 4px; }
.nav a, .knopf { position: relative; display: inline-flex; align-items: center; height: 40px; padding: 0 14px; border-radius: 999px; font-size: 15px; font-weight: 500; letter-spacing: -.01em; }
.nav a span, .knopf span { display: block; }
.kopf-rechts { display: flex; align-items: center; gap: 10px; }
.knopf { background: var(--tinte); color: var(--papier); padding: 0 18px; }
.rund { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--linie); display: grid; place-items: center; }
.burger { display: block; width: 16px; height: 8px; border-block: 1.5px solid currentColor; }
/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); grid-template-rows: auto auto; column-gap: clamp(24px, 3.4vw, 48px); row-gap: clamp(20px, 4vh, 44px); align-content: center; }
.intro { display: flex; flex-direction: column; justify-content: space-between; gap: 28px; padding-bottom: 38px; }
h1 { font-size: clamp(40px, 5.5vw, 86px); font-weight: 500; letter-spacing: -.045em; line-height: .94; margin-top: 18px; text-wrap: balance; }
h1 em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; font-style: italic; letter-spacing: -.02em; color: var(--signal-schrift); padding-right: .04em; }
.text { font-size: clamp(15px, 1.2vw, 17px); line-height: 1.5; color: var(--leise); max-width: 34ch; }
.kacheln { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 14px; }
.kachel { display: flex; flex-direction: column; gap: 10px; text-align: left; }
.kachel .rahmen { position: relative; height: clamp(230px, 44vh, 420px); border-radius: 6px; overflow: hidden; background: #d9d4ca; }
.kachel img { transition: scale 1.2s var(--kurve); }
.kachel:hover img { scale: 1.035; }
.unterzeile { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.unterzeile strong { font-size: 15px; font-weight: 500; letter-spacing: -.01em; }
.unterzeile strong .mono { margin-right: 10px; }
/* Film-Kachel: Klick spielt ab, das Label wechselt auf „Pause“ */
.film .marke-film { position: absolute; left: 14px; top: 14px; color: var(--tinte); }
.fortschritt { position: absolute; left: 14px; right: 14px; bottom: 14px; height: 2px; background: rgba(21, 20, 19, .14); border-radius: 2px; overflow: hidden; }
.fortschritt i { display: block; height: 100%; width: 100%; background: var(--tinte); transform-origin: left; scale: 0 1; }
.film.laeuft .fortschritt i { animation: lauf 42s linear forwards; }
.film.laeuft img { scale: 1.08; transition: scale 14s linear; }
@keyframes lauf { to { scale: 1 1; } }
/* Leiste zum Ziehen */
.leiste { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.leiste-kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.fenster { overflow: hidden; border-radius: 6px; user-select: none; -webkit-user-select: none; }
.spur { display: flex; gap: 14px; width: max-content; will-change: transform; }
.karte { display: flex; flex-direction: column; gap: 8px; width: clamp(170px, 17vw, 250px); }
.karte .bild { height: clamp(84px, 14vh, 150px); border-radius: 6px; overflow: hidden; }
.karte img { pointer-events: none; -webkit-user-drag: none; }
.karte .unterzeile strong { font-size: 14px; }
/* Werte und Varianten */
.steuer { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; border-top: 1px solid var(--linie); padding-top: 12px; }
.modi { display: flex; align-items: center; gap: 6px; }
.modi .mono:first-child { margin-right: 6px; }
.modi button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; transition: color .15s, background .15s, border-color .15s; }
.modi button:hover { color: var(--tinte); border-color: rgba(21, 20, 19, .3); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.werte b { color: var(--tinte); font-weight: 500; }
/* Der Cursor: Anker (x/y) → Dehnung (rotate, scale) → Form (Breite, Höhe, Radius) */
.cursor { position: fixed; left: 0; top: 0; z-index: 50; width: 0; height: 0; pointer-events: none; opacity: 0; display: none; }
.mit-cursor .cursor { display: block; }
.cursor.blend { mix-blend-mode: difference; }
.cursor-dehnung { position: absolute; left: 0; top: 0; }
.cursor-form { position: absolute; left: 0; top: 0; width: 10px; height: 10px; border-radius: 5px; background: #fff; translate: -50% -50%; }
.cursor-inhalt { position: absolute; left: 0; top: 0; }
.cursor-maske { position: absolute; left: 0; top: 0; translate: -50% -50%; overflow: hidden; padding: 2px 0; }
.cursor-zeile { display: flex; align-items: center; gap: 8px; white-space: nowrap; font: 600 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--tinte); }
.cursor-icon { display: block; width: 14px; height: 14px; }
.cursor-icon.breit { width: 20px; }
.cursor-icon svg, .cursor-pfeil svg { display: block; width: 100%; height: 100%; }
.cursor-pfeil { position: absolute; left: 0; top: 0; width: 9px; height: 14px; margin: -7px 0 0 -4.5px; color: var(--tinte); opacity: 0; filter: drop-shadow(0 0 1.5px #fff) drop-shadow(0 0 1.5px #fff); }
body[data-variante='morph'].mit-cursor [data-dock] { cursor: none; }
/* Gezeichneter Zeiger der Vorführung (steht für den System-Cursor) */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* Sichtbare Hinweise: nur ohne Cursor-Label (Touch, reduzierte Bewegung) */
.hinweis { display: none; position: absolute; right: 12px; bottom: 12px; align-items: center; gap: 7px; height: 30px; padding: 0 12px; border-radius: 999px; background: rgba(255, 255, 255, .92); box-shadow: 0 0 0 1px var(--linie); color: var(--tinte); font: 600 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; transition: background-color .15s linear, color .15s linear; }
.hinweis svg { width: 12px; height: 12px; }
.film .hinweis { bottom: 26px; }
.leiste-kopf .hinweis { position: static; height: 26px; background: none; }
.nur-touch { display: none; }
@media (hover: none) {
.hinweis { display: inline-flex; }
.nur-touch { display: inline; } .nur-maus { display: none; }
.fenster { overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }
.fenster::-webkit-scrollbar { display: none; }
.karte { scroll-snap-align: start; }
.kachel:hover img { scale: 1; }
}
.reduziert .hinweis { display: inline-flex; }
.reduziert [data-cursor]:hover .hinweis, [data-cursor]:focus-visible .hinweis, .zeigt .hinweis { background: var(--signal); color: var(--tinte); }
.reduziert .leiste-kopf .hinweis { background: none; }
.reduziert .leiste:hover .leiste-kopf .hinweis, .reduziert .leiste.zeigt .leiste-kopf .hinweis { background: var(--signal); box-shadow: none; }
.reduziert .kachel img, .reduziert .film.laeuft img { transition: none; scale: 1; }
.reduziert .cursor, .reduziert .zeiger { display: none !important; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .steuer { display: none; }
.vorschau body { grid-template-rows: auto 1fr; }
@media (max-width: 760px) {
body { gap: 22px; }
.nav, .knopf, .marke .mono { display: none; }
.buehne { grid-template-columns: minmax(0, 1fr); row-gap: 28px; }
.intro { padding-bottom: 0; gap: 16px; }
h1 { font-size: clamp(38px, 11vw, 56px); margin-top: 12px; }
.kacheln { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.kachel .rahmen { height: auto; aspect-ratio: 4 / 3; }
.projekt .rahmen { aspect-ratio: 5 / 4; }
.karte { width: 68vw; }
.karte .bild { height: auto; aspect-ratio: 3 / 2; }
.werte { font-size: 10px; }
}
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="kreis">
<header class="kopf">
<a class="marke" href="#start"><b>Ostwind</b><span class="mono">Büro für Gestaltung</span></a>
<nav class="nav" aria-label="Hauptnavigation">
<a href="#arbeiten" data-dock><span>Arbeiten</span></a>
<a href="#buero" data-dock><span>Büro</span></a>
<a href="#journal" data-dock><span>Journal</span></a>
</nav>
<div class="kopf-rechts">
<a class="knopf" href="#anfrage" data-dock><span>Projekt anfragen</span></a>
<button class="rund" type="button" data-dock aria-label="Menü öffnen"><span class="burger"></span></button>
</div>
</header>
<main class="buehne" id="start">
<div class="intro">
<div>
<p class="mono">Leipzig · seit 2014 · 12 Leute</p>
<h1>Arbeiten, die <em>neugierig</em> machen.</h1>
</div>
<p class="text">Wir gestalten Erscheinungsbilder, Räume und Websites für Cafés, Handwerk und Kultur. Ein Tisch, kurze Wege, alles aus einer Hand.</p>
</div>
<div class="kacheln">
<button class="kachel film" type="button" data-cursor="Abspielen" data-cursor-icon="play" aria-pressed="false" aria-label="Markenfilm Atelier Hain abspielen">
<span class="rahmen">
<img src="bilder/keramik-stillleben.jpg" alt="" draggable="false">
<span class="mono marke-film">Markenfilm · 0:42</span>
<span class="fortschritt"><i></i></span>
<span class="hinweis" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M4 2.5v11l9-5.5z" fill="currentColor"/></svg><span data-hinweis-film>Abspielen</span></span>
</span>
<span class="unterzeile"><strong><span class="mono">01</span>Atelier Hain</strong><span class="mono">Film, 2025</span></span>
</button>
<a class="kachel projekt" href="#gefuege" data-cursor="Ansehen" data-cursor-icon="pfeil">
<span class="rahmen">
<img src="bilder/magazin-doppelseite-hoch.jpg" alt="" draggable="false">
<span class="hinweis" aria-hidden="true">Ansehen<svg viewBox="0 0 16 16"><path d="M4.5 11.5l7-7M6 4.5h5.5V10" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></span>
</span>
<span class="unterzeile"><strong><span class="mono">02</span>Gefüge</strong><span class="mono">Heft, 2024</span></span>
</a>
</div>
<section class="leiste" data-cursor="Ziehen" data-cursor-icon="ziehen" aria-label="Weitere Arbeiten">
<div class="leiste-kopf">
<p class="mono">Weitere Arbeiten (06)</p>
<span class="hinweis" aria-hidden="true"><span class="nur-maus">← Ziehen →</span><span class="nur-touch">← Wischen →</span></span>
</div>
<div class="fenster" data-fenster>
<div class="spur" data-spur>
<a class="karte" href="#krume"><span class="bild"><img src="bilder/marke-krume.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Krume</strong><span class="mono">Logo</span></span></a>
<a class="karte" href="#sommerlicht"><span class="bild"><img src="bilder/fenster-licht.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Haus Sommerlicht</strong><span class="mono">Raum</span></span></a>
<a class="karte" href="#roestkontor"><span class="bild"><img src="bilder/marke-roestkontor.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Röstkontor</strong><span class="mono">Marke</span></span></a>
<a class="karte" href="#limonaia"><span class="bild"><img src="bilder/zitronen-tisch.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Limonaia</strong><span class="mono">Website</span></span></a>
<a class="karte" href="#eselsohr"><span class="bild"><img src="bilder/marke-eselsohr.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Eselsohr</strong><span class="mono">Website</span></span></a>
<a class="karte" href="#licht"><span class="bild"><img src="bilder/typo-plakat.jpg" alt="" draggable="false"></span><span class="unterzeile"><strong>Licht, Halle 4</strong><span class="mono">Plakat</span></span></a>
</div>
</div>
</section>
</main>
<footer class="steuer">
<div class="modi" role="group" aria-label="Variante">
<span class="mono">Variante</span>
<button type="button" data-variante="kreis" aria-pressed="true">Kreis</button>
<button type="button" data-variante="pille" aria-pressed="false">Pille</button>
<button type="button" data-variante="morph" aria-pressed="false">Morph</button>
</div>
<p class="mono werte" data-werte></p>
</footer>
<div class="cursor blend" data-cursor-el aria-hidden="true">
<div class="cursor-dehnung"><div class="cursor-form"></div></div>
<span class="cursor-pfeil" data-pfeil="-1"><svg viewBox="0 0 9 14"><path d="M7.5 1.5 2 7l5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></span>
<span class="cursor-pfeil" data-pfeil="1"><svg viewBox="0 0 9 14"><path d="M1.5 1.5 7 7l-5.5 5.5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></span>
<div class="cursor-inhalt"></div>
</div>
<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="#151413" stroke="#efece5" 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, body = document.body;
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const reduziert = () => html.classList.contains('reduziert');
const fein = matchMedia('(hover: hover) and (pointer: fine)');
const vorfuehrt = () => html.classList.contains('vorfuehrung');
const klemm = (v, a, b) => Math.min(b, Math.max(a, v));
// Alle Werte an einer Stelle (gemessen: Cuberto 550 ms expo.out, Stick 0,15, Skew max 0,15, Hide 300 ms)
const W = {
dauer: 0.55, ease: 'expo.out',
punkt: 10, label: 96, labelZieht: 80, pilleH: 44, pillePad: 18,
stick: 0.15, dehnX: 0.3, dehnY: 0.2, skewDelta: 0.001, skewMax: 0.15,
dockMin: 48, dockMax: 76, dockPlus: 16, morphPad: 4, verstecken: 300, ui: 0.15
};
const SIGNAL = '#f0532a', MORPH = 'rgba(21, 20, 19, .11)';
const ICON = {
play: '<svg viewBox="0 0 16 16"><path d="M4 2.5v11l9-5.5z" fill="currentColor"/></svg>',
pause: '<svg viewBox="0 0 16 16"><path d="M4 2.5h3v11H4zM9 2.5h3v11H9z" fill="currentColor"/></svg>',
pfeil: '<svg viewBox="0 0 16 16"><path d="M4.5 11.5l7-7M6 4.5h5.5V10" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>',
ziehen: '<svg viewBox="0 0 20 14"><path d="M5.5 2 1 7l4.5 5M14.5 2 19 7l-4.5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>'
};
const WERTE_TEXT = {
kreis: 'Folgen <b>550 ms expo.out</b> · Label <b>⌀ 96 px</b> · Andocken <b>Mitte + 15 %</b>, Dehnung <b>1,3 / 0,8</b>',
pille: 'Folgen <b>550 ms expo.out</b> · Pille <b>44 px</b> mit Icon · Andocken <b>Mitte + 15 %</b>',
morph: 'Folgen <b>550 ms expo.out</b> · Label <b>⌀ 96 px</b> · Morph auf <b>Button + 4 px</b>, Inhalt <b>15 %</b>'
};
const cursor = $('[data-cursor-el]'), dehnung = $('.cursor-dehnung'), form = $('.cursor-form'), inhalt = $('.cursor-inhalt');
const pfeile = $$('[data-pfeil]'), zeiger = $('[data-zeiger]');
const film = $('.film'), leiste = $('.leiste'), fenster = $('[data-fenster]'), spur = $('[data-spur]');
const xTo = gsap.quickTo(cursor, 'x', { duration: W.dauer, ease: W.ease });
const yTo = gsap.quickTo(cursor, 'y', { duration: W.dauer, ease: W.ease });
const spurTo = gsap.quickTo(spur, 'x', { duration: W.dauer, ease: W.ease });
let variante = 'kreis', gewaehlt = 'kreis';
let aktuell = null, zustand = 'frei', erster = true, sichtbar = false, versteckUhr = null;
let zieh = null, zieht = false, letzterWeg = 0, spurX = 0, inhaltBreite = 0;
const maus = { x: innerWidth / 2, y: innerHeight / 2 };
const dehn = { sx: 1, sy: 1, a: 0 };
/* ---------- Zustand bestimmen ---------- */
function zeigerBei(x, y) {
maus.x = x; maus.y = y;
zeigen();
const el = document.elementFromPoint(x, y);
const ziel = el ? el.closest('[data-cursor], [data-dock]') : null;
if (ziel !== aktuell) wechsel(ziel);
folgen();
}
function wechsel(ziel, erzwingen) {
if (aktuell && aktuell !== ziel && aktuell.hasAttribute('data-dock')) gsap.to(aktuell.firstElementChild, { x: 0, y: 0, duration: W.dauer, ease: W.ease, overwrite: 'auto' });
const textNeu = !erzwingen && ziel === aktuell ? false : true;
aktuell = ziel;
zustand = !ziel ? 'frei' : ziel.hasAttribute('data-cursor') ? 'label' : 'dock';
if (zustand === 'label') { if (textNeu) inhaltSetzen(ziel.dataset.cursor, ziel.dataset.cursorIcon); }
else inhaltLeeren();
zeiger.style.opacity = zustand === 'dock' && variante === 'morph' ? '0' : '1';
formAnwenden();
}
/* ---------- Form: Punkt, Label, Andocken ---------- */
function formFuer() {
if (zustand === 'label') {
if (variante === 'pille') return { w: Math.ceil(inhaltBreite) + 2 * W.pillePad, h: W.pilleH, bg: SIGNAL, blend: false };
const d = zieht ? W.labelZieht : W.label;
return { w: d, h: d, bg: SIGNAL, blend: false };
}
if (zustand === 'dock') {
const r = aktuell.getBoundingClientRect();
if (variante === 'morph') {
const rad = parseFloat(getComputedStyle(aktuell).borderTopLeftRadius) || 0;
const w = r.width + 2 * W.morphPad, h = r.height + 2 * W.morphPad;
return { w, h, r: Math.min(rad + W.morphPad, h / 2), bg: MORPH, blend: false };
}
const k = (aktuell.firstElementChild || aktuell).getBoundingClientRect();
const d = klemm(Math.max(k.width, k.height) + W.dockPlus, W.dockMin, W.dockMax);
return { w: d, h: d, bg: '#fff', blend: true };
}
return { w: W.punkt, h: W.punkt, bg: '#fff', blend: true };
}
function formAnwenden() {
const f = formFuer();
cursor.classList.toggle('blend', f.blend);
gsap.set(form, { backgroundColor: f.bg });
const radius = f.r != null ? f.r : Math.min(f.w, f.h) / 2;
if (reduziert()) gsap.set(form, { width: f.w, height: f.h, borderRadius: radius });
else gsap.to(form, { width: f.w, height: f.h, borderRadius: radius, duration: W.dauer, ease: W.ease, overwrite: 'auto' });
const mitPfeilen = zustand === 'label' && aktuell === leiste && variante !== 'pille';
pfeile.forEach(p => {
const s = +p.dataset.pfeil, abstand = (zieht ? W.labelZieht : W.label) / 2 + (zieht ? 18 : 12);
gsap.to(p, { x: mitPfeilen ? s * abstand : s * 20, opacity: mitPfeilen ? 1 : 0, duration: mitPfeilen ? W.dauer : W.ui, ease: W.ease, overwrite: 'auto' });
});
}
/* ---------- Label: Text kommt aus data-cursor, wechselt per Maske ---------- */
function inhaltSetzen(text, icon) {
inhaltLeeren(true);
const maske = document.createElement('span');
maske.className = 'cursor-maske';
const zeile = document.createElement('span');
zeile.className = 'cursor-zeile';
const mitIcon = variante === 'pille' && icon && ICON[icon];
zeile.innerHTML = (mitIcon ? `<i class="cursor-icon${icon === 'ziehen' ? ' breit' : ''}">${ICON[icon]}</i>` : '') + `<span>${text}</span>`;
maske.appendChild(zeile);
inhalt.appendChild(maske);
inhaltBreite = zeile.offsetWidth;
if (reduziert()) return;
gsap.fromTo(zeile, { yPercent: 115 }, { yPercent: 0, duration: W.dauer, ease: W.ease, delay: 0.05 });
}
function inhaltLeeren(wechselnd) {
$$('.cursor-maske', inhalt).forEach(m => {
if (m.dataset.geht) return;
m.dataset.geht = '1';
const z = m.firstElementChild;
gsap.to(z, wechselnd ? { yPercent: -115, duration: W.dauer * 0.6, ease: W.ease } : { opacity: 0, duration: W.ui, ease: 'none' })
.eventCallback('onComplete', () => m.remove());
});
}
/* ---------- Folgen und Andocken ---------- */
function folgen() {
let x = maus.x, y = maus.y;
if (zustand === 'dock') {
const r = aktuell.getBoundingClientRect(), cx = r.left + r.width / 2, cy = r.top + r.height / 2;
x = cx + (maus.x - cx) * W.stick; y = cy + (maus.y - cy) * W.stick;
if (variante === 'morph') gsap.to(aktuell.firstElementChild, { x: (maus.x - cx) * W.stick, y: (maus.y - cy) * W.stick, duration: W.dauer, ease: W.ease, overwrite: 'auto' });
}
if (erster || reduziert()) { xTo(x, x); yTo(y, y); erster = false; }
else { xTo(x); yTo(y); }
}
// Dehnung: frei aus dem Nachlauf (Skew), angedockt aus dem Abstand zur Mitte (Sticky)
gsap.ticker.add(() => {
if (!sichtbar) return;
let sx = 1, sy = 1, a = 0;
if (zustand === 'frei') {
const dx = maus.x - gsap.getProperty(cursor, 'x'), dy = maus.y - gsap.getProperty(cursor, 'y'), d = Math.hypot(dx, dy);
const s = Math.min(d * W.skewDelta, W.skewMax);
sx = 1 + s; sy = 1 - s; a = d > 2 ? Math.atan2(dy, dx) * 180 / Math.PI : dehn.a;
} else if (zustand === 'dock' && variante !== 'morph') {
const r = aktuell.getBoundingClientRect(), dx = maus.x - (r.left + r.width / 2), dy = maus.y - (r.top + r.height / 2);
const k = klemm(Math.hypot(dx, dy) / Math.max(r.width / 2, r.height / 2, 1), 0, 1);
sx = 1 + W.dehnX * k; sy = 1 - W.dehnY * k; a = k > 0.02 ? Math.atan2(dy, dx) * 180 / Math.PI : dehn.a;
}
const f = reduziert() ? 1 : 0.22;
dehn.sx += (sx - dehn.sx) * f; dehn.sy += (sy - dehn.sy) * f; dehn.a = a;
dehnung.style.transform = `rotate(${dehn.a}deg) scale(${dehn.sx}, ${dehn.sy})`;
});
function zeigen() {
clearTimeout(versteckUhr);
if (sichtbar) return;
sichtbar = true; erster = true;
gsap.to(cursor, { opacity: 1, duration: W.ui, ease: 'none', overwrite: 'auto' });
}
function verstecken(sofort) {
clearTimeout(versteckUhr);
versteckUhr = setTimeout(() => {
sichtbar = false;
gsap.to(cursor, { opacity: 0, duration: W.ui, ease: 'none', overwrite: 'auto' });
wechsel(null);
}, sofort ? 0 : W.verstecken);
}
/* ---------- Echte Maus ---------- */
const echt = () => fein.matches && !reduziert() && !vorfuehrt();
function cursorErlaubt() { body.classList.toggle('mit-cursor', (fein.matches && !reduziert()) || vorfuehrt()); }
cursorErlaubt();
fein.addEventListener('change', cursorErlaubt);
addEventListener('pointermove', e => { if (e.pointerType === 'mouse' && echt()) zeigerBei(e.clientX, e.clientY); });
document.addEventListener('mouseleave', () => { if (echt()) verstecken(); });
addEventListener('blur', () => { if (echt()) verstecken(); });
addEventListener('scroll', () => { if (sichtbar && (echt() || vorfuehrt())) zeigerBei(maus.x, maus.y); }, { passive: true });
/* ---------- Film: Klick ändert den Zustand, das Label folgt ---------- */
film.addEventListener('click', () => {
const an = !film.classList.contains('laeuft');
film.classList.toggle('laeuft', an);
film.setAttribute('aria-pressed', String(an));
film.setAttribute('aria-label', an ? 'Markenfilm Atelier Hain pausieren' : 'Markenfilm Atelier Hain abspielen');
film.dataset.cursor = an ? 'Pause' : 'Abspielen';
film.dataset.cursorIcon = an ? 'pause' : 'play';
$('[data-hinweis-film]').textContent = an ? 'Pause' : 'Abspielen';
film.querySelector('.hinweis svg').innerHTML = an ? '<path d="M4 2.5h3v11H4zM9 2.5h3v11H9z" fill="currentColor"/>' : '<path d="M4 2.5v11l9-5.5z" fill="currentColor"/>';
if (aktuell === film) wechsel(film, true);
});
function filmStopp() { if (film.classList.contains('laeuft')) film.click(); }
/* ---------- Leiste ziehen (Maus); Touch scrollt nativ ---------- */
const grenze = () => Math.min(0, fenster.clientWidth - spur.scrollWidth);
function spurSetzen(x) {
spurX = klemm(x, grenze(), 0);
if (!fein.matches && !vorfuehrt()) { fenster.scrollTo({ left: -spurX }); return; }
if (reduziert()) gsap.set(spur, { x: spurX }); else spurTo(spurX);
}
function ziehStart(x) {
zieh = { x0: x, s0: spurX, weg: 0 }; zieht = true;
if (aktuell === leiste) formAnwenden();
}
function ziehBewegen(x) {
if (!zieh) return;
const d = x - zieh.x0; zieh.weg = Math.max(zieh.weg, Math.abs(d));
spurSetzen(zieh.s0 + d);
}
function ziehEnde() {
if (!zieh) return;
letzterWeg = zieh.weg; zieh = null; zieht = false;
if (aktuell === leiste) formAnwenden();
}
fenster.addEventListener('pointerdown', e => {
if (e.pointerType !== 'mouse' || e.button !== 0 || !fein.matches || vorfuehrt()) return;
fenster.setPointerCapture(e.pointerId); ziehStart(e.clientX);
});
fenster.addEventListener('pointermove', e => { if (zieh && !vorfuehrt()) ziehBewegen(e.clientX); });
fenster.addEventListener('pointerup', () => { if (!vorfuehrt()) ziehEnde(); });
fenster.addEventListener('pointercancel', () => { if (!vorfuehrt()) ziehEnde(); });
fenster.addEventListener('click', e => { if (letzterWeg > 6) { e.preventDefault(); e.stopPropagation(); } letzterWeg = 0; }, true);
// Tastatur: fokussierte Karte ins Fenster holen
fenster.addEventListener('focusin', e => {
if (!fein.matches) return;
fenster.scrollLeft = 0;
const k = e.target.closest('.karte'); if (!k) return;
const links = k.offsetLeft, rechts = links + k.offsetWidth, w = fenster.clientWidth;
if (links + spurX < 0) spurSetzen(-links);
else if (rechts + spurX > w) spurSetzen(w - rechts);
});
document.addEventListener('click', e => { const a = e.target.closest('a[href^="#"]'); if (a) e.preventDefault(); });
/* ---------- Varianten ---------- */
function varianteSetzen(v) {
variante = v; body.dataset.variante = v;
$$('[data-variante]', $('.modi')).forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
$('[data-werte]').innerHTML = WERTE_TEXT[v];
if (aktuell) wechsel(aktuell, true);
}
$$('.modi button').forEach(b => b.addEventListener('click', () => { gewaehlt = b.dataset.variante; varianteSetzen(gewaehlt); }));
varianteSetzen('kreis');
formAnwenden();
/* ---------- Vorführung: ein gedachter Zeiger fährt die Ziele ab ---------- */
const P = (sel, fx, fy) => () => { const r = $(sel).getBoundingClientRect(); return [r.left + r.width * fx, r.top + r.height * fy]; };
const WEG = [
{ p: P('h1', .12, .92), fahrt: 0, halt: 250 },
{ p: P('h1', .78, .25), fahrt: 650, halt: 80 }, // freier Flug: Punkt dehnt sich
{ p: P('.film .rahmen', .5, .52), fahrt: 750, halt: 1500, kreis: 26 }, // Label „Abspielen“
{ p: P('.projekt .rahmen', .5, .45), fahrt: 600, halt: 1100, kreis: 18 }, // Text morpht zu „Ansehen“
{ p: P('.fenster', .62, .5), fahrt: 650, halt: 250 }, // „Ziehen“
{ p: P('.fenster', .26, .5), fahrt: 1000, halt: 300, ziehen: true }, // gedrückt ziehen
{ p: P('.nav a:nth-child(1)', .35, .6), fahrt: 850, halt: 650, kreis: 10 }, // andocken
{ p: P('.nav a:nth-child(2)', .62, .4), fahrt: 420, halt: 550, kreis: 9 },
{ p: P('.rund', .62, .58), fahrt: 650, halt: 700, kreis: 9 },
{ p: P('h1', .12, .92), fahrt: 850, halt: 300 }
];
let t0 = 0, uhr = null, standZeit = 0, runde = 0, ziehSeg = false, rUhr = null, rIndex = 0;
const ANFANG = []; let GESAMT = 0;
WEG.forEach(s => { ANFANG.push(GESAMT); GESAMT += s.fahrt + s.halt; });
const weich = u => u < .5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2;
function schritt(t) {
let e = t - t0;
if (e >= GESAMT) {
t0 += GESAMT; e -= GESAMT; runde++;
varianteSetzen(['kreis', 'pille', 'morph'][runde % 3]);
spurSetzen(0); filmStopp();
}
let i = WEG.length - 1; while (i > 0 && ANFANG[i] > e) i--;
const s = WEG[i], lokal = e - ANFANG[i];
const ziel = s.p(), von = i > 0 ? WEG[i - 1].p() : ziel;
let x, y;
if (lokal < s.fahrt) {
const u = weich(lokal / s.fahrt);
x = von[0] + (ziel[0] - von[0]) * u; y = von[1] + (ziel[1] - von[1]) * u;
} else {
const v = (lokal - s.fahrt) / 1000, r = s.kreis || 0, an = Math.sin(Math.min(1, (lokal - s.fahrt) / s.halt) * Math.PI);
x = ziel[0] + Math.sin(v * 3.4) * r * an; y = ziel[1] + Math.sin(v * 2.3 + 1) * r * 0.6 * an;
}
// Ziehen: gedrückt während der Fahrt
const drueckt = s.ziehen && lokal < s.fahrt;
if (drueckt && !ziehSeg) { ziehSeg = true; ziehStart(x); }
if (ziehSeg) ziehBewegen(x);
if (!drueckt && ziehSeg) { ziehSeg = false; ziehEnde(); }
zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px) scale(${ziehSeg ? .86 : 1})`;
zeigerBei(x, y);
uhr = requestAnimationFrame(schritt);
}
// Reduziert: kein Zeiger, die Hinweise leuchten nacheinander auf
function reduziertSchritt() {
const ziele = [film, $('.projekt'), leiste];
ziele.forEach((z, j) => z.classList.toggle('zeigt', j === rIndex % 3));
rIndex++;
rUhr = setTimeout(reduziertSchritt, 1600);
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung'); cursorErlaubt();
if (reduziert()) { rIndex = 0; reduziertSchritt(); return; }
runde = 0; varianteSetzen('kreis'); spurSetzen(0); filmStopp();
erster = true; t0 = performance.now(); uhr = requestAnimationFrame(schritt);
}
function pause() {
if (uhr) { cancelAnimationFrame(uhr); uhr = null; standZeit = performance.now() - t0; }
clearTimeout(rUhr); rUhr = null;
}
function weiter() {
if (!vorfuehrt() || uhr || rUhr) return;
if (reduziert()) { reduziertSchritt(); return; }
t0 = performance.now() - standZeit; uhr = requestAnimationFrame(schritt);
}
function neustart() {
pause(); standZeit = 0;
if (ziehSeg) { ziehSeg = false; ziehEnde(); }
html.classList.remove('vorfuehrung'); cursorErlaubt();
$$('.zeigt').forEach(z => z.classList.remove('zeigt'));
filmStopp(); spurSetzen(0); varianteSetzen(gewaehlt);
verstecken(true);
}
})();
</script>
</body>
</html>