Three new features spread down a page. Each one scrolls into view while the spotlight travels to it. The Live switch stays usable while it's highlighted.
@keyframeui
registry/demos/spotlight-tour-whats-new-demo.tsx
"use client";
import * as React from "react";
import { SpotlightTour, type SpotlightTourStep } from "@/components/ui/spotlight-tour";
// The features are spread down the page. The tour scrolls each one into view
// while the spotlight travels to it.
const steps: SpotlightTourStep[] = [
{
target: "[data-tour=whats-new]",
title: "3 new features",
body: "Here's what shipped this week. It takes about 20 seconds.",
},
{
target: "[data-tour=live]",
title: "Live mode",
body: "Charts now update in real time. Flip the switch to watch the numbers move.",
interactive: true,
},
{
target: "[data-tour=notes]",
title: "Notes on charts",
body: "Pin a note to any point on a chart, so next week's spike explains itself.",
},
{
target: "[data-tour=export]",
title: "Export to CSV",
body: "Download any table as a CSV, with your filters applied.",
},
];
const seed = [42, 48, 45, 53, 50, 58, 61, 57, 66, 63, 70, 74, 69, 78, 82, 79, 88, 85, 92, 96];
const weeks = [32, 38, 35, 44, 41, 47, 96, 58, 52, 55, 60, 63];
const pages = [
{ path: "/", views: "48,210", change: "+12%" },
{ path: "/pricing", views: "18,904", change: "+31%" },
{ path: "/docs/getting-started", views: "12,377", change: "+8%" },
{ path: "/blog/launch-week", views: "9,120", change: "+164%" },
{ path: "/changelog", views: "6,483", change: "-3%" },
{ path: "/careers", views: "2,941", change: "+5%" },
];
function useLive(on: boolean) {
const [points, setPoints] = React.useState(seed);
React.useEffect(() => {
if (!on) return;
const timer = window.setInterval(() => {
setPoints((current) => {
const last = current[current.length - 1];
const next = Math.max(40, Math.min(120, last + Math.round(Math.random() * 14 - 6)));
return [...current.slice(1), next];
});
}, 900);
return () => window.clearInterval(timer);
}, [on]);
return points;
}
function line(points: number[], width: number, height: number) {
const max = 130;
return points
.map((value, i) => `${i ? "L" : "M"}${((i / (points.length - 1)) * width).toFixed(1)} ${(height - (value / max) * height).toFixed(1)}`)
.join("");
}
const card = "rounded-xl bg-white p-4 ring-1 ring-zinc-900/[0.06] dark:bg-zinc-950 dark:ring-white/[0.07]";
export function SpotlightTourWhatsNewDemo() {
const [open, setOpen] = React.useState(false);
const [live, setLive] = React.useState(false);
const points = useLive(live);
const visitors = points[points.length - 1] * 37;
const path = line(points, 300, 96);
return (
<div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
<div className="h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
<header className="sticky top-0 z-10 flex h-14 items-center justify-between gap-3 border-b border-zinc-200/80 bg-zinc-50/85 px-4 backdrop-blur sm:px-6 dark:border-zinc-800/80 dark:bg-zinc-900/85">
<span className="flex items-center gap-2 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
<span aria-hidden className="size-5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-600" />
Pulse
</span>
<button
data-tour="whats-new"
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-white pr-1 pl-2.5 text-xs font-medium text-zinc-900 ring-1 ring-zinc-900/10 transition-[background-color,scale] duration-150 hover:bg-zinc-100 active:scale-[0.97] dark:bg-zinc-950 dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-800"
>
What's new
<span className="grid h-5 min-w-5 place-items-center rounded-full bg-emerald-500 px-1.5 text-[11px] text-white">3</span>
</button>
</header>
<div className="mx-auto max-w-2xl space-y-4 px-4 py-6 sm:px-6">
<div>
<h2 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Overview</h2>
<p className="mt-1 text-sm text-zinc-500">acme.com · Last 30 days</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:gap-3">
{[
["Visitors", "184k"],
["Bounce", "38%"],
["Avg. visit", "2m 41s"],
].map(([label, value]) => (
<div key={label} className={card}>
<p className="text-xs text-zinc-500">{label}</p>
<p className="mt-1 text-lg font-semibold tracking-tight text-zinc-900 tabular-nums dark:text-zinc-50">{value}</p>
</div>
))}
</div>
<section className={card}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Visitors right now</p>
<p className="mt-0.5 text-2xl font-semibold tracking-tight text-zinc-900 tabular-nums dark:text-zinc-50">
{visitors.toLocaleString("en-US")}
</p>
</div>
<label data-tour="live" className="flex cursor-pointer items-center gap-2 rounded-full py-1 pr-1 pl-2.5 text-xs font-medium text-zinc-600 dark:text-zinc-300">
Live
<button
type="button"
role="switch"
aria-checked={live}
onClick={() => setLive((on) => !on)}
className={`relative h-5 w-9 cursor-pointer rounded-full transition-colors duration-200 ${live ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-700"}`}
>
<span
className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out ${live ? "translate-x-4" : ""}`}
/>
</button>
</label>
</div>
<svg aria-hidden viewBox="0 0 300 96" preserveAspectRatio="none" className="mt-4 h-24 w-full overflow-visible">
<path d={`${path}L300 96L0 96Z`} className="fill-emerald-500/10" />
<path d={path} fill="none" strokeWidth={1.75} vectorEffect="non-scaling-stroke" className="stroke-emerald-500" />
</svg>
</section>
<div className="pt-4">
<h2 className="text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Weekly signups</h2>
<p className="mt-1 text-sm text-zinc-500">Week 7 was launch week.</p>
</div>
<section className={card}>
<div className="relative flex h-40 items-end gap-1.5 sm:gap-2">
{weeks.map((value, i) => (
<div
key={i}
className={`flex-1 rounded-t-[4px] ${i === 6 ? "bg-emerald-500" : "bg-zinc-200 dark:bg-zinc-800"}`}
style={{ height: `${value}%` }}
/>
))}
<div
data-tour="notes"
className="absolute top-1 left-[38%] w-44 rounded-lg bg-zinc-900 px-3 py-2 text-xs text-white shadow-lg sm:left-[46%] dark:bg-zinc-100 dark:text-zinc-900"
>
<p className="font-medium">Launch day</p>
<p className="mt-0.5 text-zinc-400 dark:text-zinc-600">Front page of Hacker News for 9 hours.</p>
</div>
</div>
</section>
<div className="grid gap-3 pt-4 sm:grid-cols-2">
{[
["Top referrer", "news.ycombinator.com", "31% of visits"],
["Top country", "United States", "42% of visits"],
["Top device", "Desktop", "68% of visits"],
["Top browser", "Chrome", "57% of visits"],
].map(([label, value, note]) => (
<div key={label} className={card}>
<p className="text-xs text-zinc-500">{label}</p>
<p className="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">{value}</p>
<p className="mt-0.5 text-xs text-zinc-500">{note}</p>
</div>
))}
</div>
<section className={`${card} p-0`}>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Top pages</p>
<button
data-tour="export"
type="button"
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-900/10 transition-colors hover:bg-zinc-50 dark:text-zinc-200 dark:ring-white/10 dark:hover:bg-zinc-900"
>
<svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
</svg>
Export CSV
</button>
</div>
<table className="w-full text-sm">
<tbody>
{pages.map((page) => (
<tr key={page.path} className="border-t border-zinc-100 dark:border-zinc-900">
<td className="max-w-0 truncate px-4 py-2.5 text-zinc-700 dark:text-zinc-300">{page.path}</td>
<td className="px-2 py-2.5 text-right text-zinc-900 tabular-nums dark:text-zinc-100">{page.views}</td>
<td
className={`px-4 py-2.5 text-right text-xs tabular-nums ${page.change.startsWith("-") ? "text-rose-600 dark:text-rose-400" : "text-emerald-600 dark:text-emerald-400"}`}
>
{page.change}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
</div>
<SpotlightTour open={open} onOpenChange={setOpen} steps={steps} />
</div>
);
}































