Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
blocksection

Hero — split

Copy on one side, media on the other. Reading order stays copy-first on every screen size.

Also ships a motion variant: Hero — split (motion). The static one is the default.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/hero-split.json

Pulls in container, cta-group, eyebrow, heading, section, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/hero-split.tsx
import { HeroSplit } from "@registry/sections/hero-split"

import { ACTIONS, MediaPlaceholder } from "./content"

export function Demo() {
  return (
    <HeroSplit
      eyebrow="Sections"
      title="Build the page, not the primitives"
      description="Every section takes typed content and slots, so assembling a landing page is composition rather than another round of copy-paste."
      actions={ACTIONS}
      note="Works in any React 19 project."
      media={<MediaPlaceholder label="Product screenshot" />}
      headingLevel={1}
      spacing="md"
    />
  )
}

Source

What the CLI copies into your project, byte for byte.

components/sections/hero-split.tsx
/**
 * HeroSplit — copy on one side, media on the other.
 *
 * `media` is a plain `ReactNode` slot rather than an image prop, because a split
 * hero's media is a one-off: a screenshot, a video, an illustration, a live
 * demo. Passing `<Image … />` straight in is simpler than any indirection this
 * file could offer, and it keeps the section free of framework imports.
 *
 * a11y: on narrow screens the copy comes first in both DOM and visual order, so
 * reading order and tab order agree. `reverse` swaps the columns on large
 * screens only, with `lg:order-*`, which keeps that guarantee intact — reversing
 * the DOM instead would put the media ahead of the headline for screen readers.
 *
 * Dependencies: react, @/lib/types, @/lib/utils,
 * @/components/ui/container, @/components/ui/cta-group, @/components/ui/eyebrow,
 * @/components/ui/heading, @/components/ui/section.
 */

import type { ElementType, ReactNode } from "react"

import type {
  CtaItem,
  HeadingLevel,
  LinkComponent,
  StackSlotProps,
} from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Container } from "@/components/ui/container"
import { CtaGroup } from "@/components/ui/cta-group"
import { Eyebrow } from "@/components/ui/eyebrow"
import { Heading } from "@/components/ui/heading"
import { Section } from "@/components/ui/section"

export interface HeroSplitProps extends StackSlotProps {
  title: string
  description?: string
  eyebrow?: ReactNode
  actions?: CtaItem[]
  link?: LinkComponent
  note?: ReactNode
  banner?: ReactNode
  /** The right-hand column. Pass `<Image />`, a video, or anything else. */
  media?: ReactNode
  /** Extra content under the buttons — a logo strip, a stat row. */
  children?: ReactNode
  /** Puts the media on the left at `lg` and up. Does not change DOM order. */
  reverse?: boolean
  headingLevel?: HeadingLevel
  size?: "md" | "lg"
  as?: "section" | "div"
  spacing?: "sm" | "md" | "lg" | "none"
  className?: string
  id?: string
}

const titleSizes = { md: "text-display-sm", lg: "text-display-md" } as const

export function HeroSplit({
  title,
  description,
  eyebrow,
  actions,
  link,
  note,
  banner,
  media,
  children,
  reverse = false,
  stackAs,
  blockAs,
  headingLevel = 1,
  size = "lg",
  as = "div",
  spacing = "lg",
  className,
  id,
}: HeroSplitProps) {
  const Stack = (stackAs ?? "div") as ElementType
  const Block = (blockAs ?? "div") as ElementType
  const titleId = id ? `${id}-title` : slugId(title)

  return (
    <Section
      as={as}
      spacing={spacing}
      labelledBy={as === "section" ? titleId : undefined}
      id={id}
      className={className}
    >
      <Container className="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
        <Stack className={cn("flex flex-col gap-6", reverse && "lg:order-2")}>
          {banner ? <Block>{banner}</Block> : null}
          {eyebrow ? (
            <Block>
              <Eyebrow tone="primary">{eyebrow}</Eyebrow>
            </Block>
          ) : null}

          <Block>
            <Heading
              level={headingLevel}
              id={titleId}
              className={cn("text-balance font-semibold", titleSizes[size])}
            >
              {title}
            </Heading>
          </Block>

          {description ? (
            <Block>
              <p className="text-muted-foreground text-pretty text-lg">{description}</p>
            </Block>
          ) : null}

          {actions?.length ? (
            <Block>
              <CtaGroup items={actions} link={link} size="lg" stackOnMobile />
            </Block>
          ) : null}

          {note ? (
            <Block>
              <p className="text-muted-foreground text-pretty text-sm">{note}</p>
            </Block>
          ) : null}

          {children ? <Block>{children}</Block> : null}
        </Stack>

        {media ? (
          <div className={cn("min-w-0", reverse && "lg:order-1")}>{media}</div>
        ) : null}
      </Container>
    </Section>
  )
}

Props

HeroSplitProps

Extends StackSlotProps.

Props for HeroSplitProps
PropTypeDefault
title*Requiredstring
descriptionstring
eyebrowReactNode
actionsCtaItem[]
linkLinkComponent
noteReactNode
bannerReactNode
mediaThe right-hand column. Pass `<Image />`, a video, or anything else.ReactNode
childrenExtra content under the buttons — a logo strip, a stat row.ReactNode
reversePuts the media on the left at `lg` and up. Does not change DOM order.booleanfalse
headingLevelHeadingLevel1
size"md" | "lg""lg"
as"section" | "div""div"
spacing"sm" | "md" | "lg" | "none""lg"
classNamestring
idstring