Sections

Hero Split

Split hero with a staggered check-draw value checklist and a perspective-tilted product frame that settles flat on hover.

Install

npx shadcn@latest add @paragon/hero-split

Also installs: button

hero-split.tsx

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