"use client";
import * as React from "react";
export type ParallaxHillsPalette = "dawn" | "dusk" | "mist" | "night";
export interface ParallaxHillsProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of ridges, far to near (2–7). */
layers?: number;
/** Colour story. Every palette has a light and a dark (theme) variant. */
palette?: ParallaxHillsPalette;
/** Seed for the ridge shapes. The same seed always draws the same landscape (SSR-safe). */
seed?: number;
/** Detail on the ridgelines, 0 (rolling) – 1 (craggy). */
roughness?: number;
/** Horizontal drift speed, 0 (still) – 1. Near layers drift faster than far ones. */
drift?: number;
/** Pointer and scroll parallax strength, 0 – 1. */
parallax?: number;
/** Sun (light theme) or moon (dark theme and the night palette). */
celestial?: boolean;
/** Stars in the dark theme and the night palette. */
stars?: boolean;
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- Palettes -------------------------------- */
/**
* Each palette is a set of CSS custom properties with a dark-theme override, so the scene follows
* the theme without JavaScript. Layer colours are mixed from `--ph-land` (nearest) toward
* `--ph-haze` (farthest) — atmospheric perspective.
*/
const PALETTES: Record<ParallaxHillsPalette, string> = {
dawn:
"[--ph-sky-top:#dfe6ec] [--ph-sky-bottom:#f6e2d2] [--ph-haze:#ecd8cc] [--ph-land:#4d5566] [--ph-orb:#ffb48c] " +
"dark:[--ph-sky-top:#10141b] dark:[--ph-sky-bottom:#2b2631] dark:[--ph-haze:#3b3442] dark:[--ph-land:#0b0d12] dark:[--ph-orb:#f1dcc4]",
dusk:
"[--ph-sky-top:#f1e4d6] [--ph-sky-bottom:#efb193] [--ph-haze:#e9b59c] [--ph-land:#33252e] [--ph-orb:#ff7448] " +
"dark:[--ph-sky-top:#121317] dark:[--ph-sky-bottom:#3a2327] dark:[--ph-haze:#4b2d31] dark:[--ph-land:#0c0a0c] dark:[--ph-orb:#f3d9c2]",
mist:
"[--ph-sky-top:#eef0ed] [--ph-sky-bottom:#dde3df] [--ph-haze:#d4dcd7] [--ph-land:#45534e] [--ph-orb:#fbf8ee] " +
"dark:[--ph-sky-top:#101413] dark:[--ph-sky-bottom:#1d2523] dark:[--ph-haze:#26302d] dark:[--ph-land:#090c0b] dark:[--ph-orb:#c9d6d0]",
night:
"[--ph-sky-top:#151b2b] [--ph-sky-bottom:#36405a] [--ph-haze:#465170] [--ph-land:#0d111a] [--ph-orb:#ece8da] " +
"dark:[--ph-sky-top:#06080e] dark:[--ph-sky-bottom:#151b2a] dark:[--ph-haze:#1c2335] dark:[--ph-land:#04060a] dark:[--ph-orb:#e6e2d4]",
};
/* ------------------------------ Terrain (pure) ----------------------------- */
const VIEW_W = 1600;
const VIEW_H = 600;
/** The noise repeats every TILE units, so drifting by one tile is seamless. */
const TILE = 1600;
/** Extra path on both sides, covering the largest parallax shift. */
const PAD = 160;
const SAMPLE = 24;
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 1D value noise that repeats every `cells` lattice cells (x in tiles). */
function periodicNoise(values: number[]) {
const cells = values.length;
return (x: number) => {
const p = x * cells;
const i = Math.floor(p);
const f = p - i;
const a = values[((i % cells) + cells) % cells];
const b = values[(((i + 1) % cells) + cells) % cells];
const s = f * f * (3 - 2 * f);
return a + (b - a) * s;
};
}
const r2 = (n: number) => Math.round(n * 100) / 100;
interface Ridge {
d: string;
/** Depth 0 (far) – 1 (near). */
depth: number;
/** Where the valley fog sits under this ridge. */
fogY: number;
}
/**
* Builds the ridgelines. Far ridges are taller and craggier (ridged noise), near ones lower and
* rolling; each is sampled every SAMPLE units over two tiles plus padding, then smoothed into
* cubic Béziers (Catmull-Rom). Coordinates are rounded so server and client output match.
*/
function buildRidges(layers: number, seed: number, roughness: number): Ridge[] {
const count = Math.max(2, Math.min(7, Math.round(layers)));
const rough = Math.min(1, Math.max(0, roughness));
return Array.from({ length: count }, (_, i) => {
const depth = count === 1 ? 1 : i / (count - 1);
const random = mulberry32(Math.round(seed) * 7919 + i * 104729 + 1);
const lattice = (cells: number) => periodicNoise(Array.from({ length: cells }, () => random()));
const n1 = Math.round(3 + (1 - depth) * 3);
const o1 = lattice(n1);
const o2 = lattice(n1 * 3);
const o3 = lattice(n1 * 6);
const base = VIEW_H * (0.44 + 0.42 * depth);
const amp = VIEW_H * (0.15 - 0.075 * depth);
const pts: [number, number][] = [];
for (let x = -PAD; x <= TILE * 2 + PAD; x += SAMPLE) {
const t = x / TILE;
let h = o1(t) + o2(t) * 0.38 * (0.35 + rough) + o3(t) * 0.16 * rough;
h /= 1 + 0.38 * (0.35 + rough) + 0.16 * rough;
// Far ridges use ridged noise (sharper crests); near ones stay rounded.
const ridged = 1 - Math.abs(2 * h - 1);
const shape = ridged * (1 - depth) * 0.7 + h * (1 - (1 - depth) * 0.7);
pts.push([x, base - shape * amp * 1.6]);
}
let d = `M${r2(pts[0][0])} ${VIEW_H + 120}L${r2(pts[0][0])} ${r2(pts[0][1])}`;
for (let k = 0; k < pts.length - 1; k++) {
const p0 = pts[Math.max(0, k - 1)];
const p1 = pts[k];
const p2 = pts[k + 1];
const p3 = pts[Math.min(pts.length - 1, k + 2)];
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
const c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
const c2y = p2[1] - (p3[1] - p1[1]) / 6;
d += `C${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(p2[0])} ${r2(p2[1])}`;
}
d += `L${r2(pts[pts.length - 1][0])} ${VIEW_H + 120}Z`;
return { d, depth, fogY: r2(base + amp * 0.2) };
});
}
interface Star {
x: number;
y: number;
r: number;
o: number;
}
function buildStars(seed: number): Star[] {
const random = mulberry32(Math.round(seed) * 31337 + 7);
return Array.from({ length: 90 }, () => ({
x: r2(random() * VIEW_W),
y: r2(Math.pow(random(), 1.4) * VIEW_H * 0.5),
r: r2(0.5 + random() * 1.1),
o: r2(0.3 + random() * 0.6),
}));
}
/* -------------------------------- Component -------------------------------- */
/**
* A layered landscape: seeded ridgelines from far (tall, craggy, hazy) to near (low, rolling,
* dark), with valley fog between them, a sun or moon, and stars at night. Layers drift slowly
* and shift with the pointer and with scroll, each by its depth. The SVG is deterministic for
* a given seed, so it renders identically on the server; motion is applied afterwards by
* writing transforms from a requestAnimationFrame loop that pauses offscreen and in hidden tabs.
*/
export function ParallaxHills({
layers = 5,
palette = "dusk",
seed = 7,
roughness = 0.5,
drift = 0.3,
parallax = 0.5,
celestial = true,
stars = true,
className = "",
children,
...props
}: ParallaxHillsProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const ridges = React.useMemo(() => buildRidges(layers, seed, roughness), [layers, seed, roughness]);
const starField = React.useMemo(() => buildStars(seed), [seed]);
const night = palette === "night";
React.useEffect(() => {
const root = rootRef.current;
if (!root || reduceMotion) return;
const groups = Array.from(root.querySelectorAll<SVGGElement>("[data-ph-depth]"));
const speed = Math.min(1, Math.max(0, drift));
const strength = Math.min(1, Math.max(0, parallax));
if (speed === 0 && strength === 0) return;
let raf = 0;
let last = 0;
let inView = false;
let elapsed = 0; // seconds of drift, only advancing while visible
const ptr = { tx: 0, ty: 0, x: 0, y: 0 };
let scroll = 0;
const readScroll = () => {
const rect = root.getBoundingClientRect();
const vh = window.innerHeight || 1;
// -1 when the scene's centre is at the bottom of the viewport, +1 at the top.
scroll = Math.max(-1, Math.min(1, (vh / 2 - (rect.top + rect.height / 2)) / (vh / 2 + rect.height / 2)));
};
const apply = () => {
for (const g of groups) {
const depth = Number(g.dataset.phDepth);
const offset = speed > 0 && g.dataset.phDrift === "1" ? -((elapsed * speed * (6 + 46 * depth)) % TILE) : 0;
const px = -ptr.x * strength * (8 + 70 * depth);
const py = -ptr.y * strength * (3 + 18 * depth) + scroll * strength * (6 + 46 * depth);
g.setAttribute("transform", `translate(${(offset + px).toFixed(2)} ${py.toFixed(2)})`);
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(0.064, last ? (now - last) / 1000 : 1 / 60);
last = now;
elapsed += dt;
const k = 1 - Math.pow(0.0015, dt); // frame-rate independent easing toward the pointer
ptr.x += (ptr.tx - ptr.x) * k;
ptr.y += (ptr.ty - ptr.y) * k;
apply();
const settling = Math.abs(ptr.tx - ptr.x) > 0.001 || Math.abs(ptr.ty - ptr.y) > 0.001;
if (speed > 0 || settling) schedule();
else last = 0;
};
const schedule = () => {
if (!raf && inView && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" || strength === 0) return;
const rect = root.getBoundingClientRect();
ptr.tx = Math.max(-1, Math.min(1, ((e.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1));
ptr.ty = Math.max(-1, Math.min(1, ((e.clientY - rect.top) / Math.max(1, rect.height)) * 2 - 1));
schedule();
};
const onScroll = () => {
if (strength === 0) return;
readScroll();
if (!raf) apply();
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const passive = { passive: true } as const;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
window.addEventListener("pointermove", onPointerMove, passive);
window.addEventListener("scroll", onScroll, passive);
readScroll();
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("scroll", onScroll);
stop();
}
});
io.observe(root);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
io.disconnect();
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("scroll", onScroll);
document.removeEventListener("visibilitychange", onVisibility);
// Leave the static, centred composition behind (e.g. when reduced motion turns on).
for (const g of groups) g.removeAttribute("transform");
};
}, [drift, parallax, reduceMotion, ridges]);
const sky = `${uid}-sky`;
const glow = `${uid}-glow`;
const fog = `${uid}-fog`;
const moon = `${uid}-moon`;
const orbX = 1060;
const orbY = 158;
return (
<div ref={rootRef} className={`absolute inset-0 overflow-hidden ${PALETTES[palette]} ${className}`} {...props}>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full"
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="xMidYMax slice"
>
<defs>
<linearGradient id={sky} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" style={{ stopColor: "var(--ph-sky-top)" }} />
<stop offset="0.75" style={{ stopColor: "var(--ph-sky-bottom)" }} />
</linearGradient>
<radialGradient id={glow}>
<stop offset="0" style={{ stopColor: "var(--ph-orb)", stopOpacity: 0.45 }} />
<stop offset="1" style={{ stopColor: "var(--ph-orb)", stopOpacity: 0 }} />
</radialGradient>
<clipPath id={moon}>
<circle cx={orbX} cy={orbY} r={34} />
</clipPath>
<linearGradient id={fog} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" style={{ stopColor: "var(--ph-haze)", stopOpacity: 0 }} />
<stop offset="0.55" style={{ stopColor: "var(--ph-haze)", stopOpacity: 0.55 }} />
<stop offset="1" style={{ stopColor: "var(--ph-haze)", stopOpacity: 0 }} />
</linearGradient>
</defs>
<rect x={-PAD} y={-PAD} width={VIEW_W + PAD * 2} height={VIEW_H + PAD * 2} fill={`url(#${sky})`} />
{stars && (
<g className={night ? "" : "opacity-0 dark:opacity-100"} style={{ fill: "var(--ph-orb)" }}>
{starField.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} opacity={s.o} />
))}
</g>
)}
{celestial && (
<g data-ph-depth="0" data-ph-drift="0">
<circle cx={orbX} cy={orbY} r={150} fill={`url(#${glow})`} />
{/* Sun on light themes; a moon (with a soft terminator) at night or on dark themes. */}
<g className={night ? "hidden" : "dark:hidden"}>
<circle cx={orbX} cy={orbY} r={44} style={{ fill: "var(--ph-orb)" }} />
</g>
<g className={night ? "" : "hidden dark:inline"}>
<circle cx={orbX} cy={orbY} r={34} style={{ fill: "var(--ph-orb)" }} />
<circle cx={orbX + 14} cy={orbY - 9} r={32} clipPath={`url(#${moon})`} style={{ fill: "var(--ph-sky-top)", opacity: 0.32 }} />
</g>
</g>
)}
{ridges.map((ridge, i) => {
// Atmospheric perspective: far layers sit close to the haze, near layers carry the land colour.
const mix = Math.round(14 + 86 * Math.pow(ridge.depth, 1.15));
const fill = `color-mix(in oklab, var(--ph-land) ${mix}%, var(--ph-haze))`;
const edge = `color-mix(in oklab, var(--ph-haze) 70%, var(--ph-sky-top))`;
return (
<g key={i} data-ph-depth={ridge.depth.toFixed(3)} data-ph-drift="1">
<path d={ridge.d} style={{ fill }} />
{/* A hairline crest, like an engraved contour. */}
<path d={ridge.d} fill="none" vectorEffect="non-scaling-stroke" strokeWidth={0.75} style={{ stroke: edge, opacity: 0.35 + 0.3 * (1 - ridge.depth) }} />
{i < ridges.length - 1 && <rect x={-PAD} y={ridge.fogY} width={TILE * 2 + PAD * 2} height={90} fill={`url(#${fog})`} />}
</g>
);
})}
</svg>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}