mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 19:51:17 +00:00
488 lines
12 KiB
TypeScript
488 lines
12 KiB
TypeScript
"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 (
|
||
<motion.div
|
||
className={className}
|
||
initial={reduced ? false : { opacity: 0, y }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
|
||
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/** List-item variant, so Reveal never breaks <ul> semantics. */
|
||
export function RevealItem({
|
||
children,
|
||
delay = 0,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
delay?: number;
|
||
className?: string;
|
||
}) {
|
||
const reduced = useReducedMotion();
|
||
|
||
return (
|
||
<motion.li
|
||
className={className}
|
||
initial={reduced ? false : { opacity: 0, y: 24 }}
|
||
whileInView={{ opacity: 1, y: 0 }}
|
||
viewport={{ once: true, amount: 0.2, margin: "0px 0px -8% 0px" }}
|
||
transition={{ duration: 0.7, delay, ease: EASE_OUT }}
|
||
>
|
||
{children}
|
||
</motion.li>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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 (
|
||
<span className={className}>
|
||
{words.map((word, index) => (
|
||
<span key={index}>
|
||
{word}
|
||
{index < words.length - 1 ? " " : ""}
|
||
</span>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<motion.span
|
||
className={className}
|
||
variants={lineVariants}
|
||
initial="hidden"
|
||
whileInView="shown"
|
||
viewport={{ once: true, amount: 0.4 }}
|
||
transition={{ delayChildren: delay }}
|
||
>
|
||
{words.map((word, index) => (
|
||
<span
|
||
key={index}
|
||
className="inline-flex overflow-hidden pb-[0.12em] align-bottom"
|
||
>
|
||
<motion.span variants={wordVariants} className="inline-block">
|
||
{word}
|
||
</motion.span>
|
||
{index < words.length - 1 ? <span className="w-[0.26em]" /> : null}
|
||
</span>
|
||
))}
|
||
</motion.span>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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 (
|
||
<span className={`${gradient ? "brand-text" : ""} ${className}`}>
|
||
{text}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (gradient) {
|
||
return (
|
||
<motion.span
|
||
aria-hidden
|
||
// 2D only: rotateX would break background-clip:text.
|
||
className={`brand-text inline-block ${className}`}
|
||
initial={{ opacity: 0, y: "55%" }}
|
||
whileInView={{ opacity: 1, y: "0%" }}
|
||
viewport={{ once: true, amount: 0.3 }}
|
||
transition={{ duration: 0.75, delay, ease: EASE_MASK }}
|
||
>
|
||
{text}
|
||
</motion.span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<motion.span
|
||
aria-hidden
|
||
className={`inline-block ${className}`}
|
||
style={{ perspective: 600 }}
|
||
variants={{
|
||
hidden: {},
|
||
shown: { transition: { staggerChildren: stagger, delayChildren: delay } },
|
||
}}
|
||
initial="hidden"
|
||
whileInView="shown"
|
||
viewport={{ once: true, amount: 0.3 }}
|
||
>
|
||
{Array.from(text).map((char, index) => (
|
||
<motion.span
|
||
key={index}
|
||
variants={charVariants}
|
||
className="inline-block origin-bottom"
|
||
style={{ transformStyle: "preserve-3d" }}
|
||
>
|
||
{char === " " ? " " : char}
|
||
</motion.span>
|
||
))}
|
||
</motion.span>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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<HTMLDivElement>(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 (
|
||
<motion.div
|
||
ref={ref}
|
||
className={className}
|
||
style={reduced ? undefined : { letterSpacing }}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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<HTMLDivElement>(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 (
|
||
<motion.div
|
||
ref={ref}
|
||
className={className}
|
||
style={reduced ? undefined : { scale, opacity }}
|
||
>
|
||
{children}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Parallax
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export function Parallax({
|
||
children,
|
||
distance = 60,
|
||
className,
|
||
}: {
|
||
children: ReactNode;
|
||
distance?: number;
|
||
className?: string;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(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 (
|
||
<motion.div ref={ref} className={className} style={reduced ? undefined : { y }}>
|
||
{children}
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Tilt
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export function Tilt({
|
||
children,
|
||
className,
|
||
max = 5,
|
||
}: {
|
||
children: ReactNode;
|
||
className?: string;
|
||
max?: number;
|
||
}) {
|
||
const ref = useRef<HTMLDivElement>(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 (
|
||
<div
|
||
ref={ref}
|
||
className={className}
|
||
style={{ transition: "transform 0.4s cubic-bezier(0.22,1,0.36,1)" }}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 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<HTMLSpanElement>(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 (
|
||
<span ref={ref} className={className}>
|
||
{prefix}
|
||
{value.toLocaleString("de-DE", {
|
||
minimumFractionDigits: decimals,
|
||
maximumFractionDigits: decimals,
|
||
})}
|
||
{suffix}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* ScrollProgress
|
||
* ------------------------------------------------------------------ */
|
||
|
||
export function ScrollProgress() {
|
||
const { scrollYProgress } = useScroll();
|
||
const scaleX = useSpring(scrollYProgress, {
|
||
stiffness: 180,
|
||
damping: 32,
|
||
restDelta: 0.001,
|
||
});
|
||
|
||
return (
|
||
<motion.div
|
||
aria-hidden
|
||
className="fixed inset-x-0 top-0 z-[60] h-[3px] origin-left"
|
||
style={{ scaleX, background: "var(--gradient-amber)" }}
|
||
/>
|
||
);
|
||
}
|