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

Hero — with media

Centred copy over a full-bleed background, or above a framed screenshot. The overlay variant always paints a scrim.

Also ships a motion variant: Hero — with media (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-with-media.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-with-media.tsx
import { HeroWithMedia } from "@registry/sections/hero-with-media"

import { ACTIONS } from "./content"

export function Demo() {
  return (
    <HeroWithMedia
      eyebrow="Overlay"
      title="Copy over media, with contrast guaranteed"
      description="The scrim is always painted, because text over an arbitrary image has no predictable contrast ratio."
      actions={ACTIONS}
      headingLevel={1}
      spacing="md"
      // Decorative: it carries nothing the copy does not already say.
      media={
        <div
          role="presentation"
          className="size-full bg-[radial-gradient(circle_at_20%_20%,#3b4a6b,transparent_55%),radial-gradient(circle_at_80%_30%,#6b3b5a,transparent_50%),linear-gradient(140deg,#11151f,#2a2140)]"
        />
      }
    />
  )
}

Source

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

components/sections/hero-with-media.tsx
/**
 * HeroWithMedia — centred copy over a full-bleed background, or above a wide
 * framed screenshot.
 *
 * Two shapes, one component, because they differ only in where the media sits:
 * `overlay` puts it behind the copy, `below` puts it under.
 *
 * a11y: `overlay` is the easiest hero to get wrong. Text over an arbitrary image
 * has no predictable contrast ratio, so the overlay variant always paints a
 * scrim between the media and the copy, and the copy switches to a fixed
 * light-on-dark pair rather than inheriting theme colours that might be dark on
 * dark. The scrim is opinionated on purpose — `overlayClassName` can tune it,
 * but it cannot be removed by accident.
 *
 * Background media is decorative by definition here: it carries no information
 * the copy does not, so pass it with `alt=""`.
 *
 * 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 HeroWithMediaProps extends StackSlotProps {
  title: string
  description?: string
  eyebrow?: ReactNode
  actions?: CtaItem[]
  link?: LinkComponent
  note?: ReactNode
  /** The media. Decorative in `overlay` mode, so give it `alt=""`. */
  media: ReactNode
  /** `overlay` puts media behind the copy; `below` puts it underneath. */
  placement?: "overlay" | "below"
  /** Tunes the scrim. Applied over the media, under the copy. */
  overlayClassName?: string
  headingLevel?: HeadingLevel
  size?: "md" | "lg" | "xl"
  as?: "section" | "div"
  spacing?: "sm" | "md" | "lg" | "none"
  className?: string
  id?: string
}

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

export function HeroWithMedia({
  title,
  description,
  eyebrow,
  actions,
  link,
  note,
  media,
  placement = "overlay",
  overlayClassName,
  stackAs,
  blockAs,
  headingLevel = 1,
  size = "lg",
  as = "div",
  spacing = "lg",
  className,
  id,
}: HeroWithMediaProps) {
  const Stack = (stackAs ?? "div") as ElementType
  const Block = (blockAs ?? "div") as ElementType
  const titleId = id ? `${id}-title` : slugId(title)
  const overlay = placement === "overlay"

  const copy = (
    <Stack
      className={cn(
        "flex max-w-3xl flex-col items-center gap-6 text-center",
        // Over media, contrast cannot be inherited from the theme — it has to be
        // guaranteed against the scrim.
        overlay && "text-white",
      )}
    >
      {eyebrow ? (
        <Block>
          <Eyebrow
            tone={overlay ? "foreground" : "primary"}
            className={overlay ? "text-white/85" : undefined}
          >
            {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={cn(
              "max-w-2xl text-pretty text-lg sm:text-xl",
              overlay ? "text-white/90" : "text-muted-foreground",
            )}
          >
            {description}
          </p>
        </Block>
      ) : null}

      {actions?.length ? (
        <Block className="w-full sm:w-auto">
          <CtaGroup items={actions} link={link} size="lg" align="center" stackOnMobile />
        </Block>
      ) : null}

      {note ? (
        <Block>
          <p
            className={cn(
              "text-pretty text-sm",
              overlay ? "text-white/80" : "text-muted-foreground",
            )}
          >
            {note}
          </p>
        </Block>
      ) : null}
    </Stack>
  )

  if (!overlay) {
    return (
      <Section
        as={as}
        spacing={spacing}
        labelledBy={as === "section" ? titleId : undefined}
        id={id}
        className={className}
      >
        <Container className="flex flex-col items-center gap-14">
          {copy}
          <div className="w-full overflow-hidden rounded-2xl border shadow-sm">
            {media}
          </div>
        </Container>
      </Section>
    )
  }

  return (
    <Section
      as={as}
      spacing={spacing}
      labelledBy={as === "section" ? titleId : undefined}
      id={id}
      className={cn("isolate overflow-hidden", className)}
    >
      <div
        aria-hidden
        className="absolute inset-0 -z-20 [&_img]:size-full [&_img]:object-cover [&_video]:size-full [&_video]:object-cover"
      >
        {media}
      </div>
      {/* Always present: text over arbitrary media has no guaranteed ratio. */}
      <div
        aria-hidden
        className={cn("absolute inset-0 -z-10 bg-black/65", overlayClassName)}
      />
      <Container className="flex flex-col items-center py-10">{copy}</Container>
    </Section>
  )
}

Props

HeroWithMediaProps

Extends StackSlotProps.

Props for HeroWithMediaProps
PropTypeDefault
title*Requiredstring
descriptionstring
eyebrowReactNode
actionsCtaItem[]
linkLinkComponent
noteReactNode
media*RequiredThe media. Decorative in `overlay` mode, so give it `alt=""`.ReactNode
placement`overlay` puts media behind the copy; `below` puts it underneath."overlay" | "below""overlay"
overlayClassNameTunes the scrim. Applied over the media, under the copy.string
headingLevelHeadingLevel1
size"md" | "lg" | "xl""lg"
as"section" | "div""div"
spacing"sm" | "md" | "lg" | "none""lg"
classNamestring
idstring