Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Mobile UI in React
  4. React Collapsing Header: iOS Large Title and Photo Hero
Auf Deutsch lesen
  1. Blog
  2. Mobile UI in React
  3. React Collapsing Header: iOS Large Title and Photo Hero
Mobile UI in React
Mobile UI in React

React Collapsing Header: iOS Large Title and Photo Hero

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 8 min read

On this page

0%
  1. Which collapsing header does your screen need?
  2. Why does a useState scroll listener make the header stutter?
  3. How do you drive a header from scroll without re-rendering?
  4. How do you build the iOS large title handover in React?
    1. What happens when the user stops halfway?
    2. What about pulling past the top?
    3. Which mode should each screen use?
  5. How do you build a parallax hero header for mobile?
  6. How do you make it work in a scroll container or a PWA?
  7. Should the header hide on scroll instead?
  8. How do reduced motion and accessibility change it?
  9. What should you do next?
  10. FAQ
    1. How do I shrink a header on scroll in React without re-rendering?
    2. Should I animate the header's height when it collapses?
    3. Can CSS scroll-driven animations replace the JavaScript?
    4. Which element should be the h1 in a large title header?
    5. Are the Wingo UI Large Title Header and Hero Header free?

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.

Published Oct 16, 2026

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 and 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 (opens in a new tab), 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.

PatternUse it onWhat moves while you scrollWingo UI component
iOS large titletop-level lists: inbox, settings, ordersthe 34px title scrolls up with the page, a compact title fades into a sticky barLarge Title Header
Photo herodetail pages: a listing, a product, a restaurantthe photo scrolls slower than the page and darkens, then the bar takes overHero Header

Apple's SwiftUI documentation for the large title mode (opens in a new tab) 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 (opens in a new tab) 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 (opens in a new tab) 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 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 (opens in a new tab), and MDN (opens in a new tab) 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:

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.
$ npx wingo-ui@latest add large-title-header
ProLarge Title Header docs

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.

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.
$ npx wingo-ui@latest add hero-header
ProHero Header docs

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 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 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.

Scroll the feed: the top bar slides away with your finger and returns as soon as you scroll up, while the progress line fills.
$ npx wingo-ui@latest add use-scroll-state
ProuseScrollState docs

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 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 and the Mobile UI in React category.

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: 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

    A React iOS large title header that shrinks into a compact title in a glass bar as you scroll.

    Pro
  • 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.

    Pro
  • useScrollState

    A React scroll position hook: offset, progress and hide-on-scroll state for the window or a container, as motion values without re-renders.

    Pro

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.

  • Mobile UI
  • Header
  • Scroll animation
  • iOS
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Mobile UI in React

Web App That Feels Native on a Phone: 12 React Fixes

12 details behind a web app that feels native on a phone: instant taps, action sheets, swipeable tabs, large titles, haptics and a keyboard-aware composer.

SRSerban Rusu·Oct 13, 2026·11 min read
Mobile UI in React

React Action Sheet: iOS-Style, Async and Responsive

Build a React action sheet like iOS: red destructive rows, a separate Cancel, an awaitable show() that waits for async actions, and a dialog from 768px.

SRSerban Rusu·Oct 16, 2026·9 min read
Mobile UI in React

React Floating Action Button: Speed Dial and Safe Areas

A React floating action button for the thumb zone: a 2 to 6 action speed dial, a label that folds on scroll, and a spot above tab bars and the home indicator.

SRSerban Rusu·Oct 16, 2026·9 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI