Split hero with a staggered check-draw value checklist and a perspective-tilted product frame that settles flat on hover.
npx shadcn@latest add @paragon/hero-splitAlso installs: button
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
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 CHECKLIST = [
"Distributed traces flowing in under a minute",
"P99 alerts fire before customers notice",
"Zero-config SDKs for nine languages",
];
/* Trace waterfall rows for the product frame — static layout, never animated. */
const SPANS = [
{ name: "edge-gateway", start: 0, width: 96, ms: "412ms", root: true },
{ name: "auth.verify", start: 3, width: 14, ms: "58ms" },
{ name: "orders.create", start: 18, width: 44, ms: "190ms" },
{ name: "postgres.tx", start: 28, width: 22, ms: "91ms" },
{ name: "billing.charge", start: 64, width: 30, ms: "124ms", warn: true },
];
export interface HeroSplitProps extends React.ComponentProps<"section"> {
badge?: string;
headline?: string;
subcopy?: string;
primaryCta?: string;
secondaryCta?: string;
}
/**
* Split hero: copy and a three-point checklist on the left, a CSS-built
* product frame on the right resting at a slight perspective tilt that
* settles flat on hover (transform only, precise pointers only). The
* checklist checkmarks draw in with a stagger on first reveal.
*/
export function HeroSplit({
badge = "Relay 2.0",
headline = "Ship APIs with total visibility",
subcopy = "Relay traces every request across your services, surfaces the slow path automatically, and pages the right on-call before latency becomes churn.",
primaryCta = "Start instrumenting",
secondaryCta = "View live demo",
className,
...props
}: HeroSplitProps) {
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-split-headline"
className={cn(
"relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 md:py-24",
className,
)}
{...props}
>
<div className="mx-auto grid w-full max-w-5xl items-center gap-12 lg:grid-cols-2 lg:gap-10">
<div>
<div
className={cn(REVEAL, revealed ? SHOWN : HIDDEN)}
style={group(0)}
>
<span className="inline-flex items-center gap-1.5 rounded-full bg-card px-3 py-1 text-xs font-medium text-muted-foreground shadow-border">
<span aria-hidden className="size-1.5 rounded-full bg-sky-500" />
{badge}
</span>
<h1
id="hero-split-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="mt-4 max-w-md text-base text-pretty text-muted-foreground sm:text-lg">
{subcopy}
</p>
<ul className="mt-6 space-y-3">
{CHECKLIST.map((item, i) => (
<li key={item} className="flex items-start gap-2.5 text-sm">
<svg
viewBox="0 0 16 16"
aria-hidden
className="mt-0.5 size-4 shrink-0"
>
<circle
cx="8"
cy="8"
r="7.25"
fill="none"
strokeWidth="1.5"
className="stroke-border"
/>
{/* Check draw: pathLength-normalized dashoffset, staggered. */}
<path
d="M4.75 8.5 7 10.75 11.25 5.75"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray="1"
strokeDashoffset={revealed ? 0 : 1}
style={{
transition: reduced
? "none"
: `stroke-dashoffset 400ms var(--ease-out) ${360 + i * 120}ms`,
}}
/>
</svg>
{item}
</li>
))}
</ul>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Button size="lg">{primaryCta}</Button>
<Button size="lg" variant="outline">
{secondaryCta}
</Button>
</div>
</div>
</div>
{/* Product frame: perspective tilt settles flat on hover. */}
<div
className={cn(REVEAL, revealed ? SHOWN : HIDDEN)}
style={{ ...group(2), perspective: "1400px" }}
>
<div
aria-hidden
className={cn(
"pointer-events-auto relative overflow-hidden rounded-xl bg-card shadow-overlay select-none",
"transition-transform duration-300 ease-out motion-reduce:transform-none motion-reduce:transition-none",
"[transform:rotateX(4deg)_rotateY(-7deg)]",
"[@media(hover:hover)_and_(pointer:fine)]:hover:[transform:rotateX(0deg)_rotateY(0deg)]",
)}
>
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/25 to-transparent" />
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="size-3.5 rounded-sm bg-primary" />
<span className="text-xs font-semibold">Relay</span>
<span className="text-[10px] text-muted-foreground">
/ trace 8f42c1
</span>
</div>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
production
</span>
</div>
<div className="p-4">
<div className="flex items-baseline justify-between">
<p className="text-xs font-medium">POST /v1/orders</p>
<p className="text-[10px] tabular-nums text-muted-foreground">
total 412ms · 5 spans
</p>
</div>
<div className="mt-3 space-y-2">
{SPANS.map((span) => (
<div
key={span.name}
className="grid grid-cols-[92px_1fr_44px] items-center gap-2"
>
<p className="truncate font-mono text-[10px] text-muted-foreground">
{span.name}
</p>
<div className="relative h-3 rounded-sm bg-muted/60">
<div
className={cn(
"absolute inset-y-0 rounded-sm",
span.warn
? "bg-amber-500/80"
: span.root
? "bg-primary/80"
: "bg-primary/35",
)}
style={{
left: `${span.start}%`,
width: `${span.width}%`,
}}
/>
</div>
<p className="text-right font-mono text-[10px] tabular-nums text-muted-foreground">
{span.ms}
</p>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between rounded-lg border border-border px-3 py-2">
<div className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-amber-500" />
<p className="text-[10px] font-medium">
billing.charge above P99 baseline
</p>
</div>
<span className="rounded-md bg-primary px-2 py-0.5 text-[10px] font-medium text-primary-foreground">
Page on-call
</span>
</div>
</div>
</div>
</div>
</div>
</section>
);
}