Hero Header React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React photo hero header for phone detail pages: a full-bleed image under the status bar, glass buttons, parallax and a bar handover.
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.
Hero Header is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add hero-header, together with UIProvider, Button, Navigation, useScrollState, Motion presets and 1 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add hero-headerHero Header is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import HeroHeader from @/components/ui/hero-header.
"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
imagedefault""stringThe 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.
imageAltdefault""stringThe photo's alt text. Leave empty when the photo is decorative (the page's <h1> says what it is).
mediaReactNodeReplaces 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).
titledefault""ReactNodeThe 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.
backLabeldefault""stringThe 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.
backAriaLabeldefault"Back"stringThe back button's accessible name when it shows only the chevron.
backHrefdefault""stringMakes the back button a link through NavLink (the app's router link).
onBack() => voidRuns when the back button is pressed (with backHref, before the navigation).
actionsReactNodeTrailing 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.
heightdefault320numberThe hero's height in px, capped at 70dvh so a tall value never hides the whole page on a short phone.
buttonStyledefault"glass""glass" | "solid" | "plain"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.
backgrounddefault"blur""blur" | "solid"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.
borderdefault"scrolled""scrolled" | "always" | "never"The hairline under the bar: shown once the bar has taken over, always, or never.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"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).
colordefault""stringAny CSS color for the bar buttons after the handover. Replaces the tone.
scrimdefaulttruebooleanA 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.
fadedefaulttruebooleanThe photo darkens (up to 45%) as it scrolls away, which makes the handover to the bar feel deliberate.
parallaxdefault0.5numberHow 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.
stretchdefaulttruebooleanPulling 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.
safeAreadefaulttruebooleanThe 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() => voidMakes 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) => voidCalled when the bar has fully taken over and when the photo comes back: switch the status bar theme color, log a view.
childrenReactNodePinned 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.
scrollTargetHTMLElement | Window | RefObject<HTMLElement | null> | nullWhat scrolls. Default: the nearest ScrollContainerProvider, else the window. Put the hero at the top of it.
linkComponent(props: LinkComponentProps) => ReactNodeThe router link for backHref on this header only. Usually set once for the app with LinkProvider.
labelsPartial<HeroHeaderLabels>The strings: openMedia ("Open photos"), imageError ("Photo unavailable"). Defaults in DEFAULT_HERO_HEADER_LABELS.
classNamesPartial<Record<"bar" | "surface" | "hairline" | "leading" | "back" | "title" | "actions" | "media" | "image" | "scrim" | "shade" | "overlay", string>>Extra classes for the inner parts.
classNamestringExtra 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
constantThe 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.
Files
- components/ui/hero-header.tsx
FAQ
Is the React Hero Header component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Hero Header?
Run npx wingo-ui@latest add hero-header. It also adds the Wingo UI items it builds on (UIProvider, Button, Navigation, useScrollState, Motion presets and 1 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/hero-header.
See also
More React mobile components
Articles using this component
- React Collapsing Header: iOS Large Title and Photo Hero
Mobile UI in React8 min read
