# Golden Hour Hero

> A hero for an AI trip planner, after the minimal split heroes of AI products: a three-line headline beside a quiet column with the button and a press strip, over a product window lit by a Golden Hour scene. In the window a frosted Footnote answers a traveler's question, numbering each claim from its source, and each example question relights the valley for its hour, running forward through the clock: golden hour, after dark, then sunrise. The sun goes down as the hero scrolls away.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/golden-hour-hero).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/golden-hour-hero
- Screenshot: https://shadcn.exchange/shot/keyframeui/golden-hour-hero/light.png
- Registry item: https://keyframeui.com/r/golden-hour-hero.json
- Categories: blocks, hero-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://keyframeui.com/r/footnote.json`, `https://keyframeui.com/r/golden-hour.json`, `utils`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/golden-hour-hero

## Files

### `registry/blocks/golden-hour-hero.tsx`

```tsx
"use client";

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

import { Footnote, type FootnoteClaim, type FootnoteSource } from "@/components/ui/footnote";
import { GoldenHour } from "@/components/ui/golden-hour";
import { cn } from "@/lib/utils";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the hero yours: the nav, the
 * copy, the names in the press strip, and the questions the
 * product answers in its window, each with the hour of day the
 * scene behind it is lit for. Keep the hours in the order of a
 * day, so each change runs forward through the clock.
 * ─────────────────────────────────────────────────────────── */

/** The one accent: the citation numbers, links and highlights in the answer. */
const ACCENT = { light: "#5b54d6", dark: "#a49dff" };

const BRAND = { name: "Meander", href: "#" };
const NAV_LINKS = [
  { label: "How it works", href: "#how-it-works" },
  { label: "Destinations", href: "#destinations" },
  { label: "Pricing", href: "#pricing" },
];
const SIGN_IN = { label: "Sign in", href: "#sign-in" };

const HEADLINE = "Trip plans that cite their sources.";
const SUBLINE =
  "Meander reads the timetables, opening hours and local guides before it suggests a single stop, and links each one to where it found it. Ask in a sentence, check anything with a tap.";
const PRIMARY_CTA = { label: "Plan a trip", href: "#plan" };
const SECONDARY_CTA = { label: "See a sample plan", href: "#sample" };
const NOTE = "Free for your first three trips.";

/** Under the buttons: where it's been written about. Wordmarks set in type; swap in real logos. */
const PRESS_LABEL = "As seen in";
const PRESS: { name: string; className: string }[] = [
  { name: "Wayfarer Weekly", className: "font-serif italic" },
  { name: "NORTHBOUND", className: "font-mono text-[12px] tracking-[0.18em]" },
  { name: "Slow Road", className: "font-semibold tracking-tight" },
  { name: "field & fold", className: "font-medium lowercase" },
];

/** The address in the product window's bar. */
const APP_URL = "meander.app/plan/brava-valley";

type Question = {
  /** The short label on the picker above the answer. */
  label: string;
  question: string;
  /** Hour of the day (0–24) the scene is lit for while this answer shows. */
  hour: number;
  read: number;
  sources: FootnoteSource[];
  answer: FootnoteClaim[];
};

const QUESTIONS: Question[] = [
  {
    label: "Train or car?",
    question: "Two days in the Brava Valley with a toddler: train or car?",
    hour: 17.6,
    read: 14,
    sources: [
      {
        id: "rail",
        site: "valleyline.travel",
        title: "Valley Line timetable, winter 2026",
        href: "#valley-line",
        snippet:
          "Trains leave Porto Lume for Vila Brava every two hours from 07:10. The last train back leaves Vila Brava at 19:40.",
        quote: "The last train back leaves Vila Brava at 19:40",
      },
      {
        id: "family",
        site: "smalltravelers.blog",
        title: "The Brava Valley with a two-year-old",
        href: "#small-travelers",
        snippet:
          "The valley road has eleven hairpins between Vila Brava and the river. Our daughter slept through the train and was carsick on the road.",
        quote: "eleven hairpins",
      },
      {
        id: "estates",
        site: "bravaestates.org",
        title: "Wine estates: visits and bookings",
        href: "#estates",
        snippet: "Most estates open for visits at 10:00. At weekends, tastings must be booked at least a day ahead.",
        quote: "must be booked at least a day ahead",
      },
      {
        id: "weather",
        site: "weatherdesk.io",
        title: "Vila Brava: October averages",
        href: "#october",
        snippet: "Afternoon highs average 19°C. Rain falls on about one day in four, mostly in the mornings.",
        quote: "about one day in four",
      },
    ],
    answer: [
      { text: "Take the train in and rent a car for one day.", cite: ["rail", "family"] },
      { text: "Trains run every two hours; the last one back leaves at 19:40.", cite: "rail" },
      { text: "The road has eleven hairpins, so plan the drive around a nap.", cite: "family" },
      { text: "Book tastings a day ahead; estates open at 10:00.", cite: "estates" },
      { text: "Expect around 19°C, with rain one day in four.", cite: "weather" },
    ],
  },
  {
    label: "Late dinner",
    question: "Somewhere good to eat after 9pm that isn't touristy?",
    hour: 20.4,
    read: 11,
    sources: [
      {
        id: "kitchens",
        site: "localtable.guide",
        title: "Late kitchens in Vila Brava",
        href: "#late-kitchens",
        snippet: "Casa Rio takes its last orders at 22:30 and fills with boat crews after their shift.",
        quote: "takes its last orders at 22:30",
      },
      {
        id: "reviews",
        site: "tablenotes.app",
        title: "Casa Rio: what 640 reviews mention",
        href: "#casa-rio",
        snippet: "Grilled fish and the river terrace come up most. Few of the reviews are in English.",
        quote: "Few of the reviews are in English",
      },
      {
        id: "bus",
        site: "valleyline.travel",
        title: "Night buses",
        href: "#night-buses",
        snippet: "Bus 4 runs back to the upper town every 40 minutes until 00:20.",
        quote: "every 40 minutes until 00:20",
      },
    ],
    answer: [
      { text: "Try Casa Rio by the bridge: the kitchen takes orders until 22:30, and the boat crews eat there after work.", cite: "kitchens" },
      { text: "Reviews mostly mention the grilled fish and the river terrace, and few are in English.", cite: "reviews" },
      { text: "Bus 4 brings you back up the hill every 40 minutes until 00:20.", cite: "bus" },
    ],
  },
  {
    label: "Easy sunrise",
    question: "Where can we watch the sunrise without a hard climb?",
    hour: 7.3,
    read: 9,
    sources: [
      {
        id: "trail",
        site: "serraalta.trails",
        title: "North Ridge viewpoint",
        href: "#north-ridge",
        snippet:
          "A 900 m path from the upper car park reaches the North Ridge viewpoint, with 60 m of gentle climb and a bench facing east.",
        quote: "60 m of gentle climb",
      },
      {
        id: "sun",
        site: "sunclock.app",
        title: "Sunrise and sunset in Vila Brava",
        href: "#sun-times",
        snippet: "On 24 October the sun rises at 07:41 and sets at 18:32.",
        quote: "the sun rises at 07:41",
      },
      {
        id: "parking",
        site: "bravaparks.org",
        title: "Upper car park: opening hours",
        href: "#car-park",
        snippet: "The upper car park opens at 06:30 from April to October, and at 07:00 through the winter.",
        quote: "opens at 06:30 from April to October",
      },
    ],
    answer: [
      { text: "Go to the North Ridge viewpoint: fifteen minutes from the upper car park, with only 60 m of climb.", cite: "trail" },
      { text: "Sunrise is at 07:41, so leave the inn by 07:00.", cite: "sun" },
      { text: "The car park opens at 06:30 until the end of October.", cite: "parking" },
      { text: "Bring a layer: the bench faces east and catches the wind.", cite: "trail" },
    ],
  },
];

/** How long a finished answer stays before the next question plays, in ms. Resting on the window holds it; picking a question, or reduced motion, stops it. */
const NEXT_AFTER = 7000;

/* ── Hero ────────────────────────────────────────────────── */

const focusRing =
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:focus-visible:outline-white";

export function GoldenHourHero() {
  const [index, setIndex] = React.useState(0);
  const [finished, setFinished] = React.useState(-1);
  const [resting, setResting] = React.useState(false);
  const [picked, setPicked] = React.useState(false);
  const reduced = useReducedMotion();
  const current = QUESTIONS[index];

  // Once an answer has finished, the next question plays after a pause, unless the visitor is
  // reading it or has picked one themselves.
  React.useEffect(() => {
    if (finished !== index || resting || picked || reduced) return;
    const timer = window.setTimeout(() => setIndex((i) => (i + 1) % QUESTIONS.length), NEXT_AFTER);
    return () => window.clearTimeout(timer);
  }, [finished, index, resting, picked, reduced]);

  return (
    <section
      style={{ "--accent-light": ACCENT.light, "--accent-dark": ACCENT.dark } as React.CSSProperties}
      className="relative isolate overflow-x-clip bg-[#f3f1ec] text-zinc-950 [--footnote-accent:var(--accent-light)] dark:bg-zinc-950 dark:text-white dark:[--footnote-accent:var(--accent-dark)]"
    >
      <header className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-6 px-6">
        <a href={BRAND.href} className={cn("flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-tight", focusRing)}>
          <MeanderMark className="size-5" />
          {BRAND.name}
        </a>
        <nav aria-label="Main" className="hidden flex-1 items-center gap-7 pl-6 text-sm text-zinc-600 md:flex dark:text-zinc-400">
          {NAV_LINKS.map((link) => (
            <a
              key={link.href}
              href={link.href}
              className={cn("rounded-md transition-colors duration-150 hover:text-zinc-950 dark:hover:text-white", focusRing)}
            >
              {link.label}
            </a>
          ))}
        </nav>
        <a
          href={SIGN_IN.href}
          className={cn(
            "rounded-full bg-white px-4 py-1.5 text-sm font-medium shadow-sm ring-1 ring-zinc-950/10 transition-colors duration-150 hover:bg-zinc-50 dark:bg-white/10 dark:ring-white/10 dark:hover:bg-white/15",
            focusRing,
          )}
        >
          {SIGN_IN.label}
        </a>
      </header>

      <div className="mx-auto max-w-6xl px-6 pt-10 pb-20 sm:pt-14 lg:pb-28">
        <div className="grid items-start gap-8 lg:grid-cols-[1.35fr_1fr] lg:gap-16">
          <h1 className="max-w-[13ch] text-[2.75rem]/[1.02] font-medium tracking-[-0.045em] text-balance sm:text-6xl/[1] lg:text-[4.75rem]/[0.98]">
            {HEADLINE}
          </h1>
          <div className="lg:pt-2.5">
            <p className="max-w-md text-base/7 text-pretty text-zinc-600 dark:text-zinc-400">{SUBLINE}</p>
            <div className="mt-7 flex flex-wrap items-center gap-x-6 gap-y-3">
              <a
                href={PRIMARY_CTA.href}
                className={cn(
                  "inline-flex h-11 items-center rounded-full bg-zinc-950 px-5 text-sm font-medium text-white transition-[background-color,scale] duration-150 ease-out hover:bg-zinc-800 active:scale-[0.97] dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200",
                  focusRing,
                )}
              >
                {PRIMARY_CTA.label}
              </a>
              <a
                href={SECONDARY_CTA.href}
                className={cn(
                  "group inline-flex items-center gap-1 rounded-md text-sm font-medium text-zinc-700 transition-colors duration-150 hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white",
                  focusRing,
                )}
              >
                {SECONDARY_CTA.label}
                <ArrowIcon className="size-3.5 transition-transform duration-200 group-hover:translate-x-0.5" />
              </a>
            </div>
            <p className="mt-4 text-sm text-zinc-500">{NOTE}</p>
            <div className="mt-7 border-t border-zinc-950/10 pt-5 dark:border-white/10">
              <p className="text-xs text-zinc-500">{PRESS_LABEL}</p>
              <ul className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-zinc-400 dark:text-zinc-500">
                {PRESS.map((name) => (
                  <li key={name.name} className={name.className}>
                    {name.name}
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </div>

        {/* The product window: the valley lit for the question's hour, and the answer over it. */}
        <div
          className="mt-10 sm:mt-12"
          onPointerEnter={(event) => event.pointerType === "mouse" && setResting(true)}
          onPointerLeave={() => setResting(false)}
          onFocus={() => setResting(true)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) setResting(false);
          }}
        >
          <GoldenHour
            time={current.hour}
            sunset
            horizon={0.5}
            className="h-[44rem] rounded-[1.75rem] shadow-2xl shadow-zinc-950/15 ring-1 ring-zinc-950/10 sm:h-[40rem] dark:ring-white/10"
          >
            <div className="absolute inset-x-0 top-0 flex h-11 items-center gap-3 border-b border-white/15 bg-white/10 px-4 backdrop-blur-md">
              <div className="flex gap-1.5" aria-hidden>
                <span className="size-2.5 rounded-full bg-white/50" />
                <span className="size-2.5 rounded-full bg-white/30" />
                <span className="size-2.5 rounded-full bg-white/30" />
              </div>
              <p className="mx-auto flex h-6 w-full max-w-72 items-center justify-center rounded-full bg-white/15 px-3 text-[11px] text-white/85">
                <span className="truncate">{APP_URL}</span>
              </p>
              <div className="w-10" aria-hidden />
            </div>

            <div className="absolute inset-x-3 bottom-3 flex flex-col gap-3 sm:inset-x-auto sm:top-[4.75rem] sm:bottom-auto sm:left-8 sm:w-[27rem] lg:left-12 lg:w-[28rem]">
              <div role="group" aria-label="Example questions" className="flex flex-wrap gap-1.5">
                {QUESTIONS.map((q, i) => (
                  <button
                    key={q.label}
                    type="button"
                    aria-pressed={i === index}
                    onClick={() => {
                      setPicked(true);
                      setIndex(i);
                    }}
                    className={cn(
                      "rounded-full px-3 py-1 text-xs font-medium ring-1 backdrop-blur-xl transition-[background-color,color,box-shadow] duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white",
                      i === index
                        ? "bg-white text-zinc-950 ring-white"
                        : "bg-zinc-950/25 text-white ring-white/20 hover:bg-zinc-950/35",
                    )}
                  >
                    {q.label}
                  </button>
                ))}
              </div>
              <Footnote
                question={current.question}
                answer={current.answer}
                sources={current.sources}
                read={current.read}
                avatar={<MeanderMark className="size-3.5" />}
                onComplete={() => setFinished(index)}
                className="bg-white/75 shadow-2xl shadow-zinc-950/25 ring-white/60 backdrop-blur-xl dark:bg-zinc-950/60 dark:ring-white/15"
              />
            </div>
          </GoldenHour>
        </div>
      </div>
    </section>
  );
}

/* ── Icons ───────────────────────────────────────────────── */

function MeanderMark({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 20 20" className={className} fill="none" aria-hidden>
      <path
        d="M3.5 17.5c0-3.6 5.5-3.4 5.5-7S4.6 6.2 7.2 2.5M11 17.5c0-3 5.5-3 5.5-6.4"
        stroke="currentColor"
        strokeWidth={2}
        strokeLinecap="round"
      />
    </svg>
  );
}

function ArrowIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 16 16" className={className} fill="none" stroke="currentColor" strokeWidth={1.6} aria-hidden>
      <path d="M3.5 8h9m-3.5-3.5L12.5 8 9 11.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}
```
