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 ScrollToTop from './components/ScrollToTop.jsx';
|
||||
import CustomCursor from './components/CustomCursor.jsx';
|
||||
import SetupGate from './components/SetupGate.jsx';
|
||||
import PublicLayout from './components/PublicLayout.jsx';
|
||||
import AdminLayout from './components/AdminLayout.jsx';
|
||||
|
|
@ -48,6 +49,7 @@ export default function App() {
|
|||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<CustomCursor />
|
||||
<SetupGate>
|
||||
<Routes>
|
||||
<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 {
|
||||
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