129 lines
3 KiB
TypeScript
129 lines
3 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* In-view reveal — the page's core entrance vocabulary.
|
||
* - Default: lift + fade + a touch of blur(4px -> 0) so content resolves INTO
|
||
* place instead of just sliding (Emil's blur-masked entrance). Never appears
|
||
* from nothing.
|
||
* - variant="clip": a premium clip-path inset() wipe (bottom -> full) for
|
||
* section/media reveals, paired with a small lift.
|
||
* - `stagger` cascades direct <RevealItem> children via variants (30–80ms).
|
||
* - Custom EASE_OUT curve everywhere; honors prefers-reduced-motion (keeps
|
||
* opacity/color, drops all movement + blur).
|
||
*/
|
||
import { motion, type Variants, useReducedMotion } from "motion/react";
|
||
import { EASE_OUT } from "./motion";
|
||
|
||
type Props = {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
delay?: number;
|
||
y?: number;
|
||
stagger?: number;
|
||
variant?: "lift" | "clip";
|
||
as?:
|
||
| "div"
|
||
| "section"
|
||
| "ul"
|
||
| "ol"
|
||
| "li"
|
||
| "p"
|
||
| "figure"
|
||
| "header"
|
||
| "span"
|
||
| "h2";
|
||
};
|
||
|
||
const VIEWPORT = { once: true, margin: "0px 0px -12% 0px" } as const;
|
||
|
||
export default function Reveal({
|
||
children,
|
||
className,
|
||
delay = 0,
|
||
y = 28,
|
||
stagger,
|
||
variant = "lift",
|
||
as = "div",
|
||
}: Props) {
|
||
const reduce = useReducedMotion();
|
||
const MotionTag = motion[as] as typeof motion.div;
|
||
|
||
if (stagger) {
|
||
const parent: Variants = {
|
||
hidden: {},
|
||
show: {
|
||
transition: {
|
||
staggerChildren: reduce ? 0 : stagger,
|
||
delayChildren: delay / 1000,
|
||
},
|
||
},
|
||
};
|
||
return (
|
||
<MotionTag
|
||
className={className}
|
||
variants={parent}
|
||
initial="hidden"
|
||
whileInView="show"
|
||
viewport={VIEWPORT}
|
||
>
|
||
{children}
|
||
</MotionTag>
|
||
);
|
||
}
|
||
|
||
const hidden =
|
||
variant === "clip"
|
||
? reduce
|
||
? { opacity: 1 }
|
||
: { opacity: 0, y: y * 0.5, clipPath: "inset(0 0 100% 0)" }
|
||
: reduce
|
||
? { opacity: 1 }
|
||
: { opacity: 0, y, filter: "blur(4px)" };
|
||
|
||
const shown =
|
||
variant === "clip"
|
||
? { opacity: 1, y: 0, clipPath: "inset(0 0 0% 0)" }
|
||
: { opacity: 1, y: 0, filter: "blur(0px)" };
|
||
|
||
return (
|
||
<MotionTag
|
||
className={className}
|
||
initial={hidden}
|
||
whileInView={shown}
|
||
viewport={VIEWPORT}
|
||
transition={{ duration: 0.9, delay: delay / 1000, ease: EASE_OUT }}
|
||
>
|
||
{children}
|
||
</MotionTag>
|
||
);
|
||
}
|
||
|
||
/** A child item that participates in a parent <Reveal stagger>. */
|
||
export function RevealItem({
|
||
children,
|
||
className,
|
||
y = 24,
|
||
as = "div",
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
y?: number;
|
||
as?: "div" | "li" | "p";
|
||
}) {
|
||
const reduce = useReducedMotion();
|
||
const MotionTag = motion[as] as typeof motion.div;
|
||
const item: Variants = {
|
||
hidden: reduce ? { opacity: 1 } : { opacity: 0, y, filter: "blur(4px)" },
|
||
show: {
|
||
opacity: 1,
|
||
y: 0,
|
||
filter: "blur(0px)",
|
||
transition: { duration: 0.75, ease: EASE_OUT },
|
||
},
|
||
};
|
||
return (
|
||
<MotionTag className={className} variants={item}>
|
||
{children}
|
||
</MotionTag>
|
||
);
|
||
}
|