# Expanding Hero: Product Launch

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/expanding-hero-product-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/expanding-hero-product-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/expanding-hero-product-demo-light-965889843ce2b5ec.png
- Registry item: https://keyframeui.com/r/expanding-hero-product-demo.json
- Categories: hero, scroll, video
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/expanding-hero-product-demo.json
```

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

## Dependencies

- Registry items: `https://keyframeui.com/r/expanding-hero.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/expanding-hero#expanding-hero-product-demo

## Files

### `registry/demos/expanding-hero-product-demo.tsx`

```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>
  );
}
```
