Add a site-wide custom cursor in the logo's orbital motif

A filled brand-green core with a glowing satellite circling it on a
tilted elliptical path, echoing the orbit swoosh in the HifiPlanet logo.
Over interactive elements the core grows, the orbit widens and brightens,
and the satellite speeds up; clicking fires a subwoofer-style thump plus
an outward pulse ring. Core and orbit track the mouse in perfect sync -
only orbit speed and radius ease between states.

Enabled only on fine-pointer devices and disabled entirely under
prefers-reduced-motion (native cursor untouched in both cases). Over
text fields the orbit hides and the native I-beam takes over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Maaxxs 2026-07-09 04:54:56 +02:00
parent f40866276e
commit fe751dce5f
3 changed files with 286 additions and 0 deletions

View file

@ -1,5 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import ScrollToTop from './components/ScrollToTop.jsx'; import ScrollToTop from './components/ScrollToTop.jsx';
import CustomCursor from './components/CustomCursor.jsx';
import SetupGate from './components/SetupGate.jsx'; import SetupGate from './components/SetupGate.jsx';
import PublicLayout from './components/PublicLayout.jsx'; import PublicLayout from './components/PublicLayout.jsx';
import AdminLayout from './components/AdminLayout.jsx'; import AdminLayout from './components/AdminLayout.jsx';
@ -48,6 +49,7 @@ export default function App() {
return ( return (
<> <>
<ScrollToTop /> <ScrollToTop />
<CustomCursor />
<SetupGate> <SetupGate>
<Routes> <Routes>
<Route path="/setup" element={<SetupWizard />} /> <Route path="/setup" element={<SetupWizard />} />

View file

@ -0,0 +1,152 @@
import { useEffect, useRef } from 'react';
// Eigener Mauszeiger im Orbital-Motiv des HifiPlanet-Logos: ein gefuellter markengruener
// Kern, um den ein kleiner "Satellit" auf einer schraeg gestellten Ellipsenbahn kreist
// (wie der Orbit-Schwung im Logo). Ueber interaktiven Elementen weitet sich die Bahn,
// der Satellit beschleunigt und alles leuchtet heller; ein Klick loest einen kurzen
// "Bass-Thump"-Puls aus. Kern UND Bahn folgen der Maus synchron (kein Nachziehen -
// das wirkte bei schnellen Bewegungen wie zwei getrennte Zeiger); nur Umlauf-
// geschwindigkeit und Bahnradius wechseln weich zwischen den Zustaenden.
//
// Aktiv nur auf Geraeten mit echter Maus (pointer: fine) und NICHT bei reduzierter
// Bewegung (prefers-reduced-motion) - sonst bleibt der native Cursor unangetastet.
// Ueber Textfeldern blendet sich der Orbit aus und der native Text-Cursor uebernimmt
// (siehe zugehoerige CSS-Regeln in index.css unter "Custom Cursor").
const INTERACTIVE_SELECTOR =
'a, button, [role="button"], label, select, summary, [data-cursor="hover"], .cursor-pointer';
const TEXTLIKE_SELECTOR =
'input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="submit"]):not([type="button"]):not([type="file"]), textarea, [contenteditable="true"]';
export default function CustomCursor() {
const rootRef = useRef(null);
const coreRef = useRef(null);
const orbitRef = useRef(null);
const satRef = useRef(null);
const pulseRef = useRef(null);
useEffect(() => {
const finePointer = window.matchMedia('(pointer: fine)');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (!finePointer.matches || reducedMotion.matches) return undefined;
const root = rootRef.current;
const core = coreRef.current;
const orbit = orbitRef.current;
const sat = satRef.current;
const pulse = pulseRef.current;
if (!root || !core || !orbit || !sat || !pulse) return undefined;
document.documentElement.classList.add('hifi-cursor-on');
// Bahn-Neigung wie der Orbit-Schwung im Logo.
const TILT = (-24 * Math.PI) / 180;
const cosT = Math.cos(TILT);
const sinT = Math.sin(TILT);
let mouseX = -100;
let mouseY = -100;
let angle = 0;
// Umlaufgeschwindigkeit (rad/s) und Bahnradien werden pro Frame weich in Richtung
// ihrer Zielwerte gezogen, damit Hover-Wechsel nie springen.
let speed = 2.4;
let targetSpeed = 2.4;
let radiusX = 16;
let radiusY = 6.5;
let targetRadiusX = 16;
let targetRadiusY = 6.5;
let visible = false;
let rafId;
let last = performance.now();
const tick = (now) => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
speed += (targetSpeed - speed) * Math.min(1, dt * 8);
radiusX += (targetRadiusX - radiusX) * Math.min(1, dt * 10);
radiusY += (targetRadiusY - radiusY) * Math.min(1, dt * 10);
angle += speed * dt;
core.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
orbit.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
const ex = Math.cos(angle) * radiusX;
const ey = Math.sin(angle) * radiusY;
sat.style.transform = `translate(${ex * cosT - ey * sinT}px, ${ex * sinT + ey * cosT}px)`;
rafId = requestAnimationFrame(tick);
};
const applyState = (state) => {
root.dataset.state = state;
const hovering = state === 'hover';
targetSpeed = hovering ? 7 : 2.4;
targetRadiusX = hovering ? 23 : 16;
targetRadiusY = hovering ? 9.5 : 6.5;
};
const onMouseMove = (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
if (!visible) {
visible = true;
root.dataset.hidden = 'false';
}
};
const onMouseOver = (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
if (target.closest(TEXTLIKE_SELECTOR)) applyState('text');
else if (target.closest(INTERACTIVE_SELECTOR)) applyState('hover');
else applyState('default');
};
const onMouseDown = () => {
root.dataset.down = 'true';
pulse.style.left = `${mouseX}px`;
pulse.style.top = `${mouseY}px`;
// Animation neu anstossen, auch wenn sie gerade noch laeuft.
pulse.classList.remove('is-live');
void pulse.offsetWidth;
pulse.classList.add('is-live');
};
const onMouseUp = () => {
root.dataset.down = 'false';
};
const onLeave = () => {
visible = false;
root.dataset.hidden = 'true';
};
document.addEventListener('mousemove', onMouseMove, { passive: true });
document.addEventListener('mouseover', onMouseOver, { passive: true });
document.addEventListener('mousedown', onMouseDown, { passive: true });
document.addEventListener('mouseup', onMouseUp, { passive: true });
document.documentElement.addEventListener('mouseleave', onLeave);
window.addEventListener('blur', onLeave);
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseover', onMouseOver);
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mouseup', onMouseUp);
document.documentElement.removeEventListener('mouseleave', onLeave);
window.removeEventListener('blur', onLeave);
document.documentElement.classList.remove('hifi-cursor-on');
};
}, []);
return (
<div ref={rootRef} className="hifi-cursor" data-state="default" data-hidden="true" data-down="false" aria-hidden="true">
<div ref={orbitRef} className="hifi-cursor__orbit">
<div className="hifi-cursor__ring" />
<div ref={satRef} className="hifi-cursor__sat" />
</div>
<div ref={coreRef} className="hifi-cursor__core" />
<div ref={pulseRef} className="hifi-cursor__pulse" />
</div>
);
}

View file

@ -35,3 +35,135 @@ html {
.dark .grain-band { .dark .grain-band {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.14 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch' result='noise'/%3E%3CfeColorMatrix in='noise' type='matrix' values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.14 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
} }
/* ==== Custom Cursor (Orbital-Motiv, siehe components/CustomCursor.jsx) ====
Die Klasse hifi-cursor-on setzt NUR die Komponente selbst - und nur auf Geraeten
mit echter Maus ohne reduzierte Bewegung. Ohne die Klasse bleibt ueberall der
native Cursor (Touch, prefers-reduced-motion, JS-Fehler). */
html.hifi-cursor-on,
html.hifi-cursor-on * {
cursor: none !important;
}
/* Ueber Textfeldern uebernimmt der native Text-Cursor (die Orbit-Teile blenden sich
per data-state="text" aus) - Praezision beim Textmarkieren geht vor Show. */
html.hifi-cursor-on :is(input, textarea, [contenteditable='true']) {
cursor: auto !important;
}
.hifi-cursor {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 99999;
opacity: 1;
transition: opacity 0.2s ease;
}
.hifi-cursor[data-hidden='true'] {
opacity: 0;
}
/* Kern: gefuellter Punkt, folgt der Maus direkt. Weisser Rand + Glow machen ihn auf
hellem Papier UND dunklen Kacheln gleichermassen sichtbar. */
.hifi-cursor__core {
position: fixed;
top: 0;
left: 0;
width: 10px;
height: 10px;
margin: -5px 0 0 -5px;
border-radius: 9999px;
background: #7fd44a;
box-shadow:
0 0 0 1.5px rgba(255, 255, 255, 0.9),
0 0 10px rgba(127, 212, 74, 0.75);
transition:
width 0.18s ease,
height 0.18s ease,
margin 0.18s ease,
background-color 0.18s ease,
opacity 0.15s ease;
}
.hifi-cursor__orbit {
position: fixed;
top: 0;
left: 0;
transition: opacity 0.15s ease;
}
/* Schraege Ellipsenbahn wie der Orbit-Schwung im Logo. */
.hifi-cursor__ring {
position: absolute;
width: 38px;
height: 15px;
margin: -7.5px 0 0 -19px;
border: 1px solid rgba(127, 212, 74, 0.5);
border-radius: 50%;
transform: rotate(-24deg);
transition:
transform 0.25s ease,
border-color 0.25s ease;
}
/* Satellit: Position auf der Bahn wird per JS gesetzt (rotierender Punkt). */
.hifi-cursor__sat {
position: absolute;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 9999px;
background: #eaffdb;
box-shadow: 0 0 6px rgba(139, 234, 60, 0.9);
}
/* Hover ueber Interaktivem: Kern waechst, Bahn weitet sich und leuchtet auf
(Satellit beschleunigt via JS). */
.hifi-cursor[data-state='hover'] .hifi-cursor__core {
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
background: #8bea3c;
}
.hifi-cursor[data-state='hover'] .hifi-cursor__ring {
transform: rotate(-24deg) scale(1.45);
border-color: rgba(139, 234, 60, 0.9);
}
/* Ueber Textfeldern: komplett ausblenden, nativer Text-Cursor uebernimmt. */
.hifi-cursor[data-state='text'] .hifi-cursor__core,
.hifi-cursor[data-state='text'] .hifi-cursor__orbit {
opacity: 0;
}
/* Gedrueckte Maustaste: Kern "thumpt" wie eine Subwoofer-Membran. */
.hifi-cursor[data-down='true'] .hifi-cursor__core {
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
}
/* Klick-Puls: einmalig auslaufender Ring am Klickpunkt. */
.hifi-cursor__pulse {
position: fixed;
top: 0;
left: 0;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border-radius: 9999px;
border: 1.5px solid rgba(139, 234, 60, 0.9);
opacity: 0;
}
.hifi-cursor__pulse.is-live {
animation: hifi-cursor-pulse 0.45s ease-out forwards;
}
@keyframes hifi-cursor-pulse {
from {
opacity: 0.9;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(3.2);
}
}