Die BorderGlow-Karten in Values, Kontaktabschnitt und Über uns hatten dunkle Hintergründe fest verdrahtet, ihr Text nutzt aber Theme-Farben (Kontrast 1,07:1 im hellen Modus). BorderGlow kennt jetzt lightBackgroundColor und wählt die Kartenfarbe per CSS nach Theme (--card-bg-dark / --card-bg-light, html:not(.dark)), ohne Flackern beim Laden. Dunkel behält exakt die bisherigen Farben (hsl(0 0% 6%), #120F17, hsl(0 0% 4%)); hell nutzt hsl(0 0% 96%) mit sichtbarem Rand. Der Kantenschein wird im hellen Modus normal statt mit plus-lighter gemischt und in Violett gezeichnet, sonst wäre er auf Weiß unsichtbar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
import { useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||
import './BorderGlow.css';
|
||
|
||
function parseHSL(hslStr: string) {
|
||
const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/);
|
||
if (!match) return { h: 40, s: 80, l: 80 };
|
||
return { h: parseFloat(match[1]), s: parseFloat(match[2]), l: parseFloat(match[3]) };
|
||
}
|
||
|
||
function buildGlowVars(glowColor: string, intensity: number) {
|
||
const { h, s, l } = parseHSL(glowColor);
|
||
const base = `${h}deg ${s}% ${l}%`;
|
||
const opacities = [100, 60, 50, 40, 30, 20, 10];
|
||
const keys = ['', '-60', '-50', '-40', '-30', '-20', '-10'];
|
||
const vars: Record<string, string> = {};
|
||
for (let i = 0; i < opacities.length; i++) {
|
||
vars[`--glow-color${keys[i]}`] = `hsl(${base} / ${Math.min(opacities[i] * intensity, 100)}%)`;
|
||
}
|
||
return vars;
|
||
}
|
||
|
||
const GRADIENT_POSITIONS = ['80% 55%', '69% 34%', '8% 6%', '41% 38%', '86% 85%', '82% 18%', '51% 4%'];
|
||
const GRADIENT_KEYS = ['--gradient-one', '--gradient-two', '--gradient-three', '--gradient-four', '--gradient-five', '--gradient-six', '--gradient-seven'];
|
||
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1];
|
||
|
||
function buildGradientVars(colors: string[]) {
|
||
const vars: Record<string, string> = {};
|
||
for (let i = 0; i < 7; i++) {
|
||
const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)];
|
||
vars[GRADIENT_KEYS[i]] = `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`;
|
||
}
|
||
vars['--gradient-base'] = `linear-gradient(${colors[0]} 0 100%)`;
|
||
return vars;
|
||
}
|
||
|
||
function easeOutCubic(x: number) { return 1 - Math.pow(1 - x, 3); }
|
||
function easeInCubic(x: number) { return x * x * x; }
|
||
|
||
function animateValue({ start = 0, end = 100, duration = 1000, delay = 0, ease = easeOutCubic, onUpdate, onEnd }: {
|
||
start?: number; end?: number; duration?: number; delay?: number;
|
||
ease?: (x: number) => number; onUpdate: (v: number) => void; onEnd?: () => void;
|
||
}) {
|
||
const t0 = performance.now() + delay;
|
||
function tick() {
|
||
const elapsed = performance.now() - t0;
|
||
const t = Math.min(elapsed / duration, 1);
|
||
onUpdate(start + (end - start) * ease(t));
|
||
if (t < 1) requestAnimationFrame(tick);
|
||
else if (onEnd) onEnd();
|
||
}
|
||
setTimeout(() => requestAnimationFrame(tick), delay);
|
||
}
|
||
|
||
interface BorderGlowProps {
|
||
children: ReactNode;
|
||
className?: string;
|
||
edgeSensitivity?: number;
|
||
glowColor?: string;
|
||
/** Kartenfarbe im dunklen Modus (und Standard, wenn lightBackgroundColor fehlt). */
|
||
backgroundColor?: string;
|
||
/** Kartenfarbe im hellen Modus; ohne Angabe gilt backgroundColor in beiden Themes. */
|
||
lightBackgroundColor?: string;
|
||
borderRadius?: number;
|
||
glowRadius?: number;
|
||
glowIntensity?: number;
|
||
coneSpread?: number;
|
||
animated?: boolean;
|
||
colors?: string[];
|
||
fillOpacity?: number;
|
||
}
|
||
|
||
const BorderGlow = ({
|
||
children,
|
||
className = '',
|
||
edgeSensitivity = 30,
|
||
glowColor = '40 80 80',
|
||
backgroundColor = '#120F17',
|
||
lightBackgroundColor,
|
||
borderRadius = 28,
|
||
glowRadius = 40,
|
||
glowIntensity = 1.0,
|
||
coneSpread = 25,
|
||
animated = false,
|
||
colors = ['#c084fc', '#f472b6', '#38bdf8'],
|
||
fillOpacity = 0.5,
|
||
}: BorderGlowProps) => {
|
||
const cardRef = useRef<HTMLDivElement>(null);
|
||
|
||
const getCenterOfElement = useCallback((el: HTMLElement) => {
|
||
const { width, height } = el.getBoundingClientRect();
|
||
return [width / 2, height / 2];
|
||
}, []);
|
||
|
||
const getEdgeProximity = useCallback((el: HTMLElement, x: number, y: number) => {
|
||
const [cx, cy] = getCenterOfElement(el);
|
||
const dx = x - cx;
|
||
const dy = y - cy;
|
||
let kx = Infinity;
|
||
let ky = Infinity;
|
||
if (dx !== 0) kx = cx / Math.abs(dx);
|
||
if (dy !== 0) ky = cy / Math.abs(dy);
|
||
return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1);
|
||
}, [getCenterOfElement]);
|
||
|
||
const getCursorAngle = useCallback((el: HTMLElement, x: number, y: number) => {
|
||
const [cx, cy] = getCenterOfElement(el);
|
||
const dx = x - cx;
|
||
const dy = y - cy;
|
||
if (dx === 0 && dy === 0) return 0;
|
||
const radians = Math.atan2(dy, dx);
|
||
let degrees = radians * (180 / Math.PI) + 90;
|
||
if (degrees < 0) degrees += 360;
|
||
return degrees;
|
||
}, [getCenterOfElement]);
|
||
|
||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||
const card = cardRef.current;
|
||
if (!card) return;
|
||
const rect = card.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const y = e.clientY - rect.top;
|
||
const edge = getEdgeProximity(card, x, y);
|
||
const angle = getCursorAngle(card, x, y);
|
||
card.style.setProperty('--edge-proximity', `${(edge * 100).toFixed(3)}`);
|
||
card.style.setProperty('--cursor-angle', `${angle.toFixed(3)}deg`);
|
||
}, [getEdgeProximity, getCursorAngle]);
|
||
|
||
useEffect(() => {
|
||
if (!animated || !cardRef.current) return;
|
||
const card = cardRef.current;
|
||
const angleStart = 110;
|
||
const angleEnd = 465;
|
||
let cancelled = false;
|
||
let running = false;
|
||
let visible = typeof IntersectionObserver === 'undefined';
|
||
|
||
function runSweep() {
|
||
if (cancelled || !card) return;
|
||
// Nur sweepen, solange die Karte im Bild ist – sonst malt der Browser
|
||
// Maske, Verläufe und Schatten in jedem Frame umsonst neu
|
||
if (!visible) {
|
||
running = false;
|
||
return;
|
||
}
|
||
running = true;
|
||
card.classList.add('sweep-active');
|
||
card.style.setProperty('--cursor-angle', `${angleStart}deg`);
|
||
animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) });
|
||
animateValue({
|
||
ease: easeInCubic, duration: 1500, end: 50,
|
||
onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); }
|
||
});
|
||
animateValue({
|
||
ease: easeOutCubic, delay: 1500, duration: 2250, start: 50, end: 100,
|
||
onUpdate: v => { card.style.setProperty('--cursor-angle', `${(angleEnd - angleStart) * (v / 100) + angleStart}deg`); }
|
||
});
|
||
animateValue({
|
||
ease: easeInCubic, delay: 2500, duration: 1500, start: 100, end: 0,
|
||
onUpdate: v => card.style.setProperty('--edge-proximity', String(v)),
|
||
onEnd: () => {
|
||
card.classList.remove('sweep-active');
|
||
if (!cancelled) setTimeout(runSweep, 800);
|
||
},
|
||
});
|
||
}
|
||
|
||
if (visible) {
|
||
runSweep();
|
||
return () => { cancelled = true; };
|
||
}
|
||
const observer = new IntersectionObserver(([entry]) => {
|
||
visible = entry.isIntersecting;
|
||
if (visible && !running) runSweep();
|
||
});
|
||
observer.observe(card);
|
||
return () => {
|
||
cancelled = true;
|
||
observer.disconnect();
|
||
};
|
||
}, [animated]);
|
||
|
||
const glowVars = buildGlowVars(glowColor, glowIntensity);
|
||
|
||
return (
|
||
<div
|
||
ref={cardRef}
|
||
onPointerMove={animated ? undefined : handlePointerMove}
|
||
className={`border-glow-card ${className}`}
|
||
style={{
|
||
'--card-bg-dark': backgroundColor,
|
||
'--card-bg-light': lightBackgroundColor ?? backgroundColor,
|
||
'--edge-sensitivity': edgeSensitivity,
|
||
'--border-radius': `${borderRadius}px`,
|
||
'--glow-padding': `${glowRadius}px`,
|
||
'--cone-spread': coneSpread,
|
||
'--fill-opacity': fillOpacity,
|
||
...glowVars,
|
||
...buildGradientVars(colors),
|
||
} as React.CSSProperties}
|
||
>
|
||
<span className="edge-light" />
|
||
<div className="border-glow-inner">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default BorderGlow;
|