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