# Golden Hour: Sunset Scene Background for React

> A painted valley lit for an hour of the day. Every color comes from that hour: the sky, the sun and its glow, the light along each ridge, the road and the stars. Change it and the sun travels there through the hours in between, so noon to night passes golden hour and dusk on the way. It can follow the visitor's own clock, or go down as the page scrolls.

Sunset Scene Background for React: a painted valley whose light follows the hour. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/golden-hour
- Tags: Background, Hero, SVG
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/golden-hour.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/golden-hour
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/golden-hour.json
```

## Usage

The main demo shows typical usage.

### Any Hour

Drag the dial through the day: the sun climbs and sets, the light along the ridges turns from white to gold, and the road's lights come on after dusk. "Your time" jumps to your own clock.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/golden-hour-demo
```

```tsx title="components/golden-hour-demo.tsx"
"use client";

import * as React from "react";

import { GoldenHour } from "@/components/ui/golden-hour";

// The light through a day, for the dial's track: night, dawn, morning, noon,
// afternoon, golden hour, dusk and night again, each at its hour.
const DAY = [
  [0, "#1a1f4a"],
  [5, "#262a5c"],
  [6.2, "#c47c9c"],
  [7.2, "#ffd3a0"],
  [9.5, "#9cc3ee"],
  [12.5, "#78aef0"],
  [15.5, "#c9c6e0"],
  [17.6, "#f68a48"],
  [18.6, "#de4a62"],
  [19.6, "#4a2f72"],
  [21.2, "#1a1f4a"],
  [24, "#1a1f4a"],
] as const;
const TRACK = `linear-gradient(90deg, ${DAY.map(([hour, color]) => `${color} ${((hour / 24) * 100).toFixed(1)}%`).join(", ")})`;

function clockLabel(hour: number) {
  const minutes = Math.round((hour % 24) * 60) % (24 * 60);
  const h = Math.floor(minutes / 60);
  const m = minutes % 60;
  return `${((h + 11) % 12) + 1}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
}

function lightLabel(hour: number) {
  if (hour < 5) return "Night";
  if (hour < 6.6) return "Dawn";
  if (hour < 8) return "Sunrise";
  if (hour < 11) return "Morning";
  if (hour < 14) return "Midday";
  if (hour < 17) return "Afternoon";
  if (hour < 18.3) return "Golden hour";
  if (hour < 19) return "Sunset";
  if (hour < 20.2) return "Dusk";
  return "Night";
}

export function GoldenHourDemo() {
  const [hour, setHour] = React.useState(17.6);
  const label = `${clockLabel(hour)} · ${lightLabel(hour)}`;

  return (
    <div className="w-full max-w-3xl">
      <GoldenHour time={hour} className="aspect-[4/5] w-full rounded-3xl sm:aspect-[16/10]">
        <p className="absolute top-4 left-4 flex items-center gap-2 rounded-full bg-zinc-950/30 py-1.5 pr-3.5 pl-2 text-xs font-medium text-white ring-1 ring-white/15 backdrop-blur-xl">
          <span aria-hidden className="size-4 rounded-full bg-gradient-to-b from-amber-200 to-orange-400 shadow-[0_0_12px_rgba(255,190,110,0.8)]" />
          <span className="tabular-nums">{label}</span>
        </p>
      </GoldenHour>

      <div className="mt-5 flex items-center gap-4 px-1">
        <label htmlFor="golden-hour-dial" className="sr-only">
          Time of day
        </label>
        <div className="flex-1">
          <input
            id="golden-hour-dial"
            type="range"
            min={0}
            max={24}
            step={0.05}
            value={hour}
            aria-valuetext={label}
            onChange={(event) => setHour(Number(event.target.value))}
            style={{ backgroundImage: TRACK }}
            className="block h-2 w-full cursor-pointer appearance-none rounded-full outline-offset-4 focus-visible:outline-2 focus-visible:outline-zinc-950 dark:focus-visible:outline-white [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-zinc-950/20 [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-zinc-950/20 [&::-webkit-slider-thumb]:shadow-[0_1px_6px_rgba(0,0,0,0.35)]"
          />
          <div aria-hidden className="mt-2 flex justify-between text-[11px] text-zinc-500 tabular-nums dark:text-zinc-400">
            <span>12 AM</span>
            <span>6 AM</span>
            <span>Noon</span>
            <span>6 PM</span>
            <span>12 AM</span>
          </div>
        </div>
        <button
          type="button"
          onClick={() => {
            const now = new Date();
            setHour(now.getHours() + now.getMinutes() / 60);
          }}
          className="-mt-5 shrink-0 rounded-full px-3 py-1.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-950/10 transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-950/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] dark:text-zinc-200 dark:ring-white/15 dark:hover:bg-white/10 dark:focus-visible:outline-white"
        >
          Your time
        </button>
      </div>
    </div>
  );
}
```

## Examples

Finished sections built with Golden Hour. Each one installs whole, with the component included.

### Sunset Sign-Up

A newsletter's closing section. The sun sets as it comes into view, and subscribing brings the night and the moon out.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/golden-hour-cta-demo
```

```tsx title="components/golden-hour-cta-demo.tsx"
"use client";

import * as React from "react";
import { useInView } from "motion/react";

import { GoldenHour } from "@/components/ui/golden-hour";

// Golden hour on the way down the page, the sun just set once the section is
// in view, and night with the moon up after the visitor subscribes.
const ARRIVING = 17.6;
const ARRIVED = 19;
const SUBSCRIBED = 21.6;

export function GoldenHourCtaDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.6 });
  const [email, setEmail] = React.useState("");
  const [sent, setSent] = React.useState(false);
  const hour = sent ? SUBSCRIBED : inView ? ARRIVED : ARRIVING;

  return (
    <div ref={ref} className="w-full max-w-4xl">
      <GoldenHour
        time={hour}
        horizon={0.68}
        className="flex min-h-[30rem] flex-col items-center rounded-3xl px-6 pt-14 text-center text-white sm:min-h-[32rem] sm:pt-20"
      >
        <p className="flex items-center gap-2 text-sm font-medium text-white/80">
          <SunMark className="size-4" />
          Long Light · Fridays
        </p>
        <h2 className="mt-4 max-w-[17ch] text-4xl/[1.05] font-medium tracking-[-0.035em] text-balance sm:text-5xl/[1.02]">
          One slow email a week, sent at sunset.
        </h2>
        <p className="mt-4 max-w-md text-base/7 text-pretty text-white/80">
          Five places worth the detour, and the hour to be there. Read by 18,000 people who would rather be outside.
        </p>

        <div className="mt-8 w-full max-w-md" aria-live="polite">
          {sent ? (
            <div className="flex min-h-[52px] items-center justify-center gap-2 rounded-full bg-zinc-950/20 px-5 py-2 text-sm font-medium ring-1 ring-white/25 backdrop-blur-xl">
              <CheckIcon className="size-4" />
              You&apos;re on the list. The first letter lands Friday, just before sunset.
            </div>
          ) : (
            <form
              onSubmit={(event) => {
                event.preventDefault();
                if (email.includes("@")) setSent(true);
              }}
              className="flex h-[52px] items-center gap-2 rounded-full bg-zinc-950/20 p-1.5 ring-1 ring-white/25 backdrop-blur-xl focus-within:ring-white/60"
            >
              <label htmlFor="golden-hour-cta-email" className="sr-only">
                Email
              </label>
              <input
                id="golden-hour-cta-email"
                type="email"
                required
                value={email}
                onChange={(event) => setEmail(event.target.value)}
                placeholder="you@example.com"
                className="h-full min-w-0 flex-1 bg-transparent pl-4 text-sm text-white outline-none placeholder:text-white/70"
              />
              <button
                type="submit"
                className="h-full shrink-0 rounded-full bg-white px-5 text-sm font-medium text-zinc-950 transition-[background-color,scale] duration-150 ease-out hover:bg-white/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white active:scale-[0.97]"
              >
                Subscribe
              </button>
            </form>
          )}
        </div>
        <p className="mt-3 text-xs text-white/70">No spam. Unsubscribe in one click.</p>
      </GoldenHour>
    </div>
  );
}

function SunMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth={1.4} aria-hidden>
      <path d="M2 11.5h12M4.5 11.5a3.5 3.5 0 0 1 7 0M8 3v1.6M3.4 5.4l1.1 1.1M12.6 5.4l-1.1 1.1" strokeLinecap="round" />
    </svg>
  );
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden>
      <path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}
```

