"use client";
import {
Children,
isValidElement,
memo,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import {
motion,
useMotionTemplate,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
export interface DepthStageProps {
accentColor?: string;
depth?: number;
tiltMax?: number;
entrance?: boolean;
float?: boolean;
spotlight?: boolean;
depthOfField?: boolean;
reducedMotion?: boolean;
className?: string;
children?: ReactNode;
}
function layerDepthOf(child: ReactNode): number {
if (!isValidElement(child)) return 0.35;
const raw = (child.props as Record<string, unknown>)["data-depth"];
const n = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(n) ? Math.max(-1.5, Math.min(1.5, n)) : 0.35;
}
const DepthStage = memo(
({
accentColor = "#a855f7",
depth = 40,
tiltMax = 3.5,
entrance = true,
float = true,
spotlight = true,
depthOfField = true,
reducedMotion = false,
className,
children,
}: DepthStageProps) => {
const [animName] = useState(
() => `__sg_depth_float_${Math.random().toString(36).slice(2, 8)}`,
);
const px = useSpring(0, { stiffness: 110, damping: 16, mass: 0.45 });
const py = useSpring(0, { stiffness: 110, damping: 16, mass: 0.45 });
const lx = useTransform(px, (v) => 50 + v * 40);
const ly = useTransform(py, (v) => 50 - v * 40);
const light = useMotionTemplate`radial-gradient(40% 40% at ${lx}% ${ly}%, ${accentColor}2e 0%, transparent 100%)`;
const layers = Children.toArray(children);
const track = (e: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion || depth === 0) return;
const r = e.currentTarget.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
px.set((((e.clientX - r.left) / r.width) * 2 - 1));
py.set(-(((e.clientY - r.top) / r.height) * 2 - 1));
};
const rest = () => {
px.set(0);
py.set(0);
};
return (
<div
className={
className ??
"relative h-full min-h-80 w-full select-none overflow-hidden"
}
onPointerMove={track}
onPointerLeave={rest}
style={{
backgroundImage: `radial-gradient(120% 90% at 50% 110%, ${accentColor}30 0%, transparent 55%), radial-gradient(70% 60% at 78% 12%, ${accentColor}1f 0%, transparent 60%)`,
}}
>
{float && !reducedMotion ? (
<style>{`
@keyframes ${animName} {
from { transform: translateY(-5px); }
to { transform: translateY(5px); }
}
`}</style>
) : null}
{spotlight ? (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ backgroundImage: light }}
/>
) : null}
<div className="absolute inset-0">
{layers.map((child, i) => (
<DepthLayer
key={i}
childDepth={layerDepthOf(child)}
travel={depth}
tiltMax={reducedMotion ? 0 : tiltMax}
reducedMotion={reducedMotion}
entranceEnabled={entrance}
index={i}
animName={animName}
floatEnabled={float}
dof={depthOfField}
px={px}
py={py}
>
{child}
</DepthLayer>
))}
</div>
</div>
);
},
);
function DepthLayer({
childDepth,
travel,
tiltMax,
reducedMotion,
entranceEnabled,
floatEnabled,
dof,
index,
animName,
px,
py,
children,
}: {
childDepth: number;
travel: number;
tiltMax: number;
reducedMotion: boolean;
entranceEnabled: boolean;
floatEnabled: boolean;
dof: boolean;
index: number;
animName: string;
px: MotionValue<number>;
py: MotionValue<number>;
children: ReactNode;
}) {
const x = useTransform(px, (v) => v * childDepth * travel * 0.5);
const y = useTransform(py, (v) => v * childDepth * travel * 0.5);
const z = -childDepth * travel * 4;
const rotY = useTransform(px, (v) => v * tiltMax);
const rotX = useTransform(py, (v) => -v * tiltMax);
const transform = useMotionTemplate`perspective(1000px) rotateX(${rotX}deg) rotateY(${rotY}deg) translate3d(${x}px, ${y}px, ${z}px)`;
const blur = dof ? Math.max(0, Math.abs(childDepth) - 0.45) * 3 : 0;
const shouldEnter = entranceEnabled && !reducedMotion;
const shouldFloat = floatEnabled && !reducedMotion;
const innerStyle: CSSProperties = {
...(shouldFloat
? {
animation: `${animName} ${3.6 + (index % 4) * 0.7}s ease-in-out ${
index % 2 ? "alternate" : "alternate-reverse"
} infinite`,
}
: {}),
...(blur > 0 ? { filter: `blur(${blur.toFixed(2)}px)` } : {}),
};
return (
<motion.div
className="pointer-events-none absolute inset-0"
initial={shouldEnter ? { opacity: 0, y: 28, scale: 0.94 } : false}
animate={shouldEnter ? { opacity: 1, y: 0, scale: 1 } : undefined}
transition={{
duration: 0.8,
delay: shouldEnter ? 0.09 * index : 0,
ease: [0.22, 1, 0.36, 1],
}}
>
<motion.div className="h-full w-full" style={{ transform }}>
<div className="h-full w-full" style={innerStyle}>
{children}
</div>
</motion.div>
</motion.div>
);
}
DepthStage.displayName = "DepthStage";
export default DepthStage;