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:
parent
f40866276e
commit
fe751dce5f
3 changed files with 286 additions and 0 deletions
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
152
hifi-src/src/components/CustomCursor.jsx
Normal file
152
hifi-src/src/components/CustomCursor.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue