An app screenshot instead of a video, built in markup so it stays sharp full screen. A "Watch the demo" button appears once it fills the box.
@keyframeui
registry/demos/expanding-hero-product-demo.tsx
"use client";
import * as React from "react";
import { ExpandingHero } from "@/components/ui/expanding-hero";
const features = [
{ title: "Docs", body: "Specs, briefs and notes that link straight to the work they describe." },
{ title: "Tasks", body: "Boards and lists that stay in step with the doc that started them." },
{ title: "Roadmaps", body: "Every project on one timeline, updated as tasks move." },
];
// The "screenshot" is markup, so it stays sharp at any size and follows the
// theme. A real image works the same way: pass an <img> as `media`.
export function ExpandingHeroProductDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50">
<div ref={scrollRef} className="relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
<ExpandingHero
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,36rem)]"
headlineClassName="@3xl:text-[clamp(3rem,6.4cqw,7.5rem)]"
before="Your work,"
after="in one place"
media={<AppScreenshot />}
overlay={
<a
href="#demo"
className="flex items-center gap-3 rounded-full bg-zinc-950 py-2 pr-5 pl-2 text-sm font-medium text-white shadow-xl shadow-zinc-950/20 transition-transform duration-150 ease-out active:scale-[0.97] dark:bg-white dark:text-zinc-950"
>
<span className="grid size-8 place-items-center rounded-full bg-white/15 dark:bg-zinc-950/10">
<svg viewBox="0 0 16 16" aria-hidden className="ml-0.5 size-3 fill-current">
<path d="M4 2.6v10.8a.6.6 0 0 0 .9.5l8.6-5.4a.6.6 0 0 0 0-1L4.9 2.1a.6.6 0 0 0-.9.5Z" />
</svg>
</span>
Watch the demo
<span className="text-white/50 tabular-nums dark:text-zinc-950/50">2:14</span>
</a>
}
>
<p className="max-w-sm text-base text-balance text-zinc-600 sm:text-lg dark:text-zinc-400">
Docs, tasks and roadmaps for teams that ship together.
</p>
<div className="flex gap-3">
<a
href="#start"
className="rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition-[background-color,scale] duration-150 ease-out hover:bg-indigo-500 active:scale-[0.97]"
>
Start for free
</a>
<a
href="#sales"
className="rounded-full px-5 py-2.5 text-sm font-medium ring-1 ring-zinc-950/10 transition-[background-color,scale] duration-150 ease-out ring-inset hover:bg-zinc-950/5 active:scale-[0.97] dark:ring-white/15 dark:hover:bg-white/5"
>
Talk to sales
</a>
</div>
</ExpandingHero>
<section className="mx-auto grid max-w-4xl gap-8 px-6 py-16 sm:grid-cols-3 sm:py-24">
{features.map((feature) => (
<div key={feature.title}>
<h2 className="font-medium">{feature.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
{feature.body}
</p>
</div>
))}
</section>
</div>
</div>
);
}
const tasks = [
{ title: "Final pricing copy", who: "MK", color: "bg-amber-400", done: true },
{ title: "Launch film, cut 3", who: "JS", color: "bg-sky-400", done: false },
{ title: "Press kit", who: "AL", color: "bg-rose-400", done: false },
{ title: "Changelog post", who: "MK", color: "bg-amber-400", done: false },
];
// Start and length on the timeline, in percent.
const roadmap = [
{ name: "Pricing page", start: 0, span: 34, tone: "bg-amber-400/25 text-amber-800 dark:text-amber-200" },
{ name: "Launch film", start: 20, span: 44, tone: "bg-sky-400/25 text-sky-800 dark:text-sky-200" },
{ name: "Press and launch day", start: 60, span: 40, tone: "bg-indigo-500/20 text-indigo-800 dark:text-indigo-200" },
];
const card = "rounded-lg bg-white p-3 ring-1 ring-zinc-950/5 dark:bg-zinc-800/60 dark:ring-white/5";
function AppScreenshot() {
return (
<div className="@container relative bg-linear-to-b from-indigo-100 via-indigo-50 to-white dark:from-indigo-950 dark:via-zinc-950 dark:to-zinc-950">
<div className="absolute inset-x-[4%] top-[8%] -bottom-px flex flex-col overflow-hidden rounded-t-xl bg-white text-left text-xs text-zinc-900 shadow-2xl ring-1 shadow-indigo-950/15 ring-zinc-950/10 dark:bg-zinc-900 dark:text-zinc-100 dark:shadow-black/50 dark:ring-white/10">
<div className="flex h-9 shrink-0 items-center gap-1.5 border-b border-zinc-950/5 px-3 dark:border-white/5">
{[0, 1, 2].map((dot) => (
<span key={dot} className="size-2.5 rounded-full bg-zinc-200 dark:bg-zinc-700" />
))}
<span className="mx-auto rounded-md bg-zinc-100 px-3 py-1 text-[11px] text-zinc-500 dark:bg-zinc-800">
tandem.app/q3-launch
</span>
</div>
<div className="flex min-h-0 flex-1">
<aside className="hidden w-44 shrink-0 flex-col gap-0.5 border-r border-zinc-950/5 p-3 @2xl:flex dark:border-white/5">
<div className="mb-3 flex items-center gap-2 px-2 font-semibold">
<span className="size-5 rounded-md bg-indigo-600" />
Tandem
</div>
{["Inbox", "My work", "Roadmap", "Docs"].map((item, i) => (
<span
key={item}
className={`rounded-md px-2 py-1.5 ${i === 2 ? "bg-zinc-100 font-medium dark:bg-zinc-800" : "text-zinc-500"}`}
>
{item}
</span>
))}
<span className="mt-4 px-2 text-[11px] text-zinc-400">Projects</span>
{[
["Q3 Launch", "bg-indigo-500"],
["Mobile app", "bg-emerald-500"],
["Brand refresh", "bg-rose-500"],
].map(([name, color]) => (
<span key={name} className="flex items-center gap-2 px-2 py-1.5 text-zinc-500">
<span className={`size-2 rounded-full ${color}`} />
{name}
</span>
))}
</aside>
<div className="flex min-w-0 flex-1 flex-col gap-3 bg-zinc-100 p-4 @3xl:p-6 dark:bg-zinc-900">
<div className="flex items-center justify-between text-zinc-500">
<span>Projects / Q3 Launch</span>
<div className="flex items-center gap-3">
<div className="flex -space-x-1.5">
{["bg-amber-400", "bg-sky-400", "bg-rose-400"].map((color) => (
<span key={color} className={`size-6 rounded-full ring-2 ring-zinc-100 dark:ring-zinc-900 ${color}`} />
))}
</div>
<span className="rounded-md bg-zinc-950 px-2.5 py-1 font-medium text-white dark:bg-white dark:text-zinc-950">
Share
</span>
</div>
</div>
<div className="mt-2 flex items-center gap-3">
<span className="text-xl font-semibold tracking-tight">Q3 Launch</span>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
On track
</span>
</div>
<div className="grid grid-cols-3 gap-3">
{[
["Tasks done", "128", "/ 164"],
["Open docs", "24", ""],
["Days to launch", "18", ""],
].map(([label, value, rest]) => (
<div key={label} className={card}>
<span className="text-zinc-500">{label}</span>
<p className="mt-1 text-lg font-semibold tabular-nums">
{value}
<span className="text-sm font-normal text-zinc-400"> {rest}</span>
</p>
</div>
))}
</div>
<div className="grid min-h-40 flex-1 gap-3 @3xl:grid-cols-[1.5fr_1fr]">
<div className={`${card} flex flex-col`}>
<div className="flex justify-between text-zinc-500">
<span>Burn-up</span>
<span>Last 6 weeks</span>
</div>
<svg
viewBox="0 0 300 100"
preserveAspectRatio="none"
aria-hidden
className="mt-3 min-h-20 w-full flex-1"
>
<defs>
<linearGradient id="tandem-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stopColor="rgb(99 102 241)" stopOpacity="0.35" />
<stop offset="1" stopColor="rgb(99 102 241)" stopOpacity="0" />
</linearGradient>
</defs>
<path
d="M0 88 C30 84 45 76 70 72 S115 60 140 52 S190 44 215 30 S265 18 300 10 V100 H0Z"
fill="url(#tandem-area)"
/>
<path
d="M0 88 C30 84 45 76 70 72 S115 60 140 52 S190 44 215 30 S265 18 300 10"
fill="none"
stroke="rgb(99 102 241)"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
<div className={card}>
<span className="text-zinc-500">Up next</span>
<ul className="mt-2 space-y-2.5">
{tasks.map((task) => (
<li key={task.title} className="flex items-center gap-2">
<span
className={`size-3.5 shrink-0 rounded-full ${task.done ? "bg-emerald-500" : "ring-1 ring-zinc-300 ring-inset dark:ring-zinc-600"}`}
/>
<span className={`truncate ${task.done ? "text-zinc-400 line-through" : ""}`}>
{task.title}
</span>
<span
className={`ml-auto grid size-5 shrink-0 place-items-center rounded-full text-[9px] font-semibold text-zinc-950 ${task.color}`}
>
{task.who}
</span>
</li>
))}
</ul>
</div>
</div>
<div className={card}>
<div className="flex justify-between text-zinc-500">
<span>Roadmap</span>
<span>Jul – Sep</span>
</div>
<div className="mt-3 space-y-1.5">
{roadmap.map((item) => (
<div key={item.name} className="relative h-6">
<span
className={`absolute inset-y-0 flex items-center truncate rounded-md px-2 font-medium ${item.tone}`}
style={{ left: `${item.start}%`, width: `${item.span}%` }}
>
{item.name}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
Live preview, examples and props: https://keyframeui.com/components/expanding-hero#expanding-hero-product-demo




























