Files
entscheidomat/components/preloader.tsx
2026-07-23 21:59:15 +02:00

149 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useRef, useState } from "react";
import { gsap } from "@/lib/gsap";
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
const CURL_PATH =
"M 100 30 C 88 30, 76 26, 66 22 C 56 18, 46 18, 40 24 C 34 30, 38 40, 48 40 C 56 40, 60 32, 54 28 C 49 25, 43 29, 46 34";
const SESSION_KEY = "entscheidomat:preloader-seen";
/**
* Minimalistische Ladeanimation: ein Kalligraphie-Ornament,
* das sich selbst zeichnet, danach hebt sich der Vorhang.
* Feuert "app:reveal", sobald die Seite sichtbar wird.
*/
export default function Preloader() {
const reduced = usePrefersReducedMotion();
const [animationDone, setAnimationDone] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
const leftCurlRef = useRef<SVGPathElement>(null);
const rightCurlRef = useRef<SVGPathElement>(null);
const dotRef = useRef<SVGCircleElement>(null);
const wordRef = useRef<HTMLParagraphElement>(null);
const hasStartedRef = useRef(false);
const reveal = () => {
document.documentElement.dataset.preloader = "complete";
window.dispatchEvent(new Event("app:reveal"));
};
// Reduced Motion: keine Animation, aber Reveal-Event trotzdem dispatchen
// rein imperativ, kein setState, deshalb kein separater Effekt-Zweig nötig.
useEffect(() => {
let hasSeenPreloader = false;
try {
hasSeenPreloader = window.sessionStorage.getItem(SESSION_KEY) === "1";
} catch {
// Falls Browser-Speicher blockiert ist, bleibt der Loader funktional.
}
const shouldSkip = document.documentElement.dataset.preloader === "cached";
if (reduced || shouldSkip || (hasSeenPreloader && !hasStartedRef.current)) {
setAnimationDone(true);
reveal();
return;
}
hasStartedRef.current = true;
try {
window.sessionStorage.setItem(SESSION_KEY, "1");
} catch {
// Die Animation darf nicht von verfügbarem Browser-Speicher abhängen.
}
document.documentElement.style.overflow = "hidden";
const duration = window.matchMedia("(max-width: 767px)").matches ? 0.45 : 1;
const curls = [leftCurlRef.current, rightCurlRef.current].filter(
Boolean,
) as SVGPathElement[];
const lengths = curls.map((p) => p.getTotalLength());
curls.forEach((p, i) => {
p.style.strokeDasharray = `${lengths[i]}`;
p.style.strokeDashoffset = `${lengths[i]}`;
});
const tl = gsap.timeline({
onComplete: () => {
document.documentElement.style.overflow = "";
setAnimationDone(true);
reveal();
},
});
tl.to(curls, {
strokeDashoffset: 0,
duration: 0.35 * duration,
ease: "power2.inOut",
stagger: 0.05 * duration,
})
.fromTo(
dotRef.current,
{ scale: 0, transformOrigin: "50% 50%" },
{ scale: 1, duration: 0.15 * duration, ease: "back.out(3)" },
`-=${0.12 * duration}`,
)
.fromTo(
wordRef.current,
{ opacity: 0, y: 10, letterSpacing: "0.5em" },
{ opacity: 1, y: 0, letterSpacing: "0.28em", duration: 0.22 * duration, ease: "power2.out" },
`-=${0.1 * duration}`,
)
.to(overlayRef.current, {
yPercent: -100,
duration: 0.4 * duration,
ease: "power4.inOut",
delay: 0.08 * duration,
});
return () => {
document.documentElement.style.overflow = "";
tl.kill();
};
}, [reduced]);
if (reduced || animationDone) return null;
return (
<div
ref={overlayRef}
aria-hidden
data-preloader-overlay
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-paper"
>
<svg
width="220"
height="66"
viewBox="0 0 200 60"
fill="none"
stroke="#161513"
strokeWidth="1.6"
strokeLinecap="round"
>
<path ref={leftCurlRef} d={CURL_PATH} />
<path
ref={rightCurlRef}
d={CURL_PATH}
transform="rotate(180 100 30)"
/>
<circle
ref={dotRef}
cx="100"
cy="30"
r="1.8"
fill="#161513"
stroke="none"
/>
</svg>
<p
ref={wordRef}
className="mt-6 text-[11px] font-semibold uppercase tracking-[0.28em] text-ink-soft opacity-0"
>
Entscheidomat
</p>
</div>
);
}