Glow-Überschriften: Fix A aus der Hover-Analyse

Der Glow der Überschriften (Leistungen, Ablauf) wacht jetzt etwa 180 px
vor der SVG-Box auf (Zeiger-Tracking am window, nur feine Zeiger), bildet
den Mauszeiger über getScreenCTM korrekt auf die viewBox ab, blendet
weich per Opacity ein und aus und klebt auf Touch-Geräten nicht mehr.
Screenreader hören das Wort einmal (role=img mit aria-label, Textknoten
aria-hidden). Der Diff aus /root/reports/webklar-relaunch-2026-10-01/
hover/fix/text-hover-effect.fixA.diff ließ sich unverändert anwenden.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-01 10:43:43 +00:00
1 parent 0d12bbc06a
commit 194301c6af
2 files changed
+179 -25

No files matched your search

+65 -25
View File
@@ -1,8 +1,12 @@
"use client";
import { useRef, useState, useCallback, useId } from "react";
import { useRef, useState, useEffect, useId } from "react";
import { motion } from "motion/react";
// Wie weit (in CSS-px) um die SVG-Box herum der Mauszeiger den Glow schon weckt.
// Der Glow-Radius ist 80 viewBox-Einheiten (~300 px), dahinter ist er ohnehin schwarz.
const WAKE_DISTANCE_PX = 260;
export const TextHoverEffect = ({
text,
}: {
@@ -10,24 +14,64 @@ export const TextHoverEffect = ({
duration?: number;
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const [hovered, setHovered] = useState(false);
const [active, setActive] = useState(false);
const [cursorPos, setCursorPos] = useState({ x: 150, y: 30 });
const id = useId();
const gradientId = `textGradient-${id}`;
const revealMaskId = `revealMask-${id}`;
const textMaskId = `textMask-${id}`;
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
// Map screen coords to viewBox coords (0 0 300 60)
const x = ((e.clientX - rect.left) / rect.width) * 300;
const y = ((e.clientY - rect.top) / rect.height) * 60;
setCursorPos({ x, y });
},
[]
);
useEffect(() => {
const svg = svgRef.current;
// Nur echte Maus: Touch hat kein Hover und wuerde den Glow "kleben" lassen.
if (!svg || !window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
let last: { x: number; y: number } | null = null;
let raf = 0;
let isActive = false;
const update = () => {
raf = 0;
if (!last) return;
const r = svg.getBoundingClientRect();
const dx = Math.max(r.left - last.x, 0, last.x - r.right);
const dy = Math.max(r.top - last.y, 0, last.y - r.bottom);
const near = Math.hypot(dx, dy) <= WAKE_DISTANCE_PX;
if (near !== isActive) {
isActive = near;
setActive(near);
}
const m = svg.getScreenCTM();
if (near && m) {
// echte Bildschirm -> viewBox-Abbildung (beruecksichtigt "meet"-Letterbox)
const p = new DOMPoint(last.x, last.y).matrixTransform(m.inverse());
setCursorPos({ x: p.x, y: p.y });
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update);
};
const onMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
last = { x: e.clientX, y: e.clientY };
schedule();
};
const onLeave = () => {
last = null;
isActive = false;
setActive(false);
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("scroll", schedule, { passive: true });
document.documentElement.addEventListener("mouseleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("scroll", schedule);
document.documentElement.removeEventListener("mouseleave", onLeave);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return (
<svg
@@ -36,9 +80,8 @@ export const TextHoverEffect = ({
height="100%"
viewBox="0 0 300 60"
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
role="img"
aria-label={text}
className="select-none"
>
<defs>
@@ -62,13 +105,7 @@ export const TextHoverEffect = ({
</radialGradient>
<mask id={textMaskId}>
<rect
x="0"
y="0"
width="300"
height="60"
fill={hovered ? `url(#${revealMaskId})` : "black"}
/>
<rect x="0" y="0" width="300" height="60" fill={`url(#${revealMaskId})`} />
</mask>
</defs>
@@ -79,6 +116,7 @@ export const TextHoverEffect = ({
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ opacity: 0.15, fontSize: "2rem" }}
>
@@ -92,6 +130,7 @@ export const TextHoverEffect = ({
textAnchor="middle"
dominantBaseline="middle"
strokeWidth="0.3"
aria-hidden="true"
className="font-display font-bold fill-transparent stroke-neutral-200 dark:stroke-neutral-800"
style={{ fontSize: "2rem" }}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
@@ -101,7 +140,7 @@ export const TextHoverEffect = ({
{text}
</motion.text>
{/* Colored gradient revealed on hover */}
{/* Colored gradient revealed near the pointer */}
<text
x="150"
y="30"
@@ -110,8 +149,9 @@ export const TextHoverEffect = ({
stroke={`url(#${gradientId})`}
strokeWidth="0.3"
mask={`url(#${textMaskId})`}
aria-hidden="true"
className="font-display font-bold fill-transparent"
style={{ fontSize: "2rem" }}
style={{ fontSize: "2rem", opacity: active ? 1 : 0, transition: "opacity 300ms ease-out" }}
>
{text}
</text>