### Studio Clocks

A design studio's contact card lit for the local time in each city. Switching from Lisbon to Tokyo carries the sun through the hours between.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/golden-hour-studios-demo
```

```tsx title="components/golden-hour-studios-demo.tsx"
"use client";

import * as React from "react";

import { GoldenHour } from "@/components/ui/golden-hour";

const STUDIOS = [
  { city: "Lisbon", zone: "Europe/Lisbon", address: "Rua das Flores 28", team: "6 in the studio", open: [9, 19] },
  { city: "New York", zone: "America/New_York", address: "55 Water St, Brooklyn", team: "11 in the studio", open: [9, 18] },
  { city: "Tokyo", zone: "Asia/Tokyo", address: "2-14 Kamimeguro, Meguro", team: "4 in the studio", open: [10, 19] },
] as const;

// The time now, read on the client only: the page is prerendered, so a server
// clock would be stale by the time anyone reads it.
const subscribe = (onChange: () => void) => {
  const timer = window.setInterval(onChange, 30_000);
  return () => window.clearInterval(timer);
};
const readMinute = () => Math.floor(Date.now() / 60_000);
const noMinute = () => null;

function hourIn(zone: string, minute: number) {
  const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric", minute: "numeric", hourCycle: "h23" }).formatToParts(
    new Date(minute * 60_000),
  );
  const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? 0);
  return get("hour") + get("minute") / 60;
}

