# React Collapsing Header: iOS Large Title and Photo Hero

> Build a react collapsing header: the iOS large title handover and a parallax photo hero, driven by scroll motion values instead of a re-render per frame.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Mobile UI in React](https://wingo-ui.com/blog/category/mobile-ui)
- Reading time: 8 min
- Canonical: https://wingo-ui.com/blog/react-collapsing-header

## TL;DR

A React collapsing header that feels right on a phone keeps the scroll position in a motion value and maps it to opacity and transform with useTransform, so the header follows the finger at frame rate without a React render per scroll event. For list screens use the iOS large title: the title scrolls with the page and a compact copy fades into a sticky glass bar exactly as the big one passes under it. For detail pages use a photo hero that scrolls at half speed and hands over to the same bar in its last 32px.

A react collapsing header is the bar at the top of a phone screen that changes as you scroll: the big iOS title that slides up and hands over to a small one, or the full-bleed photo that shrinks into a frosted bar on a listing page. The quick version writes `scrollY` into state from a scroll listener and animates the header's height. On a phone that stutters. This tutorial builds both patterns with scroll motion values, first by hand and then with the Wingo UI [Large Title Header](https://wingo-ui.com/components/large-title-header) and [Hero Header](https://wingo-ui.com/components/hero-header).

## Which collapsing header does your screen need?

There are two patterns, and they belong on different screens: the large title for top-level lists, the photo hero for detail pages. Android developers know the second one as [`CollapsingToolbarLayout`](https://developer.android.com/reference/com/google/android/material/appbar/CollapsingToolbarLayout), which Material documents with a collapsing title, a scrim and parallax children. A collapsing toolbar web version rebuilds the same idea with scroll-linked styles.

| Pattern | Use it on | What moves while you scroll | Wingo UI component |
|---|---|---|---|
| iOS large title | top-level lists: inbox, settings, orders | the 34px title scrolls up with the page, a compact title fades into a sticky bar | [Large Title Header](https://wingo-ui.com/components/large-title-header) |
| Photo hero | detail pages: a listing, a product, a restaurant | the photo scrolls slower than the page and darkens, then the bar takes over | [Hero Header](https://wingo-ui.com/components/hero-header) |

Apple's [SwiftUI documentation for the large title mode](https://developer.apple.com/documentation/swiftui/toolbartitledisplaymode/large) describes the first one: the title starts below the toolbar and moves to its center as the content scrolls. On the web, Ionic ships the same effect as collapsible large titles, and its [title docs](https://ionicframework.com/docs/api/title) note that the feature is iOS only.

## Why does a useState scroll listener make the header stutter?

Because it puts React in the middle of every frame. A typical shrink header on scroll React snippet looks like this:

```tsx
const [y, setY] = useState(0);
useEffect(() => {
  const onScroll = () => setY(window.scrollY);
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);
// then: style={{ height: Math.max(44, 96 - y) }}
```

Two things go wrong. Every scroll event renders the component and everything under it. And `height` is a layout property, so each frame re-lays out the whole page below the header. The [web.dev animations guide](https://web.dev/articles/animations-guide) advises animating `transform` and `opacity` and avoiding properties that trigger layout or paint. The other common recipe, a boolean that flips at 50px, renders less but jumps between two states instead of following the finger.

The fix is to keep the offset outside React state. A motion value from `motion/react` can change 60 or 120 times a second and write straight to `style`, without a render. Booleans like `collapsed` still live in state, and they change only when they flip.

## How do you drive a header from scroll without re-rendering?

Read the offset as a motion value and derive every style from it with `useTransform`. Motion's own `useScroll` returns `scrollY` as a motion value. The Wingo UI [useScrollState](https://wingo-ui.com/components/use-scroll-state) hook does the same for the window or any container, and adds `progress`, a `hideProgress` for bars that slide away, and `scrolled`, `hidden` and `atEnd` flags that re-render only when they change.

Here is a minimal iOS large title React component. It uses motion's `useScroll`, so it needs nothing beyond the `motion` package; swap in `useScrollState().offset` when the page scrolls an inner container:

```tsx
"use client";

import { motion, useScroll, useTransform } from "motion/react";

export function CollapsingTitle({ title }: { title: string }) {
  const { scrollY } = useScroll();
  // the h1 is about 45px tall (a 37px line plus 8px of padding), so its bottom edge
  // passes under the 44px bar at a scroll offset of about 45
  const largeOpacity = useTransform(scrollY, [9, 43], [1, 0]);
  // the bar takes over in the 16px around that moment
  const handover = useTransform(scrollY, [37, 53], [0, 1]);
  const compactY = useTransform(handover, [0, 1], [4, 0]);

  return (
    <>
      <div className="sticky top-0 z-sticky flex h-11 items-center justify-center">
        <motion.div
          aria-hidden
          style={{ opacity: handover }}
          className="absolute inset-0 -z-10 border-b border-foreground/10 bg-background/80 backdrop-blur-xl"
        />
        <motion.span aria-hidden style={{ opacity: handover, y: compactY }} className="text-[17px] font-semibold">
          {title}
        </motion.span>
      </div>
      <motion.h1 style={{ opacity: largeOpacity }} className="px-4 pb-2 text-[34px] leading-[1.1] font-bold">
        {title}
      </motion.h1>
    </>
  );
}
```

Nothing here changes height. The large `h1` is a normal block in the page, so the browser scrolls it for free; the code only fades it. The bar has a fixed 44px height and fades its glass surface in. The compact copy is `aria-hidden` so screen readers hear the title once.

The weak spot is the hardcoded numbers. A title that wraps to two lines, a subtitle, or a larger font setting moves the point where the title passes under the bar, and the handover drifts off it. The component in the next section measures that point with a `ResizeObserver` (the title's bottom edge minus the bar's height) and stores it in a motion value too, so the transforms stay right without rendering. It also starts expanded until the first measurement lands, so the server HTML never paints a half-faded title.

You may also see CSS scroll-driven animations suggested for this. `animation-timeline: scroll()` runs in Chrome 115 and Safari 26 according to [caniuse](https://caniuse.com/mdn-css_properties_animation-timeline_scroll), and [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline) still marks it as limited availability as of October 2026. It handles a fixed-range fade well. It cannot measure the title, snap a half-collapsed header or move a search field under the bar, so we use it as an enhancement at most.

## How do you build the iOS large title handover in React?

Install the component (it is Pro, and it brings `useScrollState`, the button and the input with it):

```bash
npx wingo-ui@latest add large-title-header
```

Then render it at the top of the page, before the content:

```tsx
"use client";

import { useState } from "react";
import { SquarePen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { LargeTitleHeader } from "@/components/ui/large-title-header";

const conversations = [
  { id: "1", name: "Daniel Reed", message: "I sent proposal #Q-2026-118." },
  { id: "2", name: "Sophia Lee", message: "Invoice #2026-0412 was paid." },
];

export function MessagesPage() {
  const [query, setQuery] = useState("");
  const results = conversations.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));

  return (
    <>
      <LargeTitleHeader
        title="Messages"
        subtitle="3 unread"
        backLabel="Settings"
        backHref="/settings"
        search
        searchValue={query}
        onSearchChange={setQuery}
        actions={
          <Button variant="ghost" size="icon" radius="full" aria-label="New message">
            <SquarePen />
          </Button>
        }
      />
      <ul className="min-h-dvh">
        {results.map((c) => (
          <li key={c.id} className="px-4 py-3">
            <p className="font-medium">{c.name}</p>
            <p className="text-sm text-muted-foreground">{c.message}</p>
          </li>
        ))}
      </ul>
    </>
  );
}
```

The root of the header is `display: contents`, so the sticky bar belongs to the page and stays pinned all the way down, instead of unsticking when its own wrapper scrolls away. The handover happens over 16px of scrolling around the measured edge: the compact title rises 4px into the bar while the glass surface and the hairline fade in. Scroll it in the phone frame:

> Live demo (Large Title Header): Scroll the conversations slowly: the large title fades as it slides under the bar, and the compact title rises into the glass at the same moment. Tap Search and the field moves right under the bar with a Cancel button. Try it at [Large Title Header](https://wingo-ui.com/components/large-title-header) and install it with `npx wingo-ui@latest add large-title-header`.

### What happens when the user stops halfway?

With `snap` on (the default), a scroll that rests between expanded and collapsed for 140ms settles to the nearer end, so the title never stays half under the bar. The check waits while a finger is still on the screen, and a page too short to collapse settles back to expanded.

### What about pulling past the top?

On iOS the scroller reports a negative offset during the bounce. `stretch` scales the large title from its corner, up to 8% after 48px of overscroll, the way the native title does. Browsers that do not bounce never report a negative offset, so nothing happens there.

### Which mode should each screen use?

Use `collapse` on top-level lists and `compact` on pushed detail screens. `mode="collapse"` is the handover above. `mode="large"` pins the title at full size and never collapses. `mode="compact"` renders only the bar with the title already in it, which suits a pushed detail screen. `titleSize="md"` drops the large title from 34px to 28px.

## How do you build a parallax hero header for mobile?

Put a full-bleed photo under the status bar, float round buttons on it, and move the photo slower than the page until a bar takes over. That is the parallax hero header mobile apps use on listing and product pages. Install the Hero Header:

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

```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"
        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>
          </>
        }
      />
      <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>
    </>
  );
}
```

The parallax is one transform. With the default `parallax={0.5}`, the photo moves down by half the scroll offset while the page moves up over it, so it appears to scroll at half speed. This is the transform inside `hero-header.tsx`, where `drift` is the clamped `parallax` prop and `mediaHeight` is the measured hero height:

```ts
const imageY = useTransform(() => {
  const at = offset.get();
  const size = mediaHeight.get();
  if (reduced) return 0;
  if (at < 0) return stretch ? at : 0; // overscroll: hold the top edge
  return Math.min(at, size) * drift;
});
```

The function form of `useTransform` subscribes to every motion value it reads, which is why the component reads `mediaHeight` before it branches: a value read only inside a branch that did not run on the first pass would never update the transform.

During an iOS overscroll the photo holds its top edge and scales up to fill the gap instead of showing the page background. The photo also darkens to 45% as it leaves (`fade`), and a top gradient (`scrim`) keeps the status bar and the white icons readable on bright photos. In the last 32px before the photo's bottom edge meets the bar, the glass bar fades in, the compact title rises into it and the round glass buttons turn into tinted bar buttons.

> Live demo (Hero Header): Scroll the listing: the photo moves at half speed and darkens, then the bar takes over and the round buttons lose their glass. On an iPhone, pull past the top and the photo stretches. Try it at [Hero Header](https://wingo-ui.com/components/hero-header) and install it with `npx wingo-ui@latest add hero-header`.

The `title` prop here is the bar copy and is `aria-hidden`, which is why the page renders its own `h1` below the hero. `height` defaults to 320px and is capped at 70dvh, so a tall photo never fills a short phone. Use `onCollapsedChange` when the status bar color should follow the handover.

## How do you make it work in a scroll container or a PWA?

Both headers follow the window by default. When an app shell scrolls an inner element instead, hand that element down once with `ScrollContainerProvider`. Keep it in state through a callback ref, so the children update after it mounts:

```tsx
"use client";

import { useState, type ReactNode } from "react";
import { ScrollContainerProvider } from "@/hooks/use-scroll-state";

export function AppShell({ children }: { children: ReactNode }) {
  const [scroller, setScroller] = useState<HTMLElement | null>(null);

  return (
    <div ref={setScroller} className="h-dvh overflow-y-auto">
      <ScrollContainerProvider value={scroller}>{children}</ScrollContainerProvider>
    </div>
  );
}
```

Every header, tab bar and back-to-top button inside now reads that element. For one-off cases, both headers also take a `scrollTarget` prop.

In a standalone PWA the bar must clear the notch. `safeArea` (on by default) pads `env(safe-area-inset-top)` above the bar, which is 0 in desktop browsers and needs `viewport-fit=cover` in your viewport meta; our [Tailwind safe area guide](https://wingo-ui.com/blog/tailwind-safe-area-insets) covers that setup. If the page sits on `bg-surface` instead of `bg-background`, set `[--bar-bg:var(--surface)]` so the glass matches. A header pairs naturally with a bottom bar, which the [React bottom navigation bar tutorial](https://wingo-ui.com/blog/react-bottom-navigation-bar) builds.

## Should the header hide on scroll instead?

Hide it on feeds with a plain top bar, and collapse it on screens that have a real title. On a feed, the bar slides away while you scroll down and returns on the way up, which gives the content the full height. That is `useScrollState({ hideOnScroll: true })`: `hideProgress` follows the finger from 0 to 1 over `hideDistance` pixels (56 by default) and snaps to shown or hidden when you stop.

> Live demo (useScrollState): Scroll the feed: the top bar slides away with your finger and returns as soon as you scroll up, while the progress line fills. Try it at [useScrollState](https://wingo-ui.com/components/use-scroll-state) and install it with `npx wingo-ui@latest add use-scroll-state`.

## How do reduced motion and accessibility change it?

Under `prefers-reduced-motion`, the large title handover becomes an instant switch and nothing stretches; the hero drops the parallax and the stretch, and its bar takes over in one short fade. Both components read the preference through a hydration-safe hook, a problem our [prefers reduced motion guide](https://wingo-ui.com/blog/prefers-reduced-motion-react) explains. The large title stays the page's only `h1`, the back button and actions are 44px on touch, and when Escape or Cancel ends a search from the keyboard, focus returns to the title.

For the rest of the phone details, from bottom sheets to touch targets, see the [mobile-first React components guide](https://wingo-ui.com/blog/mobile-first-react-components) and the [Mobile UI in React category](https://wingo-ui.com/blog/category/mobile-ui).

## What should you do next?

If you only need a fade, the hand-rolled version above is enough. For the measured handover, the snap, the search field and the photo hero, both components are part of [Wingo UI Pro](https://wingo-ui.com/pricing): run `npx wingo-ui@latest add large-title-header hero-header` and replace the demo copy with your own screens.

## Components in this post

- [Large Title Header](https://wingo-ui.com/components/large-title-header) (Pro): A React iOS large title header that shrinks into a compact title in a glass bar as you scroll. Install: `npx wingo-ui@latest add large-title-header`
- [Hero Header](https://wingo-ui.com/components/hero-header) (Pro): A React photo hero header for phone detail pages: a full-bleed image under the status bar, glass buttons, parallax and a bar handover. Install: `npx wingo-ui@latest add hero-header`
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) (Pro): A React scroll position hook: offset, progress and hide-on-scroll state for the window or a container, as motion values without re-renders. Install: `npx wingo-ui@latest add use-scroll-state`

## FAQ

### How do I shrink a header on scroll in React without re-rendering?

Keep the scroll offset in a motion value (motion's useScroll, or useScrollState from Wingo UI) and derive opacity and transform from it with useTransform. Motion values update the DOM directly, so React renders only when a boolean like collapsed actually flips.

### Should I animate the header's height when it collapses?

No. Height is a layout property, so every frame re-lays out everything below the header. Let the large title scroll away with the content and fade a compact title into a fixed-height sticky bar, which only animates opacity and transform.

### Can CSS scroll-driven animations replace the JavaScript?

For a simple fade, yes, in Chrome 115 and later and Safari 26 and later. MDN still lists animation-timeline as limited availability as of October 2026, and CSS alone cannot measure where the title ends, snap a half-collapsed header or react to a search field, so keep a fallback.

### Which element should be the h1 in a large title header?

The large title. The compact copy in the bar is the same text, so mark it aria-hidden or the page announces its title twice. On a photo hero, the page keeps its own h1 below the photo and the bar title stays aria-hidden.

### Are the Wingo UI Large Title Header and Hero Header free?

No. Both are Pro components, and so is the useScrollState hook they share. With a Pro account signed in through npx wingo-ui@latest login, npx wingo-ui@latest add large-title-header copies the component and everything it imports into your project.

---

Source: https://wingo-ui.com/blog/react-collapsing-header
