# Hero Header

> A React photo hero header for phone detail pages: a full-bleed image under the status bar, glass buttons, parallax and a bar handover.

- Type: React component (Mobile)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add hero-header`
- Docs: https://wingo-ui.com/components/hero-header

## Interaction

The photo fills the top of the page right up to the edge, under the status bar, with round frosted buttons on top (back, share, favorite) that stay readable on any photo. Scroll and the photo moves slower than the page and slowly darkens; in the last 32px before its bottom edge reaches the bar, the glass or solid bar fades in, the compact title rises into it and the round buttons lose their glass and turn into tinted bar buttons, all following your finger. Pull past the top on an iPhone and the photo stretches from its top edge instead of leaving a gap. With onMediaClick a tap on the photo opens it (a lightbox); on custom media like a carousel a tap on the slide opens it while swipes and its own buttons keep working. With a mouse everything is a click; with the keyboard Tab goes back, actions, then the photo button, each with a visible ring (inside the photo, with a white edge so it reads on dark and light photos). On touch the round buttons are 44px. Under reduced motion there is no parallax and no stretch, and the bar takes over in one short fade.

## Installation

```bash
npx wingo-ui@latest add hero-header
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/hero-header` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/hero-header.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))

## Usage

```tsx
"use client"

import { useState } from "react"
import { Heart, Share } from "lucide-react"
import { Button } from "@/components/ui/button"
import { HeroHeader } from "@/components/ui/hero-header"

export function ListingPage() {
  const [saved, setSaved] = useState(false)

  return (
    <>
      <HeroHeader
        image="/demo/apartment.jpg"
        title="2-bedroom apartment"
        backLabel="Listings"
        backHref="/listings"
        onMediaClick={() => console.log("open gallery")}
        labels={{ openMedia: "Open photos", imageError: "Photo unavailable" }}
        actions={
          <>
            <Button variant="ghost" size="icon" radius="full" aria-label="Share">
              <Share />
            </Button>
            <Button
              variant="ghost"
              size="icon"
              radius="full"
              aria-label="Save to favorites"
              aria-pressed={saved}
              onClick={() => setSaved((v) => !v)}
            >
              <Heart className={saved ? "fill-current" : undefined} />
            </Button>
          </>
        }
      >
        <span className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-md">1 / 12</span>
      </HeroHeader>
      <main className="px-4 py-5">
        <h1 className="text-2xl font-bold">2-bedroom apartment, split floor plan</h1>
        <p className="text-sm text-muted-foreground">Brooklyn, New York · $489,000</p>
      </main>
    </>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `image?` | `string` | `""` | The photo's src. Loaded with fetchPriority="high" and object-cover; a softly pulsing bg-foreground/10 block of the same height holds its place while it loads, so nothing shifts. A broken src shows an icon and labels.imageError. |
| `imageAlt?` | `string` | `""` | The photo's alt text. Leave empty when the photo is decorative (the page's <h1> says what it is). |
| `media?` | `ReactNode` |  | Replaces the image: a Carousel, a video, a map. It gets the same parallax, stretch, scrim and shade. Size it to the hero with h-(--hero-header-height). |
| `title?` | `ReactNode` | `""` | The compact title that rises into the bar after the handover. It is aria-hidden: the page keeps its own <h1> below the hero. Without a title the bar shows only the buttons. |
| `backLabel?` | `string` | `""` | The back button's text, usually the previous page ("Listings"). The button renders when backHref or onBack is set; without a label it is a round chevron named by backAriaLabel. |
| `backAriaLabel?` | `string` | `"Back"` | The back button's accessible name when it shows only the chevron. |
| `backHref?` | `string` | `""` | Makes the back button a link through NavLink (the app's router link). |
| `onBack?` | `() => void` |  | Runs when the back button is pressed (with backHref, before the navigation). |
| `actions?` | `ReactNode` |  | Trailing round buttons: plain <Button variant="ghost" size="icon" radius="full" aria-label="…" />s. The header restyles its direct button and link children per buttonStyle while they sit on the photo, and turns them into tinted bar buttons after the handover. |
| `height?` | `number` | `320` | The hero's height in px, capped at 70dvh so a tall value never hides the whole page on a short phone. |
| `buttonStyle?` | `"glass" \| "solid" \| "plain"` | `"glass"` | The round buttons over the photo: frosted dark glass with white icons, a solid popover disc with a shadow, or bare white icons (use with the scrim). All become plain bar buttons after the handover. |
| `background?` | `"blur" \| "solid"` | `"blur"` | The bar after the handover: frosted glass or the solid page color. Set [--bar-bg:var(--surface)] on the header when the page is not bg-background. |
| `border?` | `"scrolled" \| "always" \| "never"` | `"scrolled"` | The hairline under the bar: shown once the bar has taken over, always, or never. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The tint of the back button and the actions once they sit in the bar. Over the photo they stay white (glass, plain) or neutral (solid). |
| `color?` | `string` | `""` | Any CSS color for the bar buttons after the handover. Replaces the tone. |
| `scrim?` | `boolean` | `true` | A top gradient (black 45% to clear) that stays under the bar while the photo scrolls, so the status bar and the buttons read on bright photos. Turn it off for dark photos. |
| `fade?` | `boolean` | `true` | The photo darkens (up to 45%) as it scrolls away, which makes the handover to the bar feel deliberate. |
| `parallax?` | `number` | `0.5` | How much slower the photo moves than the page: 0 scrolls with it, 0.5 at half speed, 1 stays put while the page slides over it. Off under reduced motion. |
| `stretch?` | `boolean` | `true` | Pulling past the top (iOS overscroll, where the scroller reports negative offsets) scales the photo from its top edge instead of showing a gap. Nothing happens where browsers do not bounce. Off under reduced motion. |
| `safeArea?` | `boolean` | `true` | The bar pads env(safe-area-inset-top) and the photo runs under the status bar in a standalone PWA. It is 0 in desktop browsers. A frame that fakes a status bar can set --hero-header-inset on the header instead. |
| `onMediaClick?` | `() => void` |  | Makes the photo a button named labels.openMedia (open a lightbox). With custom media, a click on the media surface opens it (its own controls and drags do not), and a focusable button appears for the keyboard. |
| `onCollapsedChange?` | `(collapsed: boolean) => void` |  | Called when the bar has fully taken over and when the photo comes back: switch the status bar theme color, log a view. |
| `children?` | `ReactNode` |  | Pinned to the bottom of the photo: a "1 / 12" chip, badges. It fades out just before it slides under the bar. Chip look: rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-md. |
| `scrollTarget?` | `HTMLElement \| Window \| RefObject<HTMLElement \| null> \| null` |  | What scrolls. Default: the nearest ScrollContainerProvider, else the window. Put the hero at the top of it. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | The router link for backHref on this header only. Usually set once for the app with LinkProvider. |
| `labels?` | `Partial<HeroHeaderLabels>` |  | The strings: openMedia ("Open photos"), imageError ("Photo unavailable"). Defaults in DEFAULT_HERO_HEADER_LABELS. |
| `classNames?` | `Partial<Record<"bar" \| "surface" \| "hairline" \| "leading" \| "back" \| "title" \| "actions" \| "media" \| "image" \| "scrim" \| "shade" \| "overlay", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root. The root is display: contents so the bar sticks to the whole page (not only while the hero is in view); it carries data-collapsed for group-data-[collapsed]/hero: styling and passes CSS variables down (--bar-bg, --hero-header-inset). |

## Parts

### DEFAULT_HERO_HEADER_LABELS (constant)

The English strings: openMedia ("Open photos") and imageError ("Photo unavailable"). Spread it and override what you translate.

## Examples

- **Restaurant, solid**: Bistro 21 with buttonStyle="solid" and background="solid".
- **Parallax 0 and 0.5**: The same hero scrolling with the page and at half speed.
- **Carousel as media**: Five photos in a Carousel; swipes page, a tap opens the gallery.
- **Scrim on and off**: Plain white icons on a dark photo without the scrim and on a bright one with it.
- **CRM client, brand tone**: A client profile in Northwind CRM whose bar buttons turn brand orange.
- **Broken photo**: The error state: same height, an icon and a label.

## See also

- [Large Title Header](https://wingo-ui.com/components/large-title-header) ([Markdown](https://wingo-ui.com/components/large-title-header.md))
- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Image Lightbox](https://wingo-ui.com/components/image-lightbox) ([Markdown](https://wingo-ui.com/components/image-lightbox.md))
- [Sticky CTA Bar](https://wingo-ui.com/components/sticky-cta-bar) ([Markdown](https://wingo-ui.com/components/sticky-cta-bar.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))

---

Canonical: https://wingo-ui.com/components/hero-header
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