function clock(hour: number) {
  const minutes = Math.round(hour * 60) % (24 * 60);
  const h = Math.floor(minutes / 60);
  return `${((h + 11) % 12) + 1}:${String(minutes % 60).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
}

function hourLabel(hour: number) {
  return `${((hour + 11) % 12) + 1} ${hour < 12 ? "AM" : "PM"}`;
}

export function GoldenHourStudiosDemo() {
  const minute = React.useSyncExternalStore(subscribe, readMinute, noMinute);
  const [picked, setPicked] = React.useState(0);
  const tabs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const studio = STUDIOS[picked];
  const hour = minute === null ? null : hourIn(studio.zone, minute);
  const isOpen = hour !== null && hour >= studio.open[0] && hour < studio.open[1];

  const pick = (index: number) => {
    const next = (index + STUDIOS.length) % STUDIOS.length;
    setPicked(next);
    tabs.current[next]?.focus();
  };

  return (
    <div className="w-full max-w-3xl">
      <GoldenHour
        id="golden-hour-studio"
        role="tabpanel"
        aria-labelledby={`golden-hour-studio-tab-${picked}`}
        time={hour ?? "golden"}
        className="flex aspect-[4/5] w-full items-end rounded-3xl p-4 sm:aspect-[16/10] sm:p-6"
      >
        <div className="w-full max-w-xs rounded-2xl bg-white/75 p-4 text-zinc-950 shadow-sm ring-1 ring-white/60 backdrop-blur-xl sm:p-5 dark:bg-zinc-950/55 dark:text-white dark:ring-white/10">
          <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Tinta · {studio.city} studio</p>
          <p className="mt-1 text-4xl font-medium tracking-[-0.03em] tabular-nums">{hour === null ? "--:--" : clock(hour)}</p>
          <p className="mt-1.5 flex items-center gap-1.5 text-sm text-zinc-600 dark:text-zinc-300">
            <span
              aria-hidden
              className={`size-1.5 rounded-full ${isOpen ? "bg-emerald-500" : "bg-zinc-400 dark:bg-zinc-500"}`}
            />
            {hour === null
              ? studio.team
              : isOpen
                ? `Open until ${hourLabel(studio.open[1])} · ${studio.team}`
                : `Closed · opens at ${hourLabel(studio.open[0])}`}
          </p>
          <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">{studio.address}</p>
          <a
            href="#book"
            className="mt-4 inline-flex h-9 items-center rounded-full bg-zinc-950 px-4 text-sm font-medium text-white transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 active:scale-[0.97] dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200 dark:focus-visible:outline-white"
          >
            {isOpen || hour === null ? "Book a call" : "Leave a note for the morning"}
          </a>
        </div>
      </GoldenHour>

      <div
        role="tablist"
        aria-label="Studios"
        className="mt-4 grid grid-cols-3 gap-1 rounded-2xl bg-zinc-100 p-1 dark:bg-white/[0.06]"
        onKeyDown={(event) => {
          if (event.key === "ArrowRight") pick(picked + 1);
          else if (event.key === "ArrowLeft") pick(picked - 1);
          else return;
          event.preventDefault();
        }}
      >
        {STUDIOS.map((place, index) => {
          const selected = index === picked;
          return (
            <button
              key={place.city}
              ref={(element) => {
                tabs.current[index] = element;
              }}
              id={`golden-hour-studio-tab-${index}`}
              type="button"
              role="tab"
              aria-selected={selected}
              aria-controls="golden-hour-studio"
              tabIndex={selected ? 0 : -1}
              onClick={() => setPicked(index)}
              className={`flex flex-col items-start rounded-xl px-3 py-2 text-left transition-[background-color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:focus-visible:outline-white ${
                selected
                  ? "bg-white shadow-sm ring-1 ring-zinc-950/5 dark:bg-white/10 dark:ring-white/10"
                  : "hover:bg-white/60 dark:hover:bg-white/[0.04]"
              }`}
            >
              <span className="text-sm font-medium text-zinc-950 dark:text-white">{place.city}</span>
              <span className="text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
                {minute === null ? "--:--" : clock(hourIn(place.zone, minute))}
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `time` | `"dawn" \| "morning" \| "noon" \| "afternoon" \| "golden" \| "sunset" \| "dusk" \| "night" \| number \| "auto"` | `"golden"` | The hour the scene is lit for: a preset, an hour from 0 to 24 (17.5 is half past five in the evening), or `"auto"` for the visitor's own clock. A new value carries the sun there through the hours in between, the short way round the clock. |
| `sunset` | `boolean \| number` | `false` | Let the sun go down as the page scrolls on, from where the scene sat when the page loaded until it leaves the top. `true` moves it on 2 hours; a number sets the hours. |
| `horizon` | `number` | `0.58` | Where the far ridge meets the sky, as a share of the height from the top. Raise it to leave room for a headline in the sky. |
| `road` | `boolean` | `true` | The winding road from the bottom edge over the near hill. Its center line lights up at night. |
| `trees` | `boolean` | `true` | Trees on the slopes in front and along the middle ridge. |
| `grain` | `number` | `0.5` | Strength of the film grain, from `0` (none) to `1`. |
| `seed` | `number` | `3` | Reshapes the hills, the trees and the stars. Any whole number. |
| `intro` | `boolean` | `true` | The first time the scene shows, settle the ridges into place, back to front, while the light moves on half an hour to the hour asked for. |
| `parallax` | `boolean` | `true` | Let the ridges drift against the mouse, nearer ones more. Touch leaves them still. |
| `parallaxShift` | `number` | `18` | Pixels the front ridge drifts sideways with the pointer at the scene's edge. Farther ridges drift less, and the up-and-down drift scales with it. |
| `duration` | `number` | `0.7` | Seconds the sun takes to travel to a new `time`, plus 0.05s for every hour on the way. |
| `children` | `React.ReactNode` |  | Content laid over the scene, like a product shot or a headline. The root is `relative`, so it can be placed with `absolute`, or laid out with flex classes on `className`. |
| `className` | `string` |  | Classes for the root, which sizes the scene: give it a height or an aspect ratio, and the rounded corners. The horizon stays at the same share of the height in any shape. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/golden-hour.tsx`, keep these, which make it feel and behave right:

- Every color, the sun and the layer offsets are written straight to the SVG from motion values, batched into one `frame.render` per frame, so nothing re-renders while the light moves.
- The markup is drawn once from `initial` (read in `useState`) and the paint effect finds each node by its `data-gh` key; later renders never repaint the colors it keeps current.
- Layers move by `transform` only, one per `LAYERS` entry back to front: the pointer drift through `translate3d` and the first-view rise through `translateY`.
- A new `time` travels the short way round the clock (`shortWay`) through every hour between, so noon to night passes golden hour and dusk.
- Reduced motion skips the settling and the pointer drift and jumps to a new time; the scroll sunset still runs, on raw `scrollYProgress` instead of the spring. Touch never drifts.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/golden-hour.tsx"
"use client";

import * as React from "react";
import {
  animate,
  cancelFrame,
  frame,
  motionValue,
  useInView,
  useReducedMotion,
  useScroll,
  useSpring,
  type AnimationPlaybackControls,
} from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - Every color, the sun and the layer offsets are written straight to the SVG from motion values,
 *   batched into one `frame.render` per frame, so nothing re-renders while the light moves.
 * - The markup is drawn once from `initial` (read in `useState`) and the paint effect finds each
 *   node by its `data-gh` key; later renders never repaint the colors it keeps current.
 * - Layers move by `transform` only, one per `LAYERS` entry back to front: the pointer drift
 *   through `translate3d` and the first-view rise through `translateY`.
 * - A new `time` travels the short way round the clock (`shortWay`) through every hour between,
 *   so noon to night passes golden hour and dusk.
 * - Reduced motion skips the settling and the pointer drift and jumps to a new time; the scroll
 *   sunset still runs, on raw `scrollYProgress` instead of the spring. Touch never drifts.
 */
const TIMING = {
  /** Each ridge rising into place on first view. */
  settle: { type: "spring", visualDuration: 0.9, bounce: 0 },
  /** Between ridges as they rise, back to front, in seconds. */
  stagger: 0.06,
  /** The light moving on to the hour asked for, on first view: seconds and its curve. */
  ripen: { duration: 1.5, ease: [0.22, 1, 0.36, 1] },
  /** The sun moving to a new `time`: `duration`, plus `travelPerHour` per hour of the way. */
  travel: { type: "spring", bounce: 0 },
  /** The sun's travel before the per-hour part, in seconds (the `duration` prop). */
  duration: 0.7,
  /** Added to the sun's travel for every hour of the way, in seconds. */
  travelPerHour: 0.05,
  /** Between reads of the visitor's clock, with time="auto", in ms. */
  clock: 30_000,
  /** The scroll's sunset trailing the page, as a spring. */
  scroll: { stiffness: 120, damping: 30, mass: 0.4 },
  /** The ridges following the pointer, as a spring. */
  pointer: { stiffness: 70, damping: 18, mass: 1 },
} as const;

/* ─────────────────────────────────────────────────────────
 * GOLDEN HOUR STORYBOARD
 *
 *  rest     a painted valley under a grainy sky: ridge after ridge
 *           going hazy toward the horizon, a road winding up from the
 *           bottom edge and over the near hill, trees on the slopes
 *           in front. Every color comes from one number, the hour:
 *           the sky, the sun and its glow, the light along each
 *           crest, the road, the stars
 *  view     the first time it shows, the land settles: each ridge
 *           rises a few percent into place, back to front, 60ms
 *           apart, on a 0.9s spring with no bounce, while the light
 *           moves on half an hour to the time asked for (1.5s,
 *           easing out)
 *  time     a new `time` carries the sun along its arc through every
 *           hour in between, the short way round the clock, on a
 *           spring of 0.7s plus 0.05s an hour: noon to night passes
 *           golden hour and dusk on the way. Below the far ridge the hills hide the sun, and
 *           its glow lingers on the horizon after it sets
 *  auto     `time="auto"` follows the visitor's clock, checked every
 *           30s
 *  scroll   with `sunset`, the hour moves on as the page scrolls,
 *           from wherever the scene sat at load until it leaves the
 *           top (2 hours by default), through the house scroll
 *           spring: gold to violet to night, the stars prick in and
 *           the road's lights come on. Scrolling back raises it
 *  hover    the ridges drift against the pointer, nearer ones more:
 *           18px at the front, 2px at the far ridge, on a 70/18
 *           spring. Mouse only; touch leaves them still
 *  reduced  no settling and no drift. A new time lands at once; the
 *           scroll still moves the sun, without the smoothing
 * ───────────────────────────────────────────────────────── */

const GOLDEN = {
  lead: 0.5, // hours the light starts behind the hour asked for, on first view
  sunset: 2, // hours the sun moves on while the scene scrolls away, when `sunset` is true
  shift: 18, // px the front ridge drifts sideways with the pointer at the scene's edge
  lift: 5, // px it drifts up or down
} as const;

// Back to front. `depth` scales the pointer drift; `rise` is how far the layer
// starts below its place on first view, as a share of its height.
const LAYERS = [
  { depth: 0, rise: 0 }, // sky, sun, moon and stars
  { depth: 0.1, rise: 0.012 }, // the far ridge
  { depth: 0.22, rise: 0.024 }, // the hills
  { depth: 0.42, rise: 0.04 }, // the middle ridge, its trees and the mist behind it
  { depth: 0.68, rise: 0.06 }, // the meadow, the near hill and the road
  { depth: 1, rise: 0.085 }, // the slopes in front and their trees
] as const;

export type GoldenHourPreset = "dawn" | "morning" | "noon" | "afternoon" | "golden" | "sunset" | "dusk" | "night";

const PRESETS: Record<GoldenHourPreset, number> = {
  dawn: 6.1,
  morning: 9.5,
  noon: 12.5,
  afternoon: 15.5,
  golden: 17.6,
  sunset: 18.6,
  dusk: 19.4,
  night: 22,
};

// The world: 1000 units tall, centered on x = 0 and drawn as wide as any
// sensible box needs. The huge viewBox with "slice" always fits the height, so
// the horizon sits at the same share of every box, wide or tall, with no
// measuring, and the server's render is already the right picture.
const WORLD = { left: -2200, right: 2200, floor: 1012, step: 55 };
const VIEWBOX = "-50000 0 100000 1000";
const SUN = { rise: 6.55, set: 18.85, span: 240, height: 470, radius: 40, halo: 760, reach: 1100 }; // reach: how far the light along the crests spreads from under the sun
const ROAD = { end: 170, near: 380, far: 6 }; // x where it tops the near hill; width at the bottom edge and at the top

/* ── The light ──────────────────────────────────────────── */

// One palette per key hour. Crest → foot for each ridge, back to front.
type Light = {
  sky: [string, string, string, string, string]; // zenith → horizon
  sun: string;
  glow: string;
  halo: number; // the glow's strength, 0–1
  ridges: [[string, string], [string, string], [string, string], [string, string], [string, string]]; // far, hills, middle, near, front
  rim: string; // light along the crests, brightest under the sun
  rimAlpha: number;
  mist: string; // haze settled between the ridges
  mistAlpha: number;
  road: [string, string]; // near, far
  sheen: string; // light on the road surface
  sheenAlpha: number;
  lamps: string; // the road's center line, which lights up at night
  lampsAlpha: number;
  trees: string;
  stars: number; // 0–1
};

const NIGHT: Light = {
  sky: ["#050817", "#0a1029", "#111739", "#1a1f4a", "#262a5c"],
  sun: "#ffffff",
  glow: "#3c4488",
  halo: 0.22,
  ridges: [
    ["#1c1e48", "#17193e"],
    ["#15173a", "#111331"],
    ["#0f122e", "#0b0e25"],
    ["#0e1526", "#0a101c"],
    ["#070b15", "#04070e"],
  ],
  rim: "#7480d0",
  rimAlpha: 0.14,
  mist: "#2a2e6a",
  mistAlpha: 0.22,
  road: ["#141a2c", "#1b2038"],
  sheen: "#ffb35c",
  sheenAlpha: 0.12,
  lamps: "#ffcf7a",
  lampsAlpha: 0.95,
  trees: "#02040a",
  stars: 1,
};

const DAWN: Light = {
  sky: ["#1a2762", "#36397c", "#6e5596", "#c47c9c", "#f2ae8e"],
  sun: "#ffd9b8",
  glow: "#ffad86",
  halo: 0.5,
  ridges: [
    ["#7a70ab", "#675f9b"],
    ["#53528e", "#46457e"],
    ["#303b67", "#283158"],
    ["#2f4c44", "#223a35"],
    ["#142520", "#0d1916"],
  ],
  rim: "#ffc9a8",
  rimAlpha: 0.35,
  mist: "#e6a0b4",
  mistAlpha: 0.38,
  road: ["#6e6078", "#8c7088"],
  sheen: "#ffc8a0",
  sheenAlpha: 0.3,
  lamps: "#ffe0c0",
  lampsAlpha: 0.45,
  trees: "#09140f",
  stars: 0.35,
};

const SUNRISE: Light = {
  sky: ["#3557a8", "#5f74c0", "#a090c8", "#efaaa0", "#ffd3a0"],
  sun: "#fff3dc",
  glow: "#ffcf98",
  halo: 0.72,
  ridges: [
    ["#9590c6", "#827fb6"],
    ["#6d76aa", "#5e689a"],
    ["#3e5872", "#344b64"],
    ["#4e7c48", "#365e38"],
    ["#1f3b26", "#14291b"],
  ],
  rim: "#ffdcaa",
  rimAlpha: 0.6,
  mist: "#f4c4b4",
  mistAlpha: 0.4,
  road: ["#dcab88", "#cc9c90"],
  sheen: "#ffe8c4",
  sheenAlpha: 0.42,
  lamps: "#fff4e0",
  lampsAlpha: 0.35,
  trees: "#0f1f15",
  stars: 0,
};

const MORNING: Light = {
  sky: ["#3f78cf", "#5d93df", "#86b3ea", "#bcd8f2", "#eef0e4"],
  sun: "#fffdf3",
  glow: "#fff4d6",
  halo: 0.42,
  ridges: [
    ["#a3b9d6", "#93abca"],
    ["#82a1bf", "#7192b2"],
    ["#4e8077", "#406e67"],
    ["#5d9449", "#457a3a"],
    ["#295b31", "#1b4224"],
  ],
  rim: "#fff6d2",
  rimAlpha: 0.35,
  mist: "#eaf2f6",
  mistAlpha: 0.36,
  road: ["#d8d0c0", "#c8c3b8"],
  sheen: "#ffffff",
  sheenAlpha: 0.22,
  lamps: "#ffffff",
  lampsAlpha: 0.55,
  trees: "#163a20",
  stars: 0,
};

const NOON: Light = {
  sky: ["#2f6fd4", "#4f8ee6", "#78aef0", "#aacff5", "#e2eff8"],
  sun: "#ffffff",
  glow: "#ffffff",
  halo: 0.32,
  ridges: [
    ["#a7bfda", "#97b2d0"],
    ["#86a8c6", "#789aba"],
    ["#558b7b", "#477b6c"],
    ["#66a24d", "#4d873f"],
    ["#2f6835", "#204c27"],
  ],
  rim: "#ffffff",
  rimAlpha: 0.26,
  mist: "#eef6fa",
  mistAlpha: 0.3,
  road: ["#ddd7c9", "#cfcac1"],
  sheen: "#ffffff",
  sheenAlpha: 0.26,
  lamps: "#ffffff",
  lampsAlpha: 0.65,
  trees: "#1a4224",
  stars: 0,
};

const AFTERNOON: Light = {
  sky: ["#3b6ccb", "#5c88d8", "#8fa8de", "#c9c6e0", "#f5dcbc"],
  sun: "#fff6e0",
  glow: "#ffe2ad",
  halo: 0.55,
  ridges: [
    ["#a59ec8", "#9690bc"],
    ["#8389b8", "#747aaa"],
    ["#4c6b7c", "#405d6d"],
    ["#609545", "#487a38"],
    ["#2b5b2f", "#1d4223"],
  ],
  rim: "#ffe6b2",
  rimAlpha: 0.52,
  mist: "#f4dccc",
  mistAlpha: 0.35,
  road: ["#e6c9a0", "#d9b997"],
  sheen: "#fff0d0",
  sheenAlpha: 0.36,
  lamps: "#ffffff",
  lampsAlpha: 0.5,
  trees: "#173a1f",
  stars: 0,
};

const GOLDEN_LIGHT: Light = {
  sky: ["#2b46bc", "#5952d3", "#ad4c9f", "#e85868", "#f68a48"],
  sun: "#fff3d1",
  glow: "#ffb257",
  halo: 0.9,
  ridges: [
    ["#9a5cae", "#7d4ea3"],
    ["#5d47a3", "#4a3b8f"],
    ["#3d3a7c", "#2e2d66"],
    ["#5d8f3f", "#2f5a2f"],
    ["#1f4325", "#11281a"],
  ],
  rim: "#ffd27a",
  rimAlpha: 0.85,
  mist: "#f0907a",
  mistAlpha: 0.32,
  road: ["#ffb048", "#ff7f4a"],
  sheen: "#ffe5a8",
  sheenAlpha: 0.55,
  lamps: "#fff2cc",
  lampsAlpha: 0.3,
  trees: "#0e2014",
  stars: 0,
};

const SUNSET: Light = {
  sky: ["#212a85", "#4a3a9c", "#94408c", "#de4a62", "#ff7a3c"],
  sun: "#ffcf8f",
  glow: "#ff6e3a",
  halo: 0.95,
  ridges: [
    ["#7a4596", "#633c88"],
    ["#46357f", "#392d70"],
    ["#2c2a60", "#232250"],
    ["#3c5c35", "#223d27"],
    ["#16291b", "#0c1a11"],
  ],
  rim: "#ff9c5a",
  rimAlpha: 0.6,
  mist: "#e0607a",
  mistAlpha: 0.3,
  road: ["#ff9445", "#f0683c"],
  sheen: "#ffc98a",
  sheenAlpha: 0.5,
  lamps: "#ffd9a0",
  lampsAlpha: 0.55,
  trees: "#0a160e",
  stars: 0.06,
};

const DUSK: Light = {
  sky: ["#10173f", "#221f5c", "#4a2f72", "#8f4a78", "#d0706a"],
  sun: "#ff9a6a",
  glow: "#e86a52",
  halo: 0.45,
  ridges: [
    ["#3c3069", "#31295b"],
    ["#2c2758", "#231f4a"],
    ["#1b1d40", "#151834"],
    ["#1b2a2c", "#121d20"],
    ["#0d1714", "#08100d"],
  ],
  rim: "#d48aa0",
  rimAlpha: 0.18,
  mist: "#8a4a7a",
  mistAlpha: 0.26,
  road: ["#2c2c44", "#3a3553"],
  sheen: "#ff9e52",
  sheenAlpha: 0.28,
  lamps: "#ffc46b",
  lampsAlpha: 0.9,
  trees: "#050b09",
  stars: 0.6,
};

// Key hours around the clock. Between two keys the light eases from one to the
// other, mixed in OKLab so no hour goes muddy on the way.
const KEYS: [number, Light][] = [
  [0, NIGHT],
  [4.8, NIGHT],
  [6, DAWN],
  [7, SUNRISE],
  [9.5, MORNING],
  [12.5, NOON],
  [15.5, AFTERNOON],
  [17.6, GOLDEN_LIGHT],
  [18.6, SUNSET],
  [19.4, DUSK],
  [21.2, NIGHT],
  [24, NIGHT],
];

/* ── Color ──────────────────────────────────────────────── */

type Lab = [number, number, number];

function toLab(hex: string): Lab {
  const n = parseInt(hex.slice(1), 16);
  const lin = (c: number) => {
    const v = c / 255;
    return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
  };
  const r = lin((n >> 16) & 255);
  const g = lin((n >> 8) & 255);
  const b = lin(n & 255);
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  return [
    0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
    1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
    0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
  ];
}

function toRgb([L, a, b]: Lab) {
  const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
  const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
  const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
  const encode = (c: number) => {
    const v = c <= 0.0031308 ? 12.92 * Math.max(0, c) : 1.055 * c ** (1 / 2.4) - 0.055;
    return Math.round(Math.min(1, Math.max(0, v)) * 255);
  };
  return `rgb(${encode(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)}, ${encode(
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
  )}, ${encode(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)})`;
}

const mixLab = (a: Lab, b: Lab, t: number): Lab => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];

// Each key flattened once: every color in a fixed order, then every number. The
// meadow between the middle ridge and the near hill takes its colors from both.
const flatten = (light: Light) => {
  const ridges = light.ridges.map(([crest, foot]) => [toLab(crest), toLab(foot)]);
  const meadow = [mixLab(ridges[2][0], ridges[3][0], 0.62), mixLab(ridges[2][1], ridges[3][1], 0.55)];
  return {
    colors: [
      ...light.sky.map(toLab),
      toLab(light.sun),
      toLab(light.glow),
      ...ridges.slice(0, 3).flat(),
      ...meadow,
      ...ridges.slice(3).flat(),
      ...[light.rim, light.mist, ...light.road, light.sheen, light.lamps, light.trees].map(toLab),
    ],
    numbers: [light.halo, light.rimAlpha, light.mistAlpha, light.sheenAlpha, light.lampsAlpha, light.stars],
  };
};
const FLAT_KEYS = KEYS.map(([hour, light]) => [hour, flatten(light)] as const);

type Lit = {
  sky: string[];
  sun: string;
  glow: string;
  ridges: [string, string][];
  rim: string;
  mist: string;
  road: string[];
  sheen: string;
  lamps: string;
  trees: string;
  halo: number;
  rimAlpha: number;
  mistAlpha: number;
  sheenAlpha: number;
  lampsAlpha: number;
  stars: number;
};

function lightAt(hour: number): Lit {
  const h = mod(hour, 24);
  let i = 0;
  while (i < FLAT_KEYS.length - 2 && FLAT_KEYS[i + 1][0] <= h) i++;
  const [h0, a] = FLAT_KEYS[i];
  const [h1, b] = FLAT_KEYS[i + 1];
  const t = smoothstep(0, 1, (h - h0) / (h1 - h0));
  const c = a.colors.map((from, k) => toRgb(mixLab(from, b.colors[k], t)));
  const n = a.numbers.map((from, k) => from + (b.numbers[k] - from) * t);
  return {
    sky: c.slice(0, 5),
    sun: c[5],
    glow: c[6],
    ridges: [0, 1, 2, 3, 4, 5].map((r) => [c[7 + r * 2], c[8 + r * 2]] as [string, string]),
    rim: c[19],
    mist: c[20],
    road: [c[21], c[22]],
    sheen: c[23],
    lamps: c[24],
    trees: c[25],
    halo: n[0],
    rimAlpha: n[1],
    mistAlpha: n[2],
    sheenAlpha: n[3],
    lampsAlpha: n[4],
    stars: n[5],
  };
}

// Where the sun is at an hour: along an arc from the left of the valley to the
// right, under the far ridge outside daylight.
function sunAt(hour: number, horizon: number) {
  const t = (mod(hour, 24) - SUN.rise) / (SUN.set - SUN.rise);
  const x = -SUN.span + 2 * SUN.span * clamp(t, -0.06, 1.06);
  // The arc tops out a little under the top edge, however high the horizon sits.
  const y = horizon + 36 - Math.sin(Math.PI * clamp(t, 0, 1)) * Math.min(SUN.height, horizon - 60);
  return { x, y, up: t > -0.03 && t < 1.03 };
}

/* ── Helpers ────────────────────────────────────────────── */

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const mod = (v: number, m: number) => ((v % m) + m) % m;
const round = (v: number) => Math.round(v);
function smoothstep(edge0: number, edge1: number, v: number) {
  const t = clamp((v - edge0) / (edge1 - edge0), 0, 1);
  return t * t * (3 - 2 * t);
}

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

// A ridge line: three sines of falling size, each with its own phase.
function wave(rand: () => number, base: number, amp: number, lengths: [number, number, number]) {
  const phases = lengths.map(() => rand() * Math.PI * 2);
  const weights = [0.6, 0.28, 0.12];
  return (x: number) => base - amp * lengths.reduce((sum, length, i) => sum + weights[i] * Math.sin(x / length + phases[i]), 0);
}

// A smooth line through f across the world (quadratic curves through the
// midpoints), closed down to the floor so it can be filled.
function ridgePath(f: (x: number) => number) {
  const xs: number[] = [];
  for (let x = WORLD.left; x <= WORLD.right; x += WORLD.step) xs.push(x);
  const ys = xs.map(f);
  let d = `M${xs[0]} ${round(ys[0])}`;
  for (let i = 1; i < xs.length - 1; i++) {
    d += `Q${xs[i]} ${round(ys[i])} ${round((xs[i] + xs[i + 1]) / 2)} ${round((ys[i] + ys[i + 1]) / 2)}`;
  }
  const last = xs.length - 1;
  return `${d}L${xs[last]} ${round(ys[last])}L${xs[last]} ${WORLD.floor}L${xs[0]} ${WORLD.floor}Z`;
}

function ellipse(cx: number, cy: number, rx: number, ry: number) {
  const x = round(cx - rx);
  const w = round(rx * 2);
  return `M${x} ${round(cy)}a${round(rx)} ${round(ry)} 0 1 0 ${w} 0a${round(rx)} ${round(ry)} 0 1 0 ${-w} 0`;
}

// Trees standing along a line, in clumps: tall cypresses and round crowns.
function forest(
  rand: () => number,
  ground: (x: number) => number,
  spans: [number, number][],
  size: [number, number],
  gap: [number, number],
  sparse: number, // -1 (everywhere) to 1 (almost nowhere)
  cypress: number, // share of tall cypresses among round crowns
) {
  const clumps = wave(rand, 0, 1, [230, 97, 41]);
  let d = "";
  for (const [from, to] of spans) {
    for (let x = from; x < to; x += gap[0] + rand() * (gap[1] - gap[0])) {
      if (clumps(x) < sparse) continue;
      const h = size[0] + rand() * (size[1] - size[0]);
      const base = ground(x) + h * 0.06;
      d +=
        rand() < cypress
          ? ellipse(x, base - h * 0.5, h * 0.16, h * 0.5)
          : ellipse(x, base - h * 0.42, h * 0.3, h * 0.36) + `M${round(x - h * 0.03)} ${round(base - h * 0.1)}h${Math.max(2, round(h * 0.06))}V${round(base)}h${-Math.max(2, round(h * 0.06))}Z`;
    }
  }
  return d;
}

// Points along a cubic Bézier and its direction.
function bezier(p: [number, number][], t: number) {
  const u = 1 - t;
  const x = u * u * u * p[0][0] + 3 * u * u * t * p[1][0] + 3 * u * t * t * p[2][0] + t * t * t * p[3][0];
  const y = u * u * u * p[0][1] + 3 * u * u * t * p[1][1] + 3 * u * t * t * p[2][1] + t * t * t * p[3][1];
  const dx = 3 * u * u * (p[1][0] - p[0][0]) + 6 * u * t * (p[2][0] - p[1][0]) + 3 * t * t * (p[3][0] - p[2][0]);
  const dy = 3 * u * u * (p[1][1] - p[0][1]) + 6 * u * t * (p[2][1] - p[1][1]) + 3 * t * t * (p[3][1] - p[2][1]);
  const len = Math.hypot(dx, dy) || 1;
  return { x, y, nx: -dy / len, ny: dx / len };
}

// A strip along the road's center line, `share` of the road's width, from t0 to t1.
function strip(curve: [number, number][], width: (t: number) => number, share: number, t0: number, t1: number, steps: number) {
  const left: string[] = [];
  const right: string[] = [];
  for (let i = 0; i <= steps; i++) {
    const t = t0 + ((t1 - t0) * i) / steps;
    const p = bezier(curve, t);
    const w = (width(t) * share) / 2;
    left.push(`${round(p.x + p.nx * w)} ${round(p.y + p.ny * w)}`);
    right.push(`${round(p.x - p.nx * w)} ${round(p.y - p.ny * w)}`);
  }
  return `M${left.join("L")}L${right.reverse().join("L")}Z`;
}

/* ── The scene's shapes ─────────────────────────────────── */

function buildScene(seed: number, horizon: number) {
  const rand = mulberry32(seed * 7919 + 17);
  const far = wave(rand, horizon + 2, 30, [300, 140, 70]);
  const hills = wave(rand, horizon + 40, 40, [400, 180, 90]);
  const mid = wave(rand, horizon + 92, 50, [520, 230, 110]);
  const meadow = wave(rand, horizon + 150, 58, [860, 300, 130]);
  const near = wave(rand, horizon + 214, 66, [980, 340, 150]);
  const bump = wave(rand, 0, 18, [260, 120, 60]);
  // The slopes in front rise toward both sides and leave the middle open for the road.
  const front = (x: number) => {
    const side = smoothstep(220, 1250, Math.abs(x));
    return WORLD.floor + 40 - 330 * side + bump(x) * smoothstep(200, 600, Math.abs(x));
  };

  const ridges = [
    { d: ridgePath(far), top: horizon - 30 },
    { d: ridgePath(hills), top: horizon },
    { d: ridgePath(mid), top: horizon + 42 },
    { d: ridgePath(meadow), top: horizon + 104 },
    { d: ridgePath(near), top: horizon + 160 },
    { d: ridgePath(front), top: WORLD.floor - 300 },
  ];

  // The road: wide at the bottom edge, winding up and over the near hill.
  const end = near(ROAD.end) + 5;
  // Near the viewer it runs almost straight, so the wide end never folds over
  // itself; the bends tighten as it recedes.
  const curve: [number, number][] = [
    [-40, 1110],
    [80, 950],
    [-250, end + 70],
    [ROAD.end, end],
  ];
  const width = (t: number) => ROAD.far + (ROAD.near - ROAD.far) * (1 - t) ** 1.5;
  const road = {
    body: strip(curve, width, 1, 0, 1, 64),
    sheen: strip(curve, width, 0.4, 0, 0.985, 64),
    // Dashes down the middle, closer together as the road recedes.
    lamps: Array.from({ length: 20 }, (_, k) => {
      const t0 = 1 - (1 - k / 20) ** 1.3;
      const t1 = t0 + (1 - (1 - (k + 1) / 20) ** 1.3 - t0) * 0.45;
      return strip(curve, width, 0.04, t0, Math.min(t1, 0.99), 3);
    }).join(""),
    top: round(end),
  };

  const trees = {
    front: forest(rand, front, [[WORLD.left, -330], [330, WORLD.right]], [56, 132], [15, 32], -0.3, 0.55),
    mid: forest(rand, mid, [[WORLD.left, WORLD.right]], [11, 24], [11, 19], 0.3, 0.25),
  };

  // Stars thin out toward the horizon; three brightness classes keep it to three paths.
  const stars = ["", "", ""];
  for (let i = 0; i < 90; i++) {
    const x = WORLD.left + rand() * (WORLD.right - WORLD.left);
    const y = 14 + (horizon - 150) * rand() ** 1.7;
    const r = 0.8 + rand() * 1.6;
    stars[Math.floor(rand() * 3)] += `M${round(x - r)} ${round(y)}a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(r * 2).toFixed(1)} 0a${r.toFixed(1)} ${r.toFixed(1)} 0 1 0 ${(-r * 2).toFixed(1)} 0`;
  }

  return { horizon, ridges, road, trees, stars };
}

/* ── Clock ──────────────────────────────────────────────── */

// The visitor's local hour, to the minute, read on the client only.
const readClock = () => {
  const now = new Date();
  return now.getHours() + now.getMinutes() / 60;
};
const subscribeClock = (onChange: () => void) => {
  const timer = window.setInterval(onChange, TIMING.clock);
  return () => window.clearInterval(timer);
};
const noSubscribe = () => () => {};
const noClock = () => null;

function resolveHour(time: GoldenHourProps["time"], clock: number | null) {
  if (time === "auto") return clock ?? PRESETS.golden;
  if (typeof time === "number") return mod(time, 24);
  return PRESETS[time ?? "golden"] ?? PRESETS.golden;
}

/* ── Types ──────────────────────────────────────────────── */

export interface GoldenHourProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The hour the scene is lit for: a preset (`"dawn"`, `"morning"`, `"noon"`, `"afternoon"`, `"golden"`, `"sunset"`, `"dusk"`, `"night"`), an hour from 0 to 24 (17.5 is half past five in the evening), or `"auto"` for the visitor's own clock. A new value carries the sun there through the hours in between. */
  time?: GoldenHourPreset | number | "auto";
  /** Let the sun go down as the page scrolls on, from where the scene sat when the page loaded until it leaves the top. `true` moves it on 2 hours; a number sets the hours. */
  sunset?: boolean | number;
  /** Where the far ridge meets the sky, as a share of the height from the top. Lower it to leave room for a headline in the sky. */
  horizon?: number;
  /** The winding road from the bottom edge over the near hill. */
  road?: boolean;
  /** Trees on the slopes in front and along the middle ridge. */
  trees?: boolean;
  /** Strength of the film grain, from `0` (none) to `1`. */
  grain?: number;
  /** Reshapes the hills, the trees and the stars. Any whole number. */
  seed?: number;
  /** Settle the land into place and move the light on to the hour the first time the scene shows. */
  intro?: boolean;
  /** Let the ridges drift against the pointer, nearer ones more. */
  parallax?: boolean;
  /** Pixels the front ridge drifts sideways with the pointer at the scene's edge. Farther ridges drift less, and the up-and-down drift scales with it. */
  parallaxShift?: number;
  /** Seconds the sun takes to travel to a new `time`, plus 0.05s for every hour on the way. */
  duration?: number;
  /** Content laid over the scene, like a product shot or a headline. The root is `relative`, so it can be placed with `absolute`. */
  children?: React.ReactNode;
}

