docs / patterns / hero-split

HeroSplit

the landing hero: copy on one side, a product visual on the other. every part but the title is optional, so it collapses to a headline and a button.

intensity · structuresrsc · server

provenance

shaped from sisu-plus

layer

core / patterns

intensity

structures · organizes a region, stays out of the content

composition

the first section of a landing page, onceactions take Buttons, proof takes an AvatarGroup, badge takes an Announcementnever nested inside a Card or another herothe background slot is not a place for content. it sits under a scrim

a11y

the title is a real heading, level set by headingLevel · the scrim is aria-hidden and the background layer drops pointer eventsjest-axe
live demo · try it out

Your whole degree, in one place.

Four registries reconciled into one planner. Nothing to configure.

+1
2,400 active users
product shot
customize
titlethe headline
titleAccentsecond phrase, carries the accent color
accentColorkeyed to the product, defaults to accent-alt
bodythe lede
notesmall print under the copy
badgepill above the title
actionsthe call to action row
proofan avatar group as social proof
visualthe product shot beside the copy
usagecopy
import { HeroSplit } from "usva/patterns/hero-split";
import { MockupShowcase } from "usva/patterns/mockup-showcase";
import { Avatar, AvatarGroup } from "usva/primitives/avatar";
import { Badge } from "usva/primitives/badge";
import { Button } from "usva/primitives/button";

<HeroSplit
  title="Your whole degree,"
  titleAccent="in one place."
  body="Four registries reconciled into one planner. Nothing to configure."
  actions={
    <>
      <Button>Add to Chrome</Button>
      <Button variant="onSurface">Source code</Button>
    </>
  }
  proof={
    <AvatarGroup max={3} tone="accent" label="2,400 active users">
      <Avatar alt="Mateusz Pasek" />
      <Avatar alt="Anna Korhonen" />
      <Avatar alt="Jussi Laine" />
    </AvatarGroup>
  }
  visual={<MockupShowcase>{/* screenshot */}</MockupShowcase>}
/>

props

proptypedefaultnotes
titleReactNodethe headline.
titleAccentReactNodesecond phrase of the title, carrying the accent color.
accentColorstringaccent-alt tokenany CSS color, keyed to the product.
headingLevel"h1" | "h2""h1"one h1 per page.
badgeReactNodepill above the title. an Announcement or a Badge.
bodyReactNodethe lede.
actionsReactNodethe call to action row. pass Buttons.
proofReactNodesocial proof under the actions. an AvatarGroup with a label does this.
noteReactNodesmall print under the copy.
visualReactNodethe product shot. beside the copy on wide containers, stacked below on narrow.
backgroundReactNodepainted behind everything, under a scrim built from the bg token. a slot, not a built-in atmosphere. no background, no scrim.

get it

npx shadcn add https://usva.build/r/hero-split.jsoncopy
source · components/ui/hero-split.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";

export type HeroSplitHeadingLevel = "h1" | "h2";

export interface HeroSplitProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  title: React.ReactNode;
  /** Second phrase of the title, carrying the accent color. */
  titleAccent?: React.ReactNode;
  /** Categorical: any CSS color, keyed to the product. Falls back to accent-alt. */
  accentColor?: string;
  headingLevel?: HeroSplitHeadingLevel;
  /** Pill above the title. Pass an Announcement or a Badge. */
  badge?: React.ReactNode;
  body?: React.ReactNode;
  /** The call to action row. Pass Buttons. */
  actions?: React.ReactNode;
  /** Social proof under the actions. Pass an AvatarGroup and a line of copy. */
  proof?: React.ReactNode;
  /** Small print under the copy column. */
  note?: React.ReactNode;
  /** The product shot, to the side on wide screens and stacked below on narrow. */
  visual?: React.ReactNode;
  /**
   * Painted behind everything, under a scrim. sisu puts a WebGL Plasma here; that one
   * is provenance-locked, so this is a slot rather than a built-in atmosphere.
   */
  background?: React.ReactNode;
}

export const HeroSplit = React.forwardRef<HTMLElement, HeroSplitProps>(
  (
    {
      className,
      title,
      titleAccent,
      accentColor,
      headingLevel: Heading = "h1",
      badge,
      body,
      actions,
      proof,
      note,
      visual,
      background,
      ...props
    },
    ref,
  ) => (
    <section
      ref={ref}
      className={cn(
        "@container relative isolate flex flex-col gap-12 overflow-hidden px-6 py-20 sm:px-10",
        // Container queries, not viewport ones. A hero dropped into a narrow column
        // would otherwise still lay itself out side by side and size its title off
        // the window, overrunning whatever sits next to it.
        "@5xl:flex-row @5xl:items-center @5xl:justify-between",
        className,
      )}
      {...props}
    >
      {background != null && (
        <div className="pointer-events-none absolute inset-0 -z-10">
          {background}
          <div
            data-hero-scrim=""
            aria-hidden="true"
            style={{
              backgroundImage:
                "linear-gradient(to bottom, color-mix(in oklab, var(--color-bg) 10%, transparent), color-mix(in oklab, var(--color-bg) 76%, transparent) 76%, var(--color-bg))",
            }}
            className="absolute inset-0"
          />
        </div>
      )}

      <div className="z-10 flex w-full flex-col gap-4 text-center @5xl:max-w-[47.5rem] @5xl:text-left">
        <div>
          {badge != null && <div className="mb-6 inline-flex">{badge}</div>}
          <Heading className="text-[clamp(2.5rem,6.5cqi,6.5rem)] font-bold leading-[0.96] text-balance text-ink">
            {title}
            {titleAccent != null && (
              <>
                {" "}
                <span
                  style={{ color: accentColor ?? "var(--color-accent-alt)" }}
                >
                  {titleAccent}
                </span>
              </>
            )}
          </Heading>
        </div>

        {body != null && (
          <p className="mx-auto max-w-[38.75rem] text-[1.08rem] leading-8 text-balance text-muted @5xl:mx-0">
            {body}
          </p>
        )}

        {(actions != null || proof != null) && (
          <div>
            {actions != null && (
              <div className="mt-3 flex flex-wrap justify-center gap-3 @5xl:justify-start">
                {actions}
              </div>
            )}
            {proof != null && (
              <div className="mx-auto mt-4 flex w-fit items-center gap-4 text-sm font-semibold text-muted @5xl:mx-0">
                {proof}
              </div>
            )}
          </div>
        )}

        {note != null && (
          <p className="mx-auto mt-4 max-w-[31rem] text-[0.7rem] leading-6 text-pretty text-muted @5xl:mx-0">
            {note}
          </p>
        )}
      </div>

      {visual != null && (
        <div className="relative z-10 w-full @5xl:w-auto @5xl:shrink-0">
          {visual}
        </div>
      )}
    </section>
  ),
);
HeroSplit.displayName = "HeroSplit";