Centered hero with announcement pill, dual CTAs, and a CSS-built dashboard frame that reveals in three staggered groups.
npx shadcn@latest add @paragon/hero-centeredAlso installs: button
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
/* Section reveal language: opacity + 12px rise + 4px blur, 3 groups
staggered 80ms, first in-view only. Reduced motion renders the final
state immediately (no movement to remove — `revealed` is true on
first paint). */
const REVEAL =
"transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";
const SIDEBAR_ITEMS = ["Overview", "Revenue", "Forecasts", "Reports", "Alerts"];
const STATS = [
{ label: "MRR", value: "$284,610", delta: "+8.2%" },
{ label: "Net retention", value: "118%", delta: "+2.1%" },
{ label: "Active accounts", value: "1,842", delta: "+64" },
];
const CHART_BARS = [34, 48, 41, 56, 50, 63, 58, 72, 66, 78, 84, 92];
export interface HeroCenteredProps extends React.ComponentProps<"section"> {
announcement?: string;
headline?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
}
/**
* Centered marketing hero: announcement pill, balanced headline, subcopy,
* dual CTAs, and a CSS-built product frame (fake dashboard made of real
* elements) under a shadow-overlay with a top-edge highlight.
*/
export function HeroCentered({
announcement = "Now in GA: revenue anomaly detection",
headline = "Understand every dollar of revenue",
subcopy = "Trellis unifies billing, CRM, and product data into one revenue model — with forecasts your CFO will actually sign off on.",
primaryCta = "Start free trial",
secondaryCta = "Book a demo",
className,
...props
}: HeroCenteredProps) {
const ref = React.useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
const reduced = useReducedMotion();
const revealed = inView || !!reduced;
const group = (i: number): React.CSSProperties => ({
transitionDelay: `${i * 80}ms`,
});
return (
<section
ref={ref}
aria-labelledby="hero-centered-headline"
className={cn(
"relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 md:py-24",
className,
)}
{...props}
>
{/* Faint top wash — the only background effect. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-[radial-gradient(70%_100%_at_50%_0%,var(--color-muted),transparent)] opacity-70"
/>
<div className="relative mx-auto max-w-3xl text-center">
<div className={cn(REVEAL, revealed ? SHOWN : HIDDEN)} style={group(0)}>
<a
href="#"
className="inline-flex items-center gap-1.5 rounded-full bg-card py-1 pr-2.5 pl-3 text-xs font-medium text-muted-foreground shadow-border transition-[box-shadow,color] duration-150 ease-out hover:text-foreground hover:shadow-border-hover"
>
<span
aria-hidden
className="size-1.5 rounded-full bg-emerald-500"
/>
{announcement}
<ArrowRight aria-hidden className="size-3" />
</a>
<h1
id="hero-centered-headline"
className="mt-6 text-4xl font-semibold tracking-tight text-balance sm:text-5xl"
>
{headline}
</h1>
</div>
<div className={cn(REVEAL, revealed ? SHOWN : HIDDEN)} style={group(1)}>
<p className="mx-auto mt-4 max-w-xl text-base text-pretty text-muted-foreground sm:text-lg">
{subcopy}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button size="lg">{primaryCta}</Button>
<Button size="lg" variant="outline">
{secondaryCta}
</Button>
</div>
</div>
<p
className={cn(
"mt-4 text-xs text-muted-foreground",
REVEAL,
revealed ? SHOWN : HIDDEN,
)}
style={group(1)}
>
Free for 14 days · No credit card required
</p>
</div>
{/* Product frame — a fake dashboard built from real elements. */}
<div
aria-hidden
className={cn(
"pointer-events-none relative mx-auto mt-14 w-full max-w-4xl select-none",
REVEAL,
revealed ? SHOWN : HIDDEN,
)}
style={group(2)}
>
<div className="relative overflow-hidden rounded-xl bg-card text-left shadow-overlay">
{/* Top-edge highlight. */}
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent" />
<div className="flex">
<aside className="hidden w-44 shrink-0 flex-col gap-0.5 border-r border-border p-3 sm:flex">
<div className="mb-2 flex items-center gap-2 px-2 py-1.5">
<div className="size-4 rounded-sm bg-primary" />
<span className="text-xs font-semibold">Trellis</span>
</div>
{SIDEBAR_ITEMS.map((item, i) => (
<div
key={item}
className={cn(
"rounded-md px-2 py-1.5 text-[11px]",
i === 0
? "bg-muted font-medium text-foreground"
: "text-muted-foreground",
)}
>
{item}
</div>
))}
</aside>
<div className="min-w-0 flex-1 p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-medium">Revenue overview</p>
<p className="text-[10px] text-muted-foreground">
Q3 2026 · updated 2m ago
</p>
</div>
<div className="rounded-md bg-primary px-2 py-1 text-[10px] font-medium text-primary-foreground">
Export
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{STATS.map((stat) => (
<div
key={stat.label}
className="rounded-lg border border-border p-2.5"
>
<p className="truncate text-[10px] text-muted-foreground">
{stat.label}
</p>
<p className="mt-0.5 truncate text-sm font-semibold tabular-nums">
{stat.value}
</p>
<p className="text-[10px] tabular-nums text-emerald-600 dark:text-emerald-400">
{stat.delta}
</p>
</div>
))}
</div>
<div className="mt-3 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between">
<p className="text-[10px] font-medium">
Monthly recurring revenue
</p>
<p className="text-[10px] text-muted-foreground">Jan – Dec</p>
</div>
<div className="mt-2 flex h-24 items-end gap-1.5">
{CHART_BARS.map((height, i) => (
<div
key={i}
className={cn(
"flex-1 rounded-t-sm",
i === CHART_BARS.length - 1
? "bg-primary"
: "bg-primary/15",
)}
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
}