/* ── GoldenHour ─────────────────────────────────────────── */

export function GoldenHour({
  time = "golden",
  sunset = false,
  horizon = 0.58,
  road = true,
  trees = true,
  grain = 0.5,
  seed = 3,
  intro = true,
  parallax = true,
  parallaxShift = GOLDEN.shift,
  duration = TIMING.duration,
  className,
  style,
  children,
  ...props
}: GoldenHourProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const uid = React.useId().replace(/[^a-zA-Z0-9]/g, "");
  const ref = (name: string) => `gh${uid}-${name}`;
  const reduced = useReducedMotion();

  const horizonY = Math.round(clamp(horizon, 0.3, 0.82) * 1000);
  const scene = React.useMemo(() => buildScene(Math.round(seed), horizonY), [seed, horizonY]);

  const clock = React.useSyncExternalStore(
    time === "auto" ? subscribeClock : noSubscribe,
    time === "auto" ? readClock : noClock,
    noClock,
  );
  const target = resolveHour(time, clock);

  // What the server draws: the hour asked for, or half an hour short of it when
  // the light is still to move on. Read once, so later renders never repaint
  // over the colors the effects keep current.
  const [initial] = React.useState(() => {
    const hour = intro ? target - GOLDEN.lead : target;
    return { hour, light: lightAt(hour), sun: sunAt(hour, horizonY), lowered: intro };
  });
  const [hour] = React.useState(() => motionValue(initial.hour));
  const [lifts] = React.useState(() => LAYERS.map(() => motionValue(initial.lowered ? 1 : 0)));

  const sunsetHours = sunset === true ? GOLDEN.sunset : typeof sunset === "number" ? sunset : 0;
  const { scrollYProgress } = useScroll({ target: rootRef, offset: ["start end", "end start"] });
  const drift = useSpring(scrollYProgress, TIMING.scroll);
  const px = useSpring(0, TIMING.pointer);
  const py = useSpring(0, TIMING.pointer);

  const seen = useInView(rootRef, { once: true, amount: 0.2 });
  const settled = React.useRef(!intro);
  const scrollStart = React.useRef<number | null>(null);
  const travel = React.useRef<AnimationPlaybackControls | null>(null);
  const rising = React.useRef<AnimationPlaybackControls[]>([]);

  // Paint: every color, the sun and the layer offsets, written straight to the
  // SVG once a frame while anything moves.
  React.useLayoutEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const nodes = new Map<string, SVGElement>();
    root.querySelectorAll<SVGElement>("[data-gh]").forEach((node) => nodes.set(node.dataset.gh!, node));
    const layers = Array.from(root.querySelectorAll<SVGSVGElement>("[data-gh-layer]"));
    const set = (key: string, name: string, value: string | number) => nodes.get(key)?.setAttribute(name, String(value));

    // The sun sets over the scroll from wherever the scene sat when the page
    // loaded to where it leaves the top, so the first look is always `time`.
    if (scrollStart.current === null) {
      const box = root.getBoundingClientRect();
      scrollStart.current = clamp((window.innerHeight - box.top) / (window.innerHeight + box.height), 0, 1);
    }
    const start = scrollStart.current;
    const span = Math.max(0.25, 1 - start);
    const setting = () => clamp(((reduced ? scrollYProgress.get() : drift.get()) - start) / span, 0, 1);

    const paint = () => {
      const h = hour.get() + sunsetHours * setting();
      const lit = lightAt(h);
      const sun = sunAt(h, horizonY);
      lit.sky.forEach((color, i) => set(`sky${i}`, "stop-color", color));
      set("sun", "cx", sun.x.toFixed(1));
      set("sun", "cy", sun.y.toFixed(1));
      set("sun", "opacity", sun.up ? 1 : 0);
      set("sun0", "stop-color", lit.sun);
      set("sun1", "stop-color", lit.sun);
      set("sun2", "stop-color", lit.glow);
      set("glow", "cx", sun.x.toFixed(1));
      set("glow", "cy", Math.min(sun.y, horizonY + 10).toFixed(1));
      set("glow0", "stop-color", lit.glow);
      set("glow0", "stop-opacity", lit.halo.toFixed(3));
      set("glow1", "stop-color", lit.glow);
      set("glow1", "stop-opacity", (lit.halo * 0.38).toFixed(3));
      set("glow2", "stop-color", lit.glow);
      set("stars", "opacity", lit.stars.toFixed(3));
      set("moon", "opacity", smoothstep(0.55, 1, lit.stars).toFixed(3));
      set("rim", "x1", (sun.x - SUN.reach).toFixed(0));
      set("rim", "x2", (sun.x + SUN.reach).toFixed(0));
      for (const i of [0, 1, 2]) set(`rim${i}`, "stop-color", lit.rim);
      lit.ridges.forEach(([crest, foot], i) => {
        set(`ridge${i}a`, "stop-color", crest);
        set(`ridge${i}b`, "stop-color", foot);
        set(`ridge${i}`, "stroke-opacity", (lit.rimAlpha * (i === 5 ? 0.5 : 1)).toFixed(3));
      });
      for (const i of [0, 1, 2]) set(`mist${i}`, "stop-color", lit.mist);
      set("mist1", "stop-opacity", lit.mistAlpha.toFixed(3));
      set("road0", "stop-color", lit.road[0]);
      set("road1", "stop-color", lit.road[1]);
      set("sheen", "fill", lit.sheen);
      set("sheen", "fill-opacity", lit.sheenAlpha.toFixed(3));
      set("lamps", "fill", lit.lamps);
      set("lamps", "fill-opacity", lit.lampsAlpha.toFixed(3));
      set("trees", "fill", lit.trees);
      set("treesMid", "fill", lit.ridges[2][1]);
    };

    const place = () => {
      layers.forEach((layer, i) => {
        const { depth, rise } = LAYERS[i];
        const x = -px.get() * depth * parallaxShift;
        const y = -py.get() * depth * GOLDEN.lift * (parallaxShift / GOLDEN.shift);
        layer.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) translateY(${(lifts[i].get() * rise * 100).toFixed(3)}%)`;
      });
    };

    let painted = false;
    let placed = false;
    const flush = () => {
      if (painted) paint();
      if (placed) place();
      painted = placed = false;
    };
    const needPaint = () => {
      painted = true;
      frame.render(flush);
    };
    const needPlace = () => {
      placed = true;
      frame.render(flush);
    };

    paint();
    place();
    const offs = [
      hour.on("change", needPaint),
      ...(sunsetHours ? [drift.on("change", needPaint), scrollYProgress.on("change", needPaint)] : []),
      px.on("change", needPlace),
      py.on("change", needPlace),
      ...lifts.map((lift) => lift.on("change", needPlace)),
    ];
    return () => {
      offs.forEach((off) => off());
      cancelFrame(flush);
    };
  }, [scene, road, trees, horizonY, sunsetHours, reduced, hour, lifts, drift, scrollYProgress, px, py, parallaxShift]);

  // First view: the land settles and the light moves on to the hour.
  React.useEffect(() => {
    if (settled.current || !seen) return;
    settled.current = true;
    if (reduced) {
      lifts.forEach((lift) => lift.jump(0));
      hour.jump(target);
      return;
    }
    rising.current = lifts.map((lift, i) => animate(lift, 0, { ...TIMING.settle, delay: i * TIMING.stagger }));
    travel.current = animate(hour, hour.get() + shortWay(hour.get(), target), TIMING.ripen);
  }, [seen, reduced, target, hour, lifts]);

  // A new hour after that: the sun travels there the short way round the clock.
  React.useEffect(() => {
    if (!settled.current) return;
    const to = hour.get() + shortWay(hour.get(), target);
    if (Math.abs(to - hour.get()) < 0.001) return;
    travel.current?.stop();
    if (reduced) hour.jump(to);
    else {
      const visualDuration = Math.max(0, duration) + TIMING.travelPerHour * Math.abs(to - hour.get());
      travel.current = animate(hour, to, { ...TIMING.travel, visualDuration });
    }
  }, [target, reduced, hour, duration]);

  React.useEffect(
    () => () => {
      travel.current?.stop();
      rising.current.forEach((control) => control.stop());
    },
    [],
  );

  // The ridges drift against a mouse; touch leaves them still.
  React.useEffect(() => {
    const root = rootRef.current;
    if (!root || !parallax || reduced) {
      px.set(0);
      py.set(0);
      return;
    }
    const move = (event: PointerEvent) => {
      if (event.pointerType !== "mouse") return;
      const box = root.getBoundingClientRect();
      px.set(clamp(((event.clientX - box.left) / box.width) * 2 - 1, -1, 1));
      py.set(clamp(((event.clientY - box.top) / box.height) * 2 - 1, -1, 1));
    };
    const leave = () => {
      px.set(0);
      py.set(0);
    };
    root.addEventListener("pointermove", move);
    root.addEventListener("pointerleave", leave);
    return () => {
      root.removeEventListener("pointermove", move);
      root.removeEventListener("pointerleave", leave);
    };
  }, [parallax, reduced, px, py]);

  const { light, sun } = initial;
  const layerClass = "absolute -top-6 -left-6 h-[calc(100%+3rem)] w-[calc(100%+3rem)] overflow-hidden";
  const layerStyle = (i: number): React.CSSProperties => ({
    transform: `translate3d(0px, 0px, 0) translateY(${(initial.lowered ? LAYERS[i].rise * 100 : 0).toFixed(3)}%)`,
    willChange: i > 0 && parallax ? "transform" : undefined,
  });
  const svg = { viewBox: VIEWBOX, preserveAspectRatio: "xMidYMid slice", "data-gh-layer": "" };
  const ridgeFill = (i: number) => (
    <path
      data-gh={`ridge${i}`}
      d={scene.ridges[i].d}
      fill={`url(#${ref(`ridge${i}`)})`}
      stroke={`url(#${ref("rim")})`}
      strokeWidth={i >= 4 ? 3 : 2.5}
      strokeOpacity={light.rimAlpha * (i === 5 ? 0.5 : 1)}
      suppressHydrationWarning
    />
  );

  return (
    <div
      ref={rootRef}
      data-slot="golden-hour"
      className={cn("relative isolate overflow-hidden", className)}
      style={style}
      {...props}
    >
      <div aria-hidden className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
        {/* The sky, with every gradient the scene uses. */}
        <svg {...svg} className={layerClass} style={layerStyle(0)}>
          <defs>
            <linearGradient id={ref("sky")} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="0" y2={scene.horizon + 30}>
              {[0, 0.3, 0.56, 0.8, 1].map((offset, i) => (
                <stop key={offset} data-gh={`sky${i}`} offset={offset} stopColor={light.sky[i]} suppressHydrationWarning />
              ))}
            </linearGradient>
            <radialGradient id={ref("glow")}>
              <stop data-gh="glow0" offset="0" stopColor={light.glow} stopOpacity={light.halo} suppressHydrationWarning />
              <stop data-gh="glow1" offset="0.32" stopColor={light.glow} stopOpacity={light.halo * 0.38} suppressHydrationWarning />
              <stop data-gh="glow2" offset="1" stopColor={light.glow} stopOpacity="0" suppressHydrationWarning />
            </radialGradient>
            <radialGradient id={ref("sun")}>
              <stop data-gh="sun0" offset="0" stopColor={light.sun} suppressHydrationWarning />
              <stop data-gh="sun1" offset="0.55" stopColor={light.sun} suppressHydrationWarning />
              <stop data-gh="sun2" offset="1" stopColor={light.glow} stopOpacity="0" suppressHydrationWarning />
            </radialGradient>
            <radialGradient id={ref("moon")}>
              <stop offset="0" stopColor="#f6f3ea" />
              <stop offset="0.12" stopColor="#f6f3ea" />
              <stop offset="0.16" stopColor="#c9ccef" stopOpacity="0.35" />
              <stop offset="1" stopColor="#c9ccef" stopOpacity="0" />
            </radialGradient>
            <linearGradient
              id={ref("rim")}
              data-gh="rim"
              gradientUnits="userSpaceOnUse"
              x1={round(sun.x - SUN.reach)}
              x2={round(sun.x + SUN.reach)}
              y1="0"
              y2="0"
              suppressHydrationWarning
            >
              <stop data-gh="rim0" offset="0" stopColor={light.rim} stopOpacity="0" suppressHydrationWarning />
              <stop data-gh="rim1" offset="0.5" stopColor={light.rim} suppressHydrationWarning />
              <stop data-gh="rim2" offset="1" stopColor={light.rim} stopOpacity="0" suppressHydrationWarning />
            </linearGradient>
            {scene.ridges.map((ridge, i) => (
              <linearGradient
                key={i}
                id={ref(`ridge${i}`)}
                gradientUnits="userSpaceOnUse"
                x1="0"
                y1={ridge.top}
                x2="0"
                y2={i === 5 ? WORLD.floor : Math.min(WORLD.floor, ridge.top + 340)}
              >
                <stop data-gh={`ridge${i}a`} offset="0" stopColor={light.ridges[i][0]} suppressHydrationWarning />
                <stop data-gh={`ridge${i}b`} offset="1" stopColor={light.ridges[i][1]} suppressHydrationWarning />
              </linearGradient>
            ))}
            <linearGradient id={ref("mist")} gradientUnits="userSpaceOnUse" x1="0" y1={scene.horizon - 40} x2="0" y2={scene.horizon + 250}>
              <stop data-gh="mist0" offset="0" stopColor={light.mist} stopOpacity="0" suppressHydrationWarning />
              <stop data-gh="mist1" offset="0.5" stopColor={light.mist} stopOpacity={light.mistAlpha} suppressHydrationWarning />
              <stop data-gh="mist2" offset="1" stopColor={light.mist} stopOpacity="0" suppressHydrationWarning />
            </linearGradient>
            <linearGradient id={ref("road")} gradientUnits="userSpaceOnUse" x1="0" y1={WORLD.floor} x2="0" y2={scene.road.top}>
              <stop data-gh="road0" offset="0" stopColor={light.road[0]} suppressHydrationWarning />
              <stop data-gh="road1" offset="1" stopColor={light.road[1]} suppressHydrationWarning />
            </linearGradient>
            <clipPath id={ref("ground")}>
              <path d={scene.ridges[4].d} />
            </clipPath>
          </defs>
          <rect x={WORLD.left} y="-20" width={WORLD.right - WORLD.left} height={WORLD.floor + 40} fill={`url(#${ref("sky")})`} />
          <circle data-gh="glow" cx={sun.x} cy={Math.min(sun.y, scene.horizon + 10)} r={SUN.halo} fill={`url(#${ref("glow")})`} suppressHydrationWarning />
          <g data-gh="stars" opacity={light.stars} fill="#fdfbf4" suppressHydrationWarning>
            {scene.stars.map((d, i) => (
              <path key={i} d={d} opacity={[0.45, 0.7, 1][i]} />
            ))}
          </g>
          <g data-gh="moon" opacity={smoothstep(0.55, 1, light.stars)} suppressHydrationWarning>
            <circle cx="-250" cy="170" r="120" fill={`url(#${ref("moon")})`} />
          </g>
          <circle
            data-gh="sun"
            cx={sun.x}
            cy={sun.y}
            r={SUN.radius}
            opacity={sun.up ? 1 : 0}
            fill={`url(#${ref("sun")})`}
            suppressHydrationWarning
          />
        </svg>

        <svg {...svg} className={layerClass} style={layerStyle(1)}>
          {ridgeFill(0)}
        </svg>

        <svg {...svg} className={layerClass} style={layerStyle(2)}>
          {ridgeFill(1)}
        </svg>

        <svg {...svg} className={layerClass} style={layerStyle(3)}>
          <rect x={WORLD.left} y={scene.horizon - 40} width={WORLD.right - WORLD.left} height="290" fill={`url(#${ref("mist")})`} />
          {ridgeFill(2)}
          {trees && <path data-gh="treesMid" d={scene.trees.mid} fill={light.ridges[2][1]} suppressHydrationWarning />}
        </svg>

        <svg {...svg} className={layerClass} style={layerStyle(4)}>
          {ridgeFill(3)}
          {ridgeFill(4)}
          {road && (
            <g clipPath={`url(#${ref("ground")})`}>
              <path d={scene.road.body} fill={`url(#${ref("road")})`} />
              <path data-gh="sheen" d={scene.road.sheen} fill={light.sheen} fillOpacity={light.sheenAlpha} suppressHydrationWarning />
              <path data-gh="lamps" d={scene.road.lamps} fill={light.lamps} fillOpacity={light.lampsAlpha} suppressHydrationWarning />
            </g>
          )}
        </svg>

        <svg {...svg} className={layerClass} style={layerStyle(5)}>
          {ridgeFill(5)}
          {trees && <path data-gh="trees" d={scene.trees.front} fill={light.trees} suppressHydrationWarning />}
        </svg>

        {/* Film grain: a tile of noise laid over everything in overlay. */}
        {grain > 0 && (
          <div
            className="absolute inset-0 mix-blend-overlay"
            style={{ backgroundImage: GRAIN, backgroundSize: "180px 180px", opacity: clamp(grain, 0, 1) * 0.55 }}
          />
        )}
      </div>
      {children}
    </div>
  );
}

// The shortest signed way round the clock from one hour to another.
function shortWay(from: number, to: number) {
  return mod(to - from + 12, 24) - 12;
}

const GRAIN = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='1.7 0 0 0 -0.35 1.7 0 0 0 -0.35 1.7 0 0 0 -0.35 0 0 0 0 1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`;
```
