"use client";
import * as React from "react";
import { motion, useReducedMotion, useScroll, useTransform, type MotionValue } from "motion/react";
import { cn } from "@/lib/utils";
export interface ParallaxLayer {
id: string;
/** Fraction of the scene height this layer travels upward over the scroll (negative = downward). */
speed: number;
content: React.ReactNode;
className?: string;
/** Fade the layer out as the scene scrolls away. */
fade?: boolean;
}
export interface ParallaxLayersProps {
/** Layers from back to front. Defaults to a night-sky / sunset hill scene that follows the theme. */
layers?: ParallaxLayer[];
/** Foreground content (headline, CTA) rendered above the layers. */
children?: React.ReactNode;
/** How fast the foreground content moves. */
contentSpeed?: number;
/** Height of the visible scene (any CSS length). */
height?: string;
/** Scroll distance while the scene is pinned, as a multiple of `height`. */
scrollLength?: number;
/** Scrollable ancestor to track (defaults to the window). */
container?: React.RefObject<HTMLElement | null>;
className?: string;
}
/* ---------- deterministic scene geometry (no Math.random → hydration safe) ---------- */
function seeded(seed: number) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hillPath(seed: number, amp: number, waves: number) {
const rnd = seeded(seed);
const phase = rnd() * Math.PI * 2;
const pts: [number, number][] = [];
for (let x = 0; x <= 1440; x += 60) {
const t = (x / 1440) * Math.PI * 2 * waves + phase;
const y = 100 - amp * (0.6 * Math.sin(t) + 0.3 * Math.sin(t * 2.3 + phase) + 0.1 * Math.sin(t * 5.1));
pts.push([x, Math.round(y * 10) / 10]);
}
let d = `M0 200 L${pts[0][0]} ${pts[0][1]}`;
for (let i = 1; i < pts.length; i++) {
const [px, py] = pts[i - 1];
const [x, y] = pts[i];
d += ` Q${px} ${py} ${(px + x) / 2} ${(py + y) / 2}`;
}
return `${d} L1440 ${pts[pts.length - 1][1]} L1440 200 Z`;
}
const rndStars = seeded(7);
const STARS = Array.from({ length: 70 }, (_, i) => ({
id: i,
x: Math.round(rndStars() * 1000) / 10,
y: Math.round(rndStars() * 600) / 10,
r: rndStars() > 0.85 ? 1.6 : rndStars() > 0.5 ? 1.1 : 0.7,
o: 0.4 + Math.round(rndStars() * 60) / 100,
}));
function Hill({ d, className, top }: { d: string; className: string; top: string }) {
return (
<div className={cn("absolute inset-x-0", className)} style={{ top }}>
<svg viewBox="0 0 1440 200" preserveAspectRatio="none" className="block h-24 w-full sm:h-40" aria-hidden>
<path d={d} fill="currentColor" />
</svg>
<div className="-mt-px h-[1600px] bg-current" />
</div>
);
}
export const defaultParallaxLayers: ParallaxLayer[] = [
{
id: "sky",
speed: 0,
content: (
<div className="absolute inset-0 bg-gradient-to-b from-sky-300 via-orange-100 to-rose-200 dark:from-slate-950 dark:via-indigo-950 dark:to-violet-900" />
),
},
{
id: "stars",
speed: 0.05,
content: (
<svg className="absolute inset-0 h-full w-full opacity-0 transition-opacity duration-700 dark:opacity-100" aria-hidden>
{STARS.map((s) => (
<circle key={s.id} cx={`${s.x}%`} cy={`${s.y}%`} r={s.r} fill="white" opacity={s.o} />
))}
</svg>
),
},
{
id: "orb",
speed: -0.35,
content: (
<div className="absolute right-[8%] top-[6%] size-16 sm:size-24">
<div className="absolute -inset-8 rounded-full bg-amber-300/50 blur-2xl dark:bg-indigo-200/15" />
<div className="relative size-full rounded-full bg-gradient-to-br from-amber-200 to-orange-400 dark:from-slate-50 dark:to-slate-300">
<span className="absolute left-[22%] top-[30%] size-[18%] rounded-full bg-slate-400/0 dark:bg-slate-400/40" />
<span className="absolute left-[55%] top-[55%] size-[24%] rounded-full bg-slate-400/0 dark:bg-slate-400/35" />
<span className="absolute left-[60%] top-[22%] size-[10%] rounded-full bg-slate-400/0 dark:bg-slate-400/40" />
</div>
</div>
),
},
{
id: "far",
speed: 0.15,
content: <Hill d={hillPath(11, 55, 1.6)} top="42%" className="text-rose-300/80 dark:text-indigo-800/70" />,
},
{
id: "mid",
speed: 0.3,
content: <Hill d={hillPath(23, 45, 2.2)} top="56%" className="text-violet-400/80 dark:text-indigo-950" />,
},
{
id: "near",
speed: 0.5,
content: <Hill d={hillPath(41, 35, 1.3)} top="72%" className="text-background" />,
},
];
function Layer({ layer, progress, height, reduce }: { layer: ParallaxLayer; progress: MotionValue<number>; height: string; reduce: boolean }) {
const y = useTransform(progress, (p) => (reduce ? "0px" : `calc(${height} * ${(-layer.speed * p).toFixed(4)})`));
const opacity = useTransform(progress, (p) => (layer.fade ? Math.max(0, 1 - p / 0.6) : 1));
return (
<motion.div aria-hidden style={{ y, opacity }} className={cn("absolute inset-0 will-change-transform", layer.className)}>
{layer.content}
</motion.div>
);
}
export function ParallaxLayers({
layers = defaultParallaxLayers,
children,
contentSpeed = 0.45,
height = "100svh",
scrollLength = 2,
container,
className,
}: ParallaxLayersProps) {
const reduce = useReducedMotion() ?? false;
const target = React.useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target, container, offset: ["start start", "end end"] });
const nearIndex = layers.length;
const contentY = useTransform(scrollYProgress, (p) => (reduce ? "0px" : `calc(${height} * ${(-contentSpeed * p).toFixed(4)})`));
// Function transforms (not range maps) keep Motion from handing opacity to a native ScrollTimeline,
// which ignores the target offsets when a custom scroll container is used.
const contentOpacity = useTransform(scrollYProgress, (p) => Math.max(0, 1 - p / 0.5));
return (
<div ref={target} className={cn("relative w-full", className)} style={{ height: `calc(${height} * ${scrollLength})` }}>
<div className="sticky top-0 w-full overflow-hidden" style={{ height }}>
{layers.map((layer, i) => (
<React.Fragment key={layer.id}>
{/* Foreground content sits between the middle layers and the nearest one, so the near hill rolls over it. */}
{i === nearIndex - 1 && children && (
<motion.div style={{ y: contentY, opacity: contentOpacity }} className="absolute inset-0 flex items-start justify-center">
{children}
</motion.div>
)}
<Layer layer={layer} progress={scrollYProgress} height={height} reduce={reduce} />
</React.Fragment>
))}
</div>
</div>
);
}