Skip to content
Uxelith
Sign in
Freev1.0.0Marketing blocks

Hero Section

A landing-page hero: a large headline that comes into focus, a magnetic call to action, customer marks and a live product shot with a self-drawing chart, a visiting cursor and floating cards.

pnpm dlx shadcn add …/r/hero-section.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/hero-section.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as hero-section.tsx. It's yours: change anything.

Usage

example.tsx
import { HeroSection } from "@/components/hero-section"

export default function Page() {
  return <HeroSection title="Ship interfaces that feel considered." primaryAction={{ label: "Get started", href: "/signup" }} />
}

Source

One file, 380 lines, nothing hidden. Read it before you install it.

hero-section.tsx
"use client";

import * as React from "react";

import { BlurText } from "@/components/blur-text";
import { MagneticButton } from "@/components/magnetic-button";
import { cn } from "@/lib/utils";

export interface HeroSectionProps extends Omit<React.ComponentProps<"section">, "title"> {
  /** Small pill above the headline, e.g. a launch note. */
  announcement?: { label: string; href: string };
  title?: string;
  subtitle?: React.ReactNode;
  primaryAction?: { label: string; href: string };
  secondaryAction?: { label: string; href: string };
  /**
   * Customers shown under the actions. A name alone gets a simple mark beside
   * it; pass `{ name, logo }` to use the real logo (the name stays as its label).
   */
  trustedBy?: (string | { name: string; logo?: React.ReactNode })[];
  /** The product shot. Defaults to a built-in app window illustration. */
  visual?: React.ReactNode;
  /** Level of the main heading: 1 when this is the page's top section, 2 when it sits under another title. */
  headingLevel?: 1 | 2;
}

function Arrow() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4 transition-transform duration-300 group-hover:translate-x-0.5">
      <path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

// Simple stand-in marks, so names without a logo still read as a logo row.
const marks = [
  "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z",
  "M12 3l9 16H3z",
  "M12 2l9.5 10L12 22 2.5 12z",
  "M4 4h7v7H4zM13 13h7v7h-7zM13 4h7v7h-7z",
  "M12 2l8.7 5v10L12 22l-8.7-5V7z",
  "M3 12a9 9 0 0 1 18 0h-4.5a4.5 4.5 0 0 0-9 0z",
];

function Mark({ index }: { index: number }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true" className="size-5 shrink-0" fill="currentColor" fillRule="evenodd">
      <path d={marks[index % marks.length]} />
    </svg>
  );
}

// Thirty days of revenue for the built-in visual: the same on the server and in the browser.
const revenue = [42, 45, 44, 48, 47, 52, 50, 49, 55, 58, 56, 61, 59, 63, 62, 60, 66, 69, 67, 72, 70, 75, 78, 76, 81, 79, 84, 88, 86, 92];
const CHART_W = 600;
const CHART_H = 160;
const point = (v: number, i: number) => [(i / (revenue.length - 1)) * CHART_W, CHART_H - ((v - 35) / 65) * (CHART_H - 12)] as const;
const linePath = revenue.map((v, i) => `${i ? "L" : "M"}${point(v, i).join(",")}`).join("");
const areaPath = `${linePath}L${CHART_W},${CHART_H}L0,${CHART_H}Z`;
// Where the demo cursor rests, in turn.
const stops = [9, 17, 23, 29, 13];
const day = (i: number) => (11 + i <= 30 ? `Sep ${11 + i}` : `Oct ${11 + i - 30}`);

const sidebar = [
  { label: "Overview", d: "M4 5h7v6H4zM13 5h7v3h-7zM13 10h7v9h-7zM4 13h7v6H4z" },
  { label: "Revenue", d: "M4 19V9M10 19V5M16 19v-7M22 19H2" },
  { label: "Customers", d: "M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM3 20c.6-3.4 3-5.5 6-5.5s5.4 2.1 6 5.5" },
  { label: "Invoices", d: "M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6" },
  { label: "Settings", d: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21" },
];

const channels = [
  { label: "Direct", share: 0.46 },
  { label: "Search", share: 0.31 },
  { label: "Referral", share: 0.15 },
  { label: "Social", share: 0.08 },
];

function Glyph({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="size-3.5 shrink-0">
      <path d={d} />
    </svg>
  );
}

/**
 * A theme-aware product shot drawn with markup, so the hero looks finished
 * before you add a screenshot: a light runs around the window's edge, the
 * revenue line draws itself, a cursor visits a few days and the cards on
 * either side lean with the pointer.
 */
function AppWindow() {
  const ref = React.useRef<HTMLDivElement>(null);
  const fillId = React.useId();
  const [stop, setStop] = React.useState(0);

  // The cursor moves on only while the visual is on screen and motion is welcome.
  React.useEffect(() => {
    const node = ref.current;
    const view = node?.ownerDocument.defaultView;
    if (!node || !view || view.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let timer: number | undefined;
    const observer = new view.IntersectionObserver(([entry]) => {
      view.clearInterval(timer);
      if (entry?.isIntersecting) timer = view.setInterval(() => setStop((s) => (s + 1) % stops.length), 1600);
    });
    observer.observe(node);
    return () => {
      observer.disconnect();
      view.clearInterval(timer);
    };
  }, []);

  // Pointer lean, written to CSS variables so moving the mouse never re-renders.
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const node = ref.current;
    if (!node || event.pointerType !== "mouse" || node.ownerDocument.defaultView?.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const box = node.getBoundingClientRect();
    node.style.setProperty("--px", (((event.clientX - box.left) / box.width) * 2 - 1).toFixed(3));
    node.style.setProperty("--py", (((event.clientY - box.top) / box.height) * 2 - 1).toFixed(3));
  };
  const onPointerLeave = () => {
    ref.current?.style.setProperty("--px", "0");
    ref.current?.style.setProperty("--py", "0");
  };

  const index = stops[stop]!;
  const [x, y] = point(revenue[index]!, index);
  const left = `${(x / CHART_W) * 100}%`;
  const top = `${(y / CHART_H) * 100}%`;
  const glide = "transition-[left,top] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";
  const lean = "transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none";

  return (
    <div ref={ref} aria-hidden="true" onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} className="relative [--px:0] [--py:0]">
      <div className={cn("relative", lean)} style={{ transform: "rotateX(calc(var(--py) * -3deg)) rotateY(calc(var(--px) * 4deg))" }}>
        {/* The light running around the edge: a spinning gradient behind a 1px inset. */}
        <div className="relative overflow-hidden rounded-2xl p-px shadow-[0_40px_120px_-40px_color-mix(in_oklab,var(--primary)_55%,transparent)]">
          <div className="absolute inset-0 rounded-2xl bg-border" />
          <div className="absolute left-1/2 top-1/2 aspect-square w-[160%] -translate-x-1/2 -translate-y-1/2 animate-[ux-hero-beam_7s_linear_infinite] bg-[conic-gradient(from_0deg,transparent_0deg,transparent_290deg,var(--primary)_340deg,transparent_360deg)] motion-reduce:hidden" />
          <div className="relative overflow-hidden rounded-[calc(1rem-1px)] bg-card text-left">
            <div className="flex h-10 items-center gap-1.5 border-b border-border px-4">
              <span className="size-2.5 rounded-full bg-muted-foreground/30" />
              <span className="size-2.5 rounded-full bg-muted-foreground/30" />
              <span className="size-2.5 rounded-full bg-muted-foreground/30" />
              <span className="mx-auto flex h-6 items-center gap-1.5 rounded-md bg-muted px-3 font-mono text-[0.68rem] text-muted-foreground">
                <svg viewBox="0 0 16 16" fill="none" className="size-3">
                  <path d="M4.5 7V5.5a3.5 3.5 0 0 1 7 0V7M3.5 7h9v6.5h-9z" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
                </svg>
                app.northwind.dev/revenue
              </span>
            </div>
            <div className="grid sm:grid-cols-[10.5rem_1fr]">
              <div className="hidden border-r border-border p-3 sm:block">
                {sidebar.map((item, i) => (
                  <div key={item.label} className={cn("flex h-8 items-center gap-2 rounded-md px-2.5 text-xs", i === 1 ? "bg-accent font-medium text-foreground" : "text-muted-foreground")}>
                    <Glyph d={item.d} />
                    {item.label}
                  </div>
                ))}
              </div>
              <div className="min-w-0 space-y-3 p-4 sm:p-5">
                <div className="grid grid-cols-3 gap-2.5 sm:gap-3">
                  {[
                    { label: "Revenue", value: "$48.2K", delta: "+12.4%" },
                    { label: "Active users", value: "3,942", delta: "+8.1%" },
                    { label: "Conversion", value: "4.7%", delta: "+0.6 pts" },
                  ].map((s) => (
                    <div key={s.label} className="rounded-lg border border-border bg-background/60 p-2.5 sm:p-3">
                      <div className="truncate text-[0.68rem] text-muted-foreground">{s.label}</div>
                      <div className="mt-1 text-sm font-semibold tabular-nums sm:text-lg">{s.value}</div>
                      <div className="text-[0.68rem] font-medium text-primary">{s.delta}</div>
                    </div>
                  ))}
                </div>
                <div className="grid gap-3 md:grid-cols-[1fr_11rem]">
                  <div className="rounded-lg border border-border bg-background/60 p-3">
                    <div className="flex items-baseline justify-between text-[0.68rem] text-muted-foreground">
                      <span className="font-medium text-foreground">Revenue</span>
                      <span>Last 30 days</span>
                    </div>
                    <div className="relative mt-3 h-32 sm:h-40">
                      <svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
                        <defs>
                          <linearGradient id={fillId} x1="0" y1="0" x2="0" y2="1">
                            <stop offset="0%" stopColor="var(--primary)" stopOpacity="0.35" />
                            <stop offset="100%" stopColor="var(--primary)" stopOpacity="0" />
                          </linearGradient>
                        </defs>
                        {[0.25, 0.5, 0.75].map((f) => (
                          <line key={f} x1="0" x2={CHART_W} y1={CHART_H * f} y2={CHART_H * f} stroke="var(--border)" strokeDasharray="3 5" vectorEffect="non-scaling-stroke" />
                        ))}
                        <path d={areaPath} fill={`url(#${fillId})`} className="animate-[ux-hero-fade_900ms_cubic-bezier(0.22,1,0.36,1)_1500ms_both] motion-reduce:animate-none" />
                        <path
                          d={linePath}
                          pathLength={1}
                          fill="none"
                          stroke="var(--primary)"
                          strokeWidth="2.25"
                          strokeLinejoin="round"
                          vectorEffect="non-scaling-stroke"
                          className="animate-[ux-hero-draw_1400ms_cubic-bezier(0.65,0,0.35,1)_900ms_both] [stroke-dasharray:1] motion-reduce:animate-none"
                        />
                      </svg>
                      {/* Crosshair, dot, tooltip and cursor glide to each stop. */}
                      <span className={cn("absolute inset-y-0 w-px bg-foreground/15", glide)} style={{ left }} />
                      <span
                        className={cn("absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-primary shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_25%,transparent)]", glide)}
                        style={{ left, top }}
                      />
                      <span
                        className={cn("absolute z-10 -translate-x-1/2 -translate-y-[calc(100%+14px)] whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-[0.68rem] text-popover-foreground shadow-md", glide)}
                        style={{ left, top }}
                      >
                        <span className="text-muted-foreground">{day(index)}</span> <b className="font-semibold tabular-nums">${Math.round(revenue[index]! * 21.4).toLocaleString("en-US")}</b>
                      </span>
                      <svg viewBox="0 0 16 16" className={cn("absolute size-4 translate-x-1 translate-y-1.5 drop-shadow", glide)} style={{ left, top }}>
                        <path d="M2 1.5l11 5.2-4.6 1.4L6.6 13z" fill="var(--foreground)" stroke="var(--background)" strokeWidth="1" strokeLinejoin="round" />
                      </svg>
                    </div>
                  </div>
                  <div className="hidden rounded-lg border border-border bg-background/60 p-3 md:block">
                    <div className="text-[0.68rem] font-medium">Top channels</div>
                    <div className="mt-3 space-y-3">
                      {channels.map((c, i) => (
                        <div key={c.label}>
                          <div className="flex justify-between text-[0.68rem] text-muted-foreground">
                            <span>{c.label}</span>
                            <span className="tabular-nums">{Math.round(c.share * 100)}%</span>
                          </div>
                          <div className="mt-1 h-1.5 rounded-full bg-muted">
                            <div
                              className="h-full origin-left animate-[ux-hero-grow_800ms_cubic-bezier(0.22,1,0.36,1)_both] rounded-full bg-primary motion-reduce:animate-none"
                              style={{ width: `${(c.share / channels[0]!.share) * 100}%`, opacity: 1 - i * 0.18, animationDelay: `${1200 + i * 90}ms` }}
                            />
                          </div>
                        </div>
                      ))}
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      {/* Cards floating off the window, leaning further with the pointer for depth. */}
      <div className={cn("absolute -left-10 bottom-14 hidden lg:block", lean)} style={{ transform: "translate(calc(var(--px) * -14px), calc(var(--py) * -10px))" }}>
        <div className="flex animate-[ux-hero-pop_500ms_cubic-bezier(0.34,1.56,0.64,1)_1900ms_both] items-center gap-3 rounded-xl border border-border bg-popover/95 py-2.5 pl-3 pr-4 text-popover-foreground shadow-[0_20px_50px_-20px_rgb(0_0_0/0.5)] backdrop-blur motion-reduce:animate-none">
          <svg viewBox="0 0 48 20" fill="none" className="h-5 w-12 text-primary">
            <path d="M1 17l8-5 7 3 9-8 7 4 15-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          <span className="text-left">
            <span className="block text-sm font-semibold tabular-nums">4.7%</span>
            <span className="block text-xs text-muted-foreground">Conversion · up 0.6</span>
          </span>
        </div>
      </div>
      <div className={cn("absolute -bottom-7 right-6 hidden sm:block lg:-right-8", lean)} style={{ transform: "translate(calc(var(--px) * 18px), calc(var(--py) * 12px))" }}>
        <div className="flex animate-[ux-hero-pop_500ms_cubic-bezier(0.34,1.56,0.64,1)_1600ms_both] items-center gap-3 rounded-xl border border-border bg-popover/95 py-2.5 pl-3 pr-4 text-popover-foreground shadow-[0_20px_50px_-20px_rgb(0_0_0/0.5)] backdrop-blur motion-reduce:animate-none">
          <span className="grid size-8 place-items-center rounded-full bg-primary/15 text-primary">
            <svg viewBox="0 0 16 16" fill="none" className="size-4">
              <path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </span>
          <span className="text-left">
            <span className="block text-sm font-semibold tabular-nums">+$1,240</span>
            <span className="block text-xs text-muted-foreground">New order · just now</span>
          </span>
        </div>
      </div>
    </div>
  );
}

/**
 * A landing-page hero: announcement pill with a passing sheen, a large
 * headline that comes into focus, a magnetic call to action, a row of
 * customer marks and a product visual that tilts back as it enters and lies
 * flat as you scroll to it.
 *
 * - The headline is the page's `<h1>` (or `<h2>` with `headingLevel={2}`).
 * - Entrances play once; the tilt follows the scroll position with no script
 *   (browsers without scroll-driven animations show the visual flat).
 * - Under `prefers-reduced-motion` everything is still and in place.
 */
export function HeroSection({
  announcement = { label: "New — the Studio is here", href: "#" },
  title = "Ship interfaces that feel considered.",
  subtitle = "Accessible, animated React components you can shape visually, then own as plain source.",
  primaryAction = { label: "Get started", href: "#" },
  secondaryAction = { label: "Book a demo", href: "#" },
  trustedBy = ["Northwind", "Lumen", "Halcyon", "Parallax", "Quanta"],
  visual,
  headingLevel = 1,
  className,
  ...props
}: HeroSectionProps) {
  return (
    <section className={cn("relative isolate overflow-hidden px-6 pb-20 pt-20 sm:pt-28", className)} {...props}>
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[38rem] bg-[radial-gradient(60%_50%_at_50%_0%,color-mix(in_oklab,var(--primary)_22%,transparent),transparent_70%)]"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-10 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px)] bg-[size:56px_56px] [mask-image:radial-gradient(70%_60%_at_50%_0%,black,transparent)]"
      />

      <div className="mx-auto flex max-w-3xl flex-col items-center text-center">
        {announcement && (
          <a
            href={announcement.href}
            className="group relative inline-flex h-8 animate-[ux-hero-rise_700ms_cubic-bezier(0.22,1,0.36,1)_both] items-center gap-2 overflow-hidden rounded-full border border-border bg-background/70 pl-3 pr-2.5 text-xs font-medium text-muted-foreground backdrop-blur transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring motion-reduce:animate-none"
          >
            <span
              aria-hidden="true"
              className="pointer-events-none absolute inset-y-0 left-0 w-1/2 animate-[ux-hero-sheen_4s_cubic-bezier(0.65,0,0.35,1)_1s_infinite] bg-[linear-gradient(100deg,transparent,color-mix(in_oklab,var(--primary)_22%,transparent),transparent)] motion-reduce:hidden"
            />
            <span className="size-1.5 rounded-full bg-primary shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]" />
            {announcement.label}
            <Arrow />
          </a>
        )}

        <BlurText
          as={headingLevel === 1 ? "h1" : "h2"}
          text={title}
          className="mt-7 text-balance text-5xl font-semibold leading-[1.02] tracking-[-0.045em] sm:text-7xl"
        />

        <p className="mt-6 max-w-xl animate-[ux-hero-rise_700ms_cubic-bezier(0.22,1,0.36,1)_300ms_both] text-pretty text-base leading-relaxed text-muted-foreground motion-reduce:animate-none sm:text-lg">
          {subtitle}
        </p>

        <div className="mt-9 flex animate-[ux-hero-rise_700ms_cubic-bezier(0.22,1,0.36,1)_420ms_both] flex-wrap items-center justify-center gap-3 motion-reduce:animate-none">
          {primaryAction && (
            <MagneticButton href={primaryAction.href} size="lg" className="group">
              {primaryAction.label}
              <Arrow />
            </MagneticButton>
          )}
          {secondaryAction && (
            <a
              href={secondaryAction.href}
              className="inline-flex h-14 items-center rounded-full border border-border bg-background/60 px-8 text-base font-medium backdrop-blur transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
            >
              {secondaryAction.label}
            </a>
          )}
        </div>

        {trustedBy.length > 0 && (
          <div className="mt-12 animate-[ux-hero-rise_700ms_cubic-bezier(0.22,1,0.36,1)_540ms_both] motion-reduce:animate-none">
            <p className="font-mono text-xs uppercase tracking-[0.16em] text-muted-foreground">Trusted by teams at</p>
            <ul className="mt-5 flex flex-wrap items-center justify-center gap-x-9 gap-y-4">
              {trustedBy.map((entry, i) => {
                const { name, logo } = typeof entry === "string" ? { name: entry, logo: undefined } : entry;
                return (
                  <li key={name} className="flex items-center gap-2 text-muted-foreground/80 transition-colors duration-200 hover:text-foreground">
                    {logo ?? <Mark index={i} />}
                    <span className={cn("text-base font-semibold tracking-tight", logo != null && "sr-only")}>{name}</span>
                  </li>
                );
              })}
            </ul>
          </div>
        )}
      </div>

      <div className="mx-auto mt-16 max-w-5xl animate-[ux-hero-rise_900ms_cubic-bezier(0.22,1,0.36,1)_600ms_both] [perspective:1600px] motion-reduce:animate-none">
        <div className="origin-top [animation-fill-mode:both] [animation-name:ux-hero-tilt] [animation-range:entry_0%_cover_50%] [animation-timeline:view()] [animation-timing-function:linear] motion-reduce:[animation-name:none]">
          {visual ?? <AppWindow />}
        </div>
      </div>
    </section>
  );
}

Props

PropTypeDefaultDescription
titlestring"Ship interfaces…"The page's <h1>.
subtitleReactNode—Supporting line.
announcement{ label; href }—Pill above the headline; pass undefined to hide.
primaryAction / secondaryAction{ label; href }—Calls to action.
trustedBy(string | { name; logo? })[]five namesCustomers under the actions; a name alone gets a simple mark, or pass your logo.
visualReactNodeapp windowYour product shot.
headingLevel1 | 211 when this is the page's top section; 2 when it sits under another title.

Accessibility

  • The headline is a real <h1>; screen readers hear it once, as plain text.
  • Calls to action are real links.
  • Entrance animations play once and are skipped under prefers-reduced-motion.
  • The visual's scroll tilt needs no script; browsers without scroll-driven animations show it flat, and everything is still under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Marketing blocks

All 8 in Marketing blocks
  • Marketing blocksPro

    Bento Feature Grid

    A features section as a bento grid: five tiles with live CSS illustrations (motion curves, a theme cycling, frameworks, a typing terminal, a focus ring stepping with Tab), a shared spotlight and scroll-driven entrances.

  • Marketing blocksPro

    FAQ Section

    Frequently asked questions: a large heading, an animated accordion, an optional search that narrows and highlights answers, a contact card and FAQPage structured data.

  • Marketing blocksPro

    Pricing Section

    A monthly/yearly switch with the saving shown, three plans whose prices roll to their new value (the monthly price struck through on yearly), a full comparison table and a no-script FAQ.