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.
registry/blocks/golden-hour-hero.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>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/golden-hour-hero