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.
"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>
);
}
Installation
The CLI writes components/golden-hour-hero.tsx, the components it's built with (components/ui/footnote.tsx and components/ui/golden-hour.tsx) and lib/utils.ts, and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/golden-hour-heroThen put it where the section goes. The copy, links and data sit in constants at the top of the file, so making it yours starts there.
import { GoldenHourHero } from "@/components/golden-hour-hero";
export default function Page() {
return <GoldenHourHero />;
}
Built with
Each of these is a component on its own, with its own examples and props. The block installs them for you.
- Footnote An AI answer that shows its work. It reads its sources, dealing a chip for each one, then writes the answer, and as each claim lands its source's number lifts out of the chip and drops in at the end of the sentence. Point at a sentence and its source lights up; point at a source and the sentences it backs light up, and resting on it opens the passage the answer leans on. @keyframeui/footnote
- Golden Hour 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. @keyframeui/golden-hour