Expanding Hero
A headline with a small video set between its words. As you scroll, the window opens until it fills the screen, pushing the words off its edges.
- Hero
- Scroll
- Video
Uses motion, clsx, tailwind-merge
Selected work
2024–2026- LumenBrand film2026
- ParcelProduct launch2025
- HalcyonTitle sequence2025
- FieldnoteExplainer series2024
"use client";
import * as React from "react";
import { ExpandingHero } from "@/components/ui/expanding-hero";
const reel = "https://assets.mixkit.co/videos/44818/44818-720.mp4";
const poster = "https://assets.mixkit.co/videos/44818/44818-thumb-720-0.jpg";
const work = [
{ client: "Lumen", kind: "Brand film", year: 2026 },
{ client: "Parcel", kind: "Product launch", year: 2025 },
{ client: "Halcyon", kind: "Title sequence", year: 2025 },
{ client: "Fieldnote", kind: "Explainer series", year: 2024 },
];
// For a full-page hero, drop the scroll box, `containerRef` and
// `stageClassName`: the stage fills the window and pins while the page scrolls.
export function ExpandingHeroDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const reelRef = React.useRef<HTMLVideoElement>(null);
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-[#f4f3ef] 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)]"
before="Make things"
after="that move"
media={
<video
ref={reelRef}
src={reel}
poster={poster}
autoPlay
muted
loop
playsInline
className="dark:invert"
/>
}
overlay={
<button
type="button"
onClick={() => watch(reelRef.current)}
className="flex items-center gap-3 rounded-full bg-white/85 py-2 pr-5 pl-2 text-sm font-medium shadow-lg ring-1 shadow-black/10 ring-black/5 backdrop-blur-md transition-transform duration-150 ease-out active:scale-[0.97] dark:bg-zinc-900/80 dark:ring-white/10"
>
<span className="grid size-8 place-items-center rounded-full bg-zinc-950 text-white dark:bg-white dark:text-zinc-950">
<PlayIcon />
</span>
Watch the film
</button>
}
>
<p className="text-base text-zinc-600 sm:text-lg dark:text-zinc-400">
A studio for motion design
</p>
<a
href="#contact"
className="rounded-full bg-zinc-950 px-5 py-2.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"
>
Get in touch
</a>
</ExpandingHero>
<section className="mx-auto max-w-3xl px-6 py-16 sm:py-24">
<div className="flex items-baseline justify-between text-sm text-zinc-500">
<h2 className="font-medium text-zinc-950 dark:text-zinc-50">Selected work</h2>
<span className="tabular-nums">2024–2026</span>
</div>
<ul className="mt-6 border-t border-zinc-950/10 dark:border-white/10">
{work.map((item) => (
<li
key={item.client}
className="grid grid-cols-[1fr_auto] items-baseline gap-x-6 border-b border-zinc-950/10 py-4 sm:grid-cols-[1fr_1fr_auto] dark:border-white/10"
>
<span className="text-lg font-medium tracking-tight sm:text-xl">{item.client}</span>
<span className="hidden text-sm text-zinc-500 sm:block">{item.kind}</span>
<span className="text-sm text-zinc-500 tabular-nums">{item.year}</span>
</li>
))}
</ul>
</section>
</div>
</div>
);
}
/** Plays the reel from the top, full screen. */
function watch(video: HTMLVideoElement | null) {
if (!video) return;
video.currentTime = 0;
void video.play();
if (video.requestFullscreen) void video.requestFullscreen();
else (video as HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen?.();
}
function PlayIcon() {
return (
<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>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/expanding-hero.tsx 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/expanding-heroInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/expanding-hero.tsx"use client"; import * as React from "react"; import { animate, cubicBezier, motion, useMotionValue, useScroll, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * EXPANDING HERO STORYBOARD (driven by scroll progress, not time) * * 0% section pins. The media plays in a small rounded * window set into the headline, between its halves * 0→78% the window opens until it fills the stage * the media never resizes: it always covers the stage * and the window is a clip-path over it. It settles * from 115% to 100% as the window opens * each half rides the window edge it touches, off the * stage. The children fade over the first 20% and * ride the bottom edge down * the corners square off as the edges meet the stage's * 80→92% the overlay fades in * 100% section unpins and the page carries on * * Wide stages put the halves either side of the window and * push them sideways. Narrow ones stack the window between * them and push them up and down. * ───────────────────────────────────────────────────────── */ const HERO = { grow: 0.78, // share of the pin the window takes to fill the stage ease: cubicBezier(0.45, 0, 0.2, 1), // the window's pace across the grow zoom: 1.15, // media scale at the start; settles to 1 as the window opens fade: 0.2, // share of the pin over which the children fade out overlay: [0.8, 0.92], // where the overlay fades in, as shares of the pin reveal: 0.4, // s the media takes to fade in once the window is placed }; const SCROLL_SPRING = { stiffness: 120, damping: 30, mass: 0.4 }; const HEADLINE = "flex items-center font-semibold leading-[0.9] tracking-[-0.045em] text-[clamp(2.75rem,12cqw,5rem)] @3xl:text-[clamp(3rem,7cqw,8.5rem)]"; const CHILDREN = "mt-[clamp(1.75rem,4cqw,3rem)] flex flex-col items-center gap-6"; // Each half's box is trimmed to its cap height and baseline, so the window // centers on the capitals beside it and sits evenly between stacked halves. const HALF = "block whitespace-nowrap [text-box:trim-both_cap_alphabetic] will-change-transform"; // The media covers whatever box it's in, and never resizes with the window. const MEDIA = "*:size-full *:object-cover"; export interface ExpandingHeroProps extends React.HTMLAttributes<HTMLElement> { /** The words before the window. One to three short words read best. */ before: React.ReactNode; /** The words after the window. */ after: React.ReactNode; /** What plays in the window: a video, an image or any element. It covers the stage. */ media: React.ReactNode; /** Shown over the media once it fills the stage, like a play button. Centered. */ overlay?: React.ReactNode; /** How long the section stays pinned, in stage heights. */ scrollLength?: number; /** * Corner radius of the window at the start. A number is in px; a string is * any CSS length, and `em` follows the headline's size. */ radius?: number | string; /** * Scroll container to track. Defaults to the window. The container must be * positioned (e.g. `relative`), or scroll progress is measured from the page. */ containerRef?: React.RefObject<HTMLElement | null>; /** Classes for the pinned stage. Set its height here. */ stageClassName?: string; /** Classes for the headline, such as its font and size. */ headlineClassName?: string; /** Classes for the window's starting size. */ windowClassName?: string; } export function ExpandingHero({ scrollLength, radius, containerRef, stageClassName, windowClassName, ...props }: ExpandingHeroProps) { const reduceMotion = usePrefersReducedMotion(); if (reduceMotion) return <StillHero {...props} />; return ( <PinnedHero scrollLength={scrollLength} radius={radius} containerRef={containerRef} stageClassName={stageClassName} windowClassName={windowClassName} {...props} /> ); } /** * Where the window starts, measured from the stage: how far each of its edges * travels to reach the stage's, and which way each half is pushed. */ type Geometry = { top: number; right: number; bottom: number; left: number; /** The starting corner radius, in px. */ radius: number; /** "x": the half sits beside the window. "y": it's stacked above or below. */ before: "x" | "y"; after: "x" | "y"; }; // How far each piece travels by the time the window fills the stage. Halves // beside the window ride its side edge and follow its center up or down; // stacked halves ride its top or bottom edge. const TRAVEL = { beforeX: (g: Geometry) => (g.before === "x" ? -g.left : (g.right - g.left) / 2), beforeY: (g: Geometry) => (g.before === "x" ? (g.bottom - g.top) / 2 : -g.top), afterX: (g: Geometry) => (g.after === "x" ? g.right : (g.right - g.left) / 2), afterY: (g: Geometry) => (g.after === "x" ? (g.bottom - g.top) / 2 : g.bottom), childrenY: (g: Geometry) => g.bottom, }; function PinnedHero({ before, after, media, overlay, scrollLength = 2, radius = "0.22em", containerRef, stageClassName, headlineClassName, windowClassName, className, children, ...props }: ExpandingHeroProps) { const sectionRef = React.useRef<HTMLElement>(null); const stageRef = React.useRef<HTMLDivElement>(null); const headlineRef = React.useRef<HTMLHeadingElement>(null); const windowRef = React.useRef<HTMLSpanElement>(null); const beforeRef = React.useRef<HTMLSpanElement>(null); const afterRef = React.useRef<HTMLSpanElement>(null); const childrenRef = React.useRef<HTMLDivElement>(null); const [stageHeight, setStageHeight] = React.useState(0); const hasChildren = Boolean(children); const geometry = useMotionValue<Geometry | null>(null); // Hidden until the window is measured, so it never shows in the wrong place. const mediaOpacity = useMotionValue(0); const { scrollYProgress } = useScroll({ target: sectionRef, container: containerRef, offset: ["start start", "end end"], }); const progress = useSpring(scrollYProgress, SCROLL_SPRING); const grow = useTransform(progress, [0, HERO.grow], [0, 1], { ease: HERO.ease }); const clipPath = useTransform(() => { const g = geometry.get(); const rest = 1 - grow.get(); if (!g || rest <= 0) return "none"; // The corners square off as the nearest edge closes in on the stage's. const corner = Math.min(g.radius, Math.min(g.top, g.right, g.bottom, g.left) * rest); return `inset(${g.top * rest}px ${g.right * rest}px ${g.bottom * rest}px ${g.left * rest}px round ${corner}px)`; }); const zoom = useTransform(grow, [0, 1], [HERO.zoom, 1]); const beforeX = useTravel(geometry, grow, TRAVEL.beforeX); const beforeY = useTravel(geometry, grow, TRAVEL.beforeY); const afterX = useTravel(geometry, grow, TRAVEL.afterX); const afterY = useTravel(geometry, grow, TRAVEL.afterY); const childrenY = useTravel(geometry, grow, TRAVEL.childrenY); const childrenOpacity = useTransform(progress, [0, HERO.fade], [1, 0]); const childrenVisibility = useTransform(childrenOpacity, (o) => (o > 0 ? "visible" : "hidden")); const overlayOpacity = useTransform(progress, HERO.overlay, [0, 1]); const overlayVisibility = useTransform(overlayOpacity, (o) => (o > 0 ? "visible" : "hidden")); React.useLayoutEffect(() => { const stage = stageRef.current; const win = windowRef.current; const first = beforeRef.current; const last = afterRef.current; if (!stage || !win || !first || !last) return; let placed = false; const measure = () => { const s = stage.getBoundingClientRect(); const w = win.getBoundingClientRect(); if (!w.width || !w.height) return; geometry.set({ top: w.top - s.top, right: s.right - w.right, bottom: s.bottom - w.bottom, left: w.left - s.left, radius: parseFloat(getComputedStyle(win).borderTopLeftRadius) || 0, before: beside(first, win) ? "x" : "y", after: beside(last, win) ? "x" : "y", }); setStageHeight(s.height); if (!placed) { placed = true; animate(mediaOpacity, 1, { duration: HERO.reveal, ease: "easeOut" }); } }; measure(); // The content is centered, so anything in it changing size can move the // window: fonts loading, a resize, new copy. const observer = new ResizeObserver(measure); for (const el of [stage, headlineRef.current, win, first, last, childrenRef.current]) { if (el) observer.observe(el); } return () => observer.disconnect(); }, [geometry, mediaOpacity, hasChildren]); return ( <section ref={sectionRef} className={cn("relative", className)} {...props}> <div ref={stageRef} className={cn( "@container sticky top-0 flex h-dvh w-full flex-col items-center justify-center overflow-hidden px-6 text-center", stageClassName, )} > <h1 ref={headlineRef} className={cn( HEADLINE, "flex-col gap-y-[0.34em] @3xl:flex-row @3xl:gap-x-[0.2em]", headlineClassName, )} > <motion.span ref={beforeRef} className={HALF} style={{ x: beforeX, y: beforeY }}> {before} </motion.span>{" "} {/* Holds the window's place in the line. The media opens from here. */} <span ref={windowRef} aria-hidden className={cn( "block aspect-[16/10] w-[min(68cqw,26rem)] shrink-0 bg-current/10 @3xl:aspect-auto @3xl:h-[0.8em] @3xl:w-[1.6em]", windowClassName, )} style={{ borderRadius: radius }} />{" "} <motion.span ref={afterRef} className={HALF} style={{ x: afterX, y: afterY }}> {after} </motion.span> </h1> {children ? ( <motion.div ref={childrenRef} className={cn(CHILDREN, "will-change-transform")} style={{ y: childrenY, opacity: childrenOpacity, visibility: childrenVisibility }} > {children} </motion.div> ) : null} <motion.div className="absolute inset-0 z-10 overflow-hidden" style={{ clipPath, opacity: mediaOpacity }} > <motion.div className={cn("size-full will-change-transform", MEDIA)} style={{ scale: zoom }}> {media} </motion.div> </motion.div> {overlay ? ( <motion.div className="absolute inset-0 z-20 flex items-center justify-center" style={{ opacity: overlayOpacity, visibility: overlayVisibility }} > {overlay} </motion.div> ) : null} </div> {/* Scroll runway: the stage stays pinned while this scrolls past. */} <div aria-hidden style={{ height: stageHeight ? stageHeight * scrollLength : `${scrollLength * 100}dvh` }} /> </section> ); } /** Reduced motion: nothing pins. The headline, then the media at full width. */ function StillHero({ before, after, media, overlay, headlineClassName, className, children, ...props }: Omit< ExpandingHeroProps, "scrollLength" | "radius" | "containerRef" | "stageClassName" | "windowClassName" >) { return ( <section className={cn("@container relative", className)} {...props}> <div className="flex flex-col items-center px-6 pt-[clamp(4rem,12cqw,8rem)] pb-[clamp(2.5rem,6cqw,4rem)] text-center"> <h1 className={cn(HEADLINE, "flex-wrap justify-center gap-x-[0.25em]", headlineClassName)}> <span className="whitespace-nowrap">{before}</span>{" "} <span className="whitespace-nowrap">{after}</span> </h1> {children ? <div className={CHILDREN}>{children}</div> : null} </div> <div className="relative aspect-video w-full overflow-hidden"> <div className={cn("absolute inset-0", MEDIA)}>{media}</div> {overlay ? ( <div className="absolute inset-0 flex items-center justify-center">{overlay}</div> ) : null} </div> </section> ); } /** How far a piece has traveled: its full travel, scaled by how far the window has opened. */ function useTravel( geometry: MotionValue<Geometry | null>, grow: MotionValue<number>, travel: (g: Geometry) => number, ) { return useTransform(() => { const g = geometry.get(); return g ? travel(g) * grow.get() : 0; }); } /** Whether `el` shares a line with the window. Layout offsets ignore the pushes. */ function beside(el: HTMLElement, win: HTMLElement) { return el.offsetTop < win.offsetTop + win.offsetHeight && win.offsetTop < el.offsetTop + el.offsetHeight; } const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; function subscribeToReducedMotion(onChange: () => void) { const query = window.matchMedia(REDUCED_MOTION); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); } /** Reads false on the server and while hydrating, so the markup always matches. */ function usePrefersReducedMotion() { return React.useSyncExternalStore( subscribeToReducedMotion, () => window.matchMedia(REDUCED_MOTION).matches, () => false, ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Expanding Hero. Each one installs whole, with the component included.
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.
Your work, in one place
Docs, tasks and roadmaps for teams that ship together.
128 / 164
24
18
- Final pricing copyMK
- Launch film, cut 3JS
- Press kitAL
- Changelog postMK
Docs
Specs, briefs and notes that link straight to the work they describe.
Tasks
Boards and lists that stay in step with the doc that started them.
Roadmaps
Every project on one timeline, updated as tasks move.
"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>
);
}
Event
A conference hero with an aftermovie and a bolder headline. The date and city chips live inside the halves, so they fly off with the words.
Schedule
Day 1Thu, Sept 24
- Opening keynote
- The craft of small details
- Designing for motion
- Evening party
Day 2Fri, Sept 25
- Tools for thinking
- Building design systems that last
- Live redesign
- Closing keynote
"use client";
import * as React from "react";
import { ExpandingHero } from "@/components/ui/expanding-hero";
const film = "https://assets.mixkit.co/videos/13222/13222-720.mp4";
const poster = "https://assets.mixkit.co/videos/13222/13222-thumb-720-0.jpg";
const days = [
{
day: "Day 1",
date: "Thu, Sept 24",
sessions: ["Opening keynote", "The craft of small details", "Designing for motion", "Evening party"],
},
{
day: "Day 2",
date: "Fri, Sept 25",
sessions: ["Tools for thinking", "Building design systems that last", "Live redesign", "Closing keynote"],
},
];
export function ExpandingHeroEventDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const filmRef = React.useRef<HTMLVideoElement>(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="font-bold tracking-[-0.06em] text-[clamp(3rem,19cqw,7rem)] @3xl:text-[clamp(3rem,11cqw,11rem)]"
windowClassName="@3xl:w-[1.8em]"
radius="0.3em"
// Anything inside a half travels with it, so the chips fly off with the words.
before={
<span className="relative">
Config
<Chip className="bottom-full left-[0.08em] mb-3">
<span className="size-1.5 rounded-full bg-orange-500" />
Sept 24–25
</Chip>
</span>
}
after={
<span className="relative">
2026
<Chip className="top-full right-[0.08em] mt-3">San Francisco</Chip>
</span>
}
media={<video ref={filmRef} src={film} poster={poster} autoPlay muted loop playsInline />}
overlay={
<button
type="button"
onClick={() => watch(filmRef.current)}
className="flex items-center gap-3 rounded-full bg-white py-2 pr-5 pl-2 text-sm font-medium text-zinc-950 shadow-xl shadow-black/30 transition-transform duration-150 ease-out active:scale-[0.97]"
>
<span className="grid size-8 place-items-center rounded-full bg-orange-500 text-white">
<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 2025 aftermovie
</button>
}
>
<p className="mt-12 max-w-xs text-base text-balance text-zinc-600 @3xl:mt-6 sm:text-lg dark:text-zinc-400">
Two days on design, craft and the tools behind them.
</p>
<a
href="#tickets"
className="rounded-full bg-orange-500 px-5 py-2.5 text-sm font-medium text-white transition-[background-color,scale] duration-150 ease-out hover:bg-orange-600 active:scale-[0.97]"
>
Get tickets
</a>
</ExpandingHero>
<section className="mx-auto max-w-3xl px-6 py-16 sm:py-24">
<h2 className="text-sm font-medium text-zinc-500">Schedule</h2>
<div className="mt-6 grid gap-10 sm:grid-cols-2">
{days.map((day) => (
<div key={day.day}>
<p className="flex items-baseline justify-between border-b border-zinc-950/10 pb-3 dark:border-white/10">
<span className="text-lg font-semibold tracking-tight">{day.day}</span>
<span className="text-sm text-zinc-500">{day.date}</span>
</p>
<ul className="mt-3 space-y-2.5 text-sm text-zinc-600 dark:text-zinc-400">
{day.sessions.map((session) => (
<li key={session}>{session}</li>
))}
</ul>
</div>
))}
</div>
</section>
</div>
</div>
);
}
function Chip({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<span
className={`absolute flex items-center gap-2 rounded-full px-3 py-1.5 text-[13px] leading-none font-medium tracking-normal whitespace-nowrap text-zinc-600 ring-1 ring-zinc-950/10 dark:text-zinc-300 dark:ring-white/15 ${className}`}
>
{children}
</span>
);
}
/** Plays the film from the top, full screen. */
function watch(video: HTMLVideoElement | null) {
if (!video) return;
video.currentTime = 0;
void video.play();
if (video.requestFullscreen) void video.requestFullscreen();
else (video as HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen?.();
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
beforeRequired | React.ReactNode | None | The words before the window. One to three short words read best. Anything attached to them, like a chip, is pushed off with them. |
afterRequired | React.ReactNode | None | The words after the window. |
mediaRequired | React.ReactNode | None | What plays in the window: a video, an image or any element. It always covers the whole stage and never resizes; the window is a mask that opens onto it. |
children | React.ReactNode | None | Content under the headline, such as a subtitle and a button. It fades out early and rides the window's bottom edge away. |
overlay | React.ReactNode | None | Shown over the media once it fills the stage, like a play button. Centered; it fades in before the section unpins. |
scrollLength | number | 2 | How long the section stays pinned, in stage heights. The window opens over the first 78%; the overlay fades in over the rest. Much longer than 2 starts to feel like the page has taken over the scroll. |
radius | number | string | "0.22em" | The window's corner radius at the start. A number is in pixels; a string is any CSS length, and em follows the headline's size. The corners square off as the window meets the stage's edges. |
containerRef | React.RefObject<HTMLElement | null> | None | Scroll container to track. Defaults to the window. The container must be positioned (e.g. relative). |
stageClassName | string | "h-dvh" | Classes for the pinned stage. Set its height here. |
headlineClassName | string | None | Classes for the headline, such as its font, weight and size. The default size scales with the stage's width. |
windowClassName | string | None | Classes for the window's starting size. Defaults to 1.6em by 0.8em between the words, and to a 16:10 card on its own line on stages narrower than 48rem, where the words stack above and below it. |
className | string | None | Extra classes for the outer section. Other props pass through to it. With reduced motion, nothing pins: the headline sits above the media at full width. |
beforeRequiredType
React.ReactNodeThe words before the window. One to three short words read best. Anything attached to them, like a chip, is pushed off with them.
afterRequiredType
React.ReactNodeThe words after the window.
mediaRequiredType
React.ReactNodeWhat plays in the window: a video, an image or any element. It always covers the whole stage and never resizes; the window is a mask that opens onto it.
childrenType
React.ReactNodeContent under the headline, such as a subtitle and a button. It fades out early and rides the window's bottom edge away.
overlayType
React.ReactNodeShown over the media once it fills the stage, like a play button. Centered; it fades in before the section unpins.
scrollLengthType
numberDefault2How long the section stays pinned, in stage heights. The window opens over the first 78%; the overlay fades in over the rest. Much longer than 2 starts to feel like the page has taken over the scroll.
radiusType
number | stringDefault"0.22em"The window's corner radius at the start. A number is in pixels; a string is any CSS length, and
emfollows the headline's size. The corners square off as the window meets the stage's edges.containerRefType
React.RefObject<HTMLElement | null>Scroll container to track. Defaults to the window. The container must be positioned (e.g.
relative).stageClassNameType
stringDefault"h-dvh"Classes for the pinned stage. Set its height here.
headlineClassNameType
stringClasses for the headline, such as its font, weight and size. The default size scales with the stage's width.
windowClassNameType
stringClasses for the window's starting size. Defaults to
1.6emby0.8embetween the words, and to a 16:10 card on its own line on stages narrower than 48rem, where the words stack above and below it.classNameType
stringExtra classes for the outer section. Other props pass through to it. With reduced motion, nothing pins: the headline sits above the media at full width.