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-pluslayer
core / patternsintensity
structures · organizes a region, stays out of the contentcomposition
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 scrima11y
the title is a real heading, level set byheadingLevel · the scrim is aria-hidden and the background layer drops pointer eventsjest-axelive demo · try it out
Your whole degree, in one place.
Four registries reconciled into one planner. Nothing to configure.
+1
2,400 active usersproduct 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
usage
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
| prop | type | default | notes |
|---|---|---|---|
| title | ReactNode | — | the headline. |
| titleAccent | ReactNode | — | second phrase of the title, carrying the accent color. |
| accentColor | string | accent-alt token | any CSS color, keyed to the product. |
| headingLevel | "h1" | "h2" | "h1" | one h1 per page. |
| badge | ReactNode | — | pill above the title. an Announcement or a Badge. |
| body | ReactNode | — | the lede. |
| actions | ReactNode | — | the call to action row. pass Buttons. |
| proof | ReactNode | — | social proof under the actions. an AvatarGroup with a label does this. |
| note | ReactNode | — | small print under the copy. |
| visual | ReactNode | — | the product shot. beside the copy on wide containers, stacked below on narrow. |
| background | ReactNode | — | painted 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.jsonsource · components/ui/hero-split.tsxexactly what this command copies
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";