Kumasy-Marketing/app/components/motion.tsx

504 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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, 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
* ------------------------------------------------------------------ */
/**
* Splits into words that keep their trailing space, so `"von "` still
* renders the gap before whatever follows it. Never breaks mid-word.
*/
function splitWords(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [];
}
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 }}
>
{/* Grouped per word, not per character: a bare list of inline-block
letters lets the browser break inside a word ("fülle/n"), which it
does on narrow phones. Each word is whitespace-nowrap, so it travels
as one unit while the stagger still runs across the whole line. */}
{splitWords(text).map((word, wordIndex) => (
<span key={wordIndex} className="inline-block whitespace-nowrap">
{Array.from(word).map((char, index) => (
<motion.span
key={index}
variants={charVariants}
className="inline-block origin-bottom"
style={{ transformStyle: "preserve-3d" }}
>
{char === " " ? " " : char}
</motion.span>
))}
</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)" }}
/>
);
}