Fazekit

Code

"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>
  );
}

More in Motion & Scroll

View all →