"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import {
motion,
useInView,
useReducedMotion as useReducedMotionRaw,
useScroll,
useSpring,
useTransform,
type Variants,
} from "motion/react";
/**
* Motion's hook returns `boolean | null` — null until the media query
* resolves. Narrow it to a plain boolean so callers can pass it straight
* into props; an unresolved preference means "motion allowed".
*/
export function useReducedMotion(): boolean {
return useReducedMotionRaw() === true;
}
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const EASE_MASK = [0.16, 1, 0.3, 1] as const;
/* ------------------------------------------------------------------ *
* Reveal
* ------------------------------------------------------------------ */
export function Reveal({
children,
delay = 0,
className,
y = 24,
}: {
children: ReactNode;
delay?: number;
className?: string;
y?: number;
}) {
const reduced = useReducedMotion();
return (
{children}
);
}
/** List-item variant, so Reveal never breaks
semantics. */
export function RevealItem({
children,
delay = 0,
className,
}: {
children: ReactNode;
delay?: number;
className?: string;
}) {
const reduced = useReducedMotion();
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* MaskedWords — words rise from behind a clipping mask
* ------------------------------------------------------------------ */
const lineVariants: Variants = {
hidden: {},
shown: { transition: { staggerChildren: 0.055, delayChildren: 0.05 } },
};
const wordVariants: Variants = {
hidden: { y: "108%", rotate: 3, skewY: 2.5 },
shown: {
y: "0%",
rotate: 0,
skewY: 0,
transition: { duration: 0.9, ease: EASE_MASK },
},
};
export function MaskedWords({
words,
className = "",
delay = 0,
}: {
words: ReactNode[];
className?: string;
delay?: number;
}) {
const reduced = useReducedMotion();
if (reduced) {
return (
{words.map((word, index) => (
{word}
{index < words.length - 1 ? " " : ""}
))}
);
}
return (
{words.map((word, index) => (
{word}
{index < words.length - 1 ? : null}
))}
);
}
/* ------------------------------------------------------------------ *
* KineticHeadline — per-character entrance for the hero only
* ------------------------------------------------------------------ */
const charVariants: Variants = {
hidden: { opacity: 0, y: "70%", rotateX: -70 },
shown: {
opacity: 1,
y: "0%",
rotateX: 0,
transition: { duration: 0.75, ease: EASE_MASK },
},
};
/**
* Splits a string into characters and tips each one up into place.
* The visible text is aria-hidden and the whole line is relabelled,
* so screen readers get the sentence, not a stream of letters.
*
* `gradient` keeps `.brand-text` on a single element so the amber fill
* spans the whole word, and animates that element as one unit — per
* character the gradient would restart on every letter, and the clipped
* fill drops out under a 3D rotateX.
*/
export function KineticLine({
text,
className = "",
delay = 0,
stagger = 0.022,
gradient = false,
}: {
text: string;
className?: string;
delay?: number;
stagger?: number;
/** Fill the word with the amber gradient and animate it as one block. */
gradient?: boolean;
}) {
const reduced = useReducedMotion();
if (reduced) {
return (
{text}
);
}
if (gradient) {
return (
{text}
);
}
return (
{Array.from(text).map((char, index) => (
{char === " " ? " " : char}
))}
);
}
/* ------------------------------------------------------------------ *
* ScrollTracking — letter-spacing tightens as the line scrolls through
* ------------------------------------------------------------------ */
export function ScrollTracking({
children,
className = "",
from = 0.06,
to = -0.04,
}: {
children: ReactNode;
className?: string;
/** Letter-spacing in em at entry and at exit. */
from?: number;
to?: number;
}) {
const ref = useRef(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "center center"],
});
const raw = useTransform(scrollYProgress, [0, 1], [from, to]);
const spring = useSpring(raw, { stiffness: 90, damping: 24 });
const letterSpacing = useTransform(spring, (v) => `${v}em`);
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* ScrollStage — the section itself reacts to being scrolled past
* ------------------------------------------------------------------ */
/**
* Wraps a section so it rises and settles on entry, then recedes slightly
* as it leaves — the scroll becomes the animation rather than a trigger
* for one. Kept subtle on purpose: this runs on every section, so anything
* stronger would read as the page wobbling.
*/
export function ScrollStage({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) {
const ref = useRef(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const scale = useSpring(
useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0.97, 1, 1, 0.97]),
{ stiffness: 100, damping: 30, mass: 0.5 },
);
const opacity = useTransform(
scrollYProgress,
[0, 0.14, 0.86, 1],
[0.55, 1, 1, 0.55],
);
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* Parallax
* ------------------------------------------------------------------ */
export function Parallax({
children,
distance = 60,
className,
}: {
children: ReactNode;
distance?: number;
className?: string;
}) {
const ref = useRef(null);
const reduced = useReducedMotion();
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
const y = useSpring(
useTransform(scrollYProgress, [0, 1], [distance, -distance]),
{ stiffness: 120, damping: 28, mass: 0.4 },
);
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* Tilt
* ------------------------------------------------------------------ */
export function Tilt({
children,
className,
max = 5,
}: {
children: ReactNode;
className?: string;
max?: number;
}) {
const ref = useRef(null);
const reduced = useReducedMotion();
useEffect(() => {
const node = ref.current;
if (!node || reduced) return;
if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;
let frame = 0;
const onMove = (event: PointerEvent) => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const rect = node.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
node.style.transform = `perspective(1000px) rotateX(${(-y * max).toFixed(2)}deg) rotateY(${(x * max).toFixed(2)}deg)`;
});
};
const onLeave = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
node.style.transform = "";
};
node.addEventListener("pointermove", onMove);
node.addEventListener("pointerleave", onLeave);
return () => {
node.removeEventListener("pointermove", onMove);
node.removeEventListener("pointerleave", onLeave);
if (frame) cancelAnimationFrame(frame);
};
}, [max, reduced]);
return (
{children}
);
}
/* ------------------------------------------------------------------ *
* Counter
* ------------------------------------------------------------------ */
export function Counter({
to,
suffix = "",
prefix = "",
decimals = 0,
duration = 1400,
className,
}: {
to: number;
suffix?: string;
prefix?: string;
decimals?: number;
duration?: number;
className?: string;
}) {
const ref = useRef(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, amount: 0.4 });
const [progressValue, setProgressValue] = useState(0);
const value = reduced ? to : progressValue;
useEffect(() => {
if (!inView || reduced) return;
let frame = 0;
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
setProgressValue(to * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [inView, reduced, to, duration]);
return (
{prefix}
{value.toLocaleString("de-DE", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}
{suffix}
);
}
/* ------------------------------------------------------------------ *
* ScrollProgress
* ------------------------------------------------------------------ */
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 180,
damping: 32,
restDelta: 0.001,
});
return (
);
}