# React Collapsing Header: iOS Large Title und Foto-Hero

> So baust du einen React Collapsing Header: die Übergabe beim iOS Large Title und einen Parallax-Foto-Hero, gesteuert über Motion Values statt Re-Renders.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 16. Okt. 2026
- Kategorie: [Mobile UI mit React](https://wingo-ui.com/de/blog/category/mobile-ui)
- Lesezeit: 8 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-collapsing-header
- Englische Fassung: https://wingo-ui.com/blog/react-collapsing-header

## Kurz gesagt

Ein React Collapsing Header, der sich auf dem Smartphone richtig anfühlt, hält die Scrollposition in einem Motion Value und rechnet sie mit useTransform in Opacity und Transform um, damit der Header dem Finger mit voller Bildrate folgt, ohne dass React bei jedem Scroll-Event rendert. Für Listen nimmst du den iOS Large Title: Der Titel scrollt mit der Seite, und eine kompakte Kopie blendet genau dann in eine Sticky-Leiste aus Glas ein, wenn der große Titel unter ihr durchläuft. Für Detailseiten nimmst du einen Foto-Hero, der mit halber Geschwindigkeit scrollt und auf seinen letzten 32px an dieselbe Leiste übergibt.

Ein React Collapsing Header ist die Leiste oben auf dem Smartphone-Screen, die sich beim Scrollen verändert: der große iOS-Titel, der nach oben gleitet und an einen kleinen übergibt, oder das randlose Foto, das auf einer Inseratsseite zu einer Leiste aus Milchglas schrumpft. Die schnelle Lösung schreibt `scrollY` aus einem Scroll-Listener in den State und animiert die Höhe des Headers. Auf dem Smartphone ruckelt das. Dieses Tutorial baut beide Muster mit Motion Values für den Scroll, zuerst von Hand und dann mit dem [Large Title Header](https://wingo-ui.com/components/large-title-header) und dem [Hero Header](https://wingo-ui.com/components/hero-header) von Wingo UI.

## Welchen Collapsing Header braucht dein Screen?

Es gibt zwei Muster, und sie gehören auf unterschiedliche Screens: der Large Title auf Listen der obersten Ebene, der Foto-Hero auf Detailseiten. Android-Entwickler kennen das zweite als [`CollapsingToolbarLayout`](https://developer.android.com/reference/com/google/android/material/appbar/CollapsingToolbarLayout), das Material mit einklappendem Titel, Scrim und Parallax-Kindelementen dokumentiert. Eine Collapsing Toolbar im Web baut dieselbe Idee mit Styles nach, die an den Scroll gekoppelt sind.

| Muster | Einsatz | Was sich beim Scrollen bewegt | Komponente von Wingo UI |
|---|---|---|---|
| iOS Large Title | Listen der obersten Ebene: Posteingang, Einstellungen, Bestellungen | der 34px große Titel scrollt mit der Seite nach oben, ein kompakter Titel blendet in eine Sticky-Leiste ein | [Large Title Header](https://wingo-ui.com/components/large-title-header) |
| Foto-Hero | Detailseiten: ein Inserat, ein Produkt, ein Restaurant | das Foto scrollt langsamer als die Seite und wird dunkler, dann übernimmt die Leiste | [Hero Header](https://wingo-ui.com/components/hero-header) |

Apples [SwiftUI-Doku zum Large-Title-Modus](https://developer.apple.com/documentation/swiftui/toolbartitledisplaymode/large) beschreibt das erste Muster: Der Titel beginnt unter der Toolbar und wandert in ihre Mitte, während der Inhalt scrollt. Im Web liefert Ionic denselben Effekt als einklappbare große Titel, und die [Doku zum Titel](https://ionicframework.com/docs/api/title) weist darauf hin, dass die Funktion nur unter iOS verfügbar ist.

## Warum ruckelt der Header mit einem Scroll-Listener und useState?

Weil React dann an jedem einzelnen Frame beteiligt ist. Ein typisches React-Snippet für „Header beim Scrollen verkleinern“ sieht so aus:

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

Zwei Dinge gehen schief. Jedes Scroll-Event rendert die Komponente und alles darunter. Und `height` ist eine Layout-Eigenschaft, also berechnet der Browser in jedem Frame das Layout der ganzen Seite unter dem Header neu. Der [Animations-Guide von web.dev](https://web.dev/articles/animations-guide) rät, `transform` und `opacity` zu animieren und Eigenschaften zu meiden, die Layout oder Paint auslösen. Das andere verbreitete Rezept, ein Boolean, das bei 50px umspringt, rendert seltener. Dafür springt der Header zwischen zwei Zuständen, statt dem Finger zu folgen.

Die Lösung: Halte den Offset außerhalb des React-States. Ein Motion Value aus `motion/react` kann sich 60- oder 120-mal pro Sekunde ändern und direkt in `style` schreiben, ohne Render. Booleans wie `collapsed` bleiben im State, und sie ändern sich nur, wenn sie wirklich umspringen.

## Wie steuerst du einen Header über den Scroll, ohne neu zu rendern?

Lies den Offset als Motion Value und leite jeden Style mit `useTransform` davon ab. Das `useScroll` von motion liefert `scrollY` als Motion Value. Der Hook [useScrollState](https://wingo-ui.com/components/use-scroll-state) von Wingo UI macht dasselbe für das Fenster oder einen beliebigen Container. Dazu kommen `progress`, ein `hideProgress` für Leisten, die wegfahren, und die Flags `scrolled`, `hidden` und `atEnd`, die nur dann neu rendern, wenn sie sich ändern.

Hier ist eine minimale iOS Large Title React-Komponente. Sie nutzt `useScroll` von motion und braucht deshalb nichts außer dem Paket `motion`. Wenn die Seite in einem inneren Container scrollt, setzt du stattdessen `useScrollState().offset` ein:

```tsx
"use client";

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

export function CollapsingTitle({ title }: { title: string }) {
  const { scrollY } = useScroll();
  // das h1 ist etwa 45px hoch (eine 37px-Zeile plus 8px Padding), also läuft seine
  // Unterkante bei einem Scroll-Offset von etwa 45 unter der 44px hohen Leiste durch
  const largeOpacity = useTransform(scrollY, [9, 43], [1, 0]);
  // die Leiste übernimmt in den 16px rund um diesen 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>
    </>
  );
}
```

Hier ändert sich keine Höhe. Das große `h1` ist ein normaler Block in der Seite, der Browser scrollt es also ohne Zusatzaufwand mit. Der Code blendet es nur aus. Die Leiste hat eine feste Höhe von 44px und blendet ihre Glasfläche ein. Die kompakte Kopie ist `aria-hidden`, damit Screenreader den Titel nur einmal vorlesen.

Die Schwachstelle sind die fest eingetragenen Zahlen. Ein Titel, der auf zwei Zeilen umbricht, ein Untertitel oder eine größere Schrifteinstellung verschieben den Punkt, an dem der Titel unter der Leiste durchläuft, und die Übergabe verrutscht. Die Komponente im nächsten Abschnitt misst diesen Punkt mit einem `ResizeObserver` (die Unterkante des Titels minus die Höhe der Leiste) und legt ihn ebenfalls in einem Motion Value ab. So bleiben die Transforms korrekt, ohne dass etwas rendert. Außerdem startet sie ausgeklappt, bis die erste Messung da ist. Das Server-HTML zeigt also nie einen halb ausgeblendeten Titel.

Vielleicht liest du auch den Vorschlag, dafür CSS Scroll-driven Animations zu nehmen. `animation-timeline: scroll()` läuft laut [caniuse](https://caniuse.com/mdn-css_properties_animation-timeline_scroll) in Chrome 115 und Safari 26, und [MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline) führt es im Oktober 2026 noch als eingeschränkt verfügbar. Ein Ausblenden über einen festen Bereich klappt damit gut. Den Titel messen, einen halb eingeklappten Header einrasten lassen oder ein Suchfeld unter die Leiste schieben kann es nicht. Wir nutzen es deshalb höchstens als Verbesserung obendrauf.

## Wie baust du die Übergabe beim iOS Large Title in React?

Installier die Komponente (sie ist Pro und bringt `useScrollState` sowie die Komponenten Button und Input mit):

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

Dann renderst du sie oben auf der Seite, vor dem Inhalt:

```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: "Ich habe das Angebot #Q-2026-118 geschickt." },
  { id: "2", name: "Sophia Lee", message: "Rechnung #2026-0412 wurde bezahlt." },
];

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

  return (
    <>
      <LargeTitleHeader
        title="Nachrichten"
        subtitle="3 ungelesen"
        backLabel="Einstellungen"
        backHref="/settings"
        search
        searchValue={query}
        onSearchChange={setQuery}
        actions={
          <Button variant="ghost" size="icon" radius="full" aria-label="Neue Nachricht">
            <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>
    </>
  );
}
```

Das Root-Element des Headers hat `display: contents`. Die Sticky-Leiste gehört damit zur Seite und bleibt bis ganz unten angeheftet, statt sich zu lösen, sobald ihr eigener Wrapper wegscrollt. Die Übergabe passiert über 16px Scrollweg rund um die gemessene Kante: Der kompakte Titel steigt 4px in die Leiste, während die Glasfläche und die feine Trennlinie einblenden. Scroll ihn im Smartphone-Rahmen durch:

> Live-Demo (Large Title Header): Scroll langsam durch die Unterhaltungen: Der große Titel blendet aus, während er unter die Leiste gleitet, und im selben Moment steigt der kompakte Titel ins Glas. Tipp auf „Search“, und das Feld rückt direkt unter die Leiste, mit einem Button „Cancel“ daneben. Probier sie unter [Large Title Header](https://wingo-ui.com/components/large-title-header) aus und installier sie mit `npx wingo-ui@latest add large-title-header`.

### Was passiert, wenn jemand auf halbem Weg aufhört?

Mit `snap` (standardmäßig an) rastet ein Scroll, der 140ms lang zwischen ausgeklappt und eingeklappt stehen bleibt, am näheren Ende ein. Der Titel bleibt also nie halb unter der Leiste stehen. Die Prüfung wartet, solange noch ein Finger auf dem Screen liegt, und eine Seite, die zu kurz zum Einklappen ist, rastet wieder ausgeklappt ein.

### Und wenn du über den oberen Rand hinausziehst?

Unter iOS meldet der Scroller während des Bounce einen negativen Offset. `stretch` skaliert den großen Titel dann von seiner Ecke aus, um bis zu 8% nach 48px Overscroll, so wie es der native Titel macht. Browser ohne Bounce melden nie einen negativen Offset, dort passiert also nichts.

### Welchen Modus nimmt welcher Screen?

Nimm `collapse` für Listen der obersten Ebene und `compact` für Detailscreens, die per Navigation geöffnet werden. `mode="collapse"` ist die Übergabe von oben. `mode="large"` heftet den Titel in voller Größe an und klappt nie ein. `mode="compact"` rendert nur die Leiste, mit dem Titel schon darin, und passt zu genau so einem Detailscreen. `titleSize="md"` verkleinert den großen Titel von 34px auf 28px.

## Wie baust du einen Parallax-Hero-Header fürs Smartphone?

Leg ein randloses Foto unter die Statusleiste, setz runde Buttons darauf und bewege das Foto langsamer als die Seite, bis eine Leiste übernimmt. Diesen Parallax Hero Header nutzen mobile Apps auf Inserats- und Produktseiten. Installier den 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="Wohnung mit 2 Schlafzimmern"
        backLabel="Inserate"
        backHref="/listings"
        actions={
          <>
            <Button variant="ghost" size="icon" radius="full" aria-label="Teilen">
              <Share />
            </Button>
            <Button
              variant="ghost"
              size="icon"
              radius="full"
              aria-label="Zu Favoriten hinzufügen"
              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">Wohnung mit 2 Schlafzimmern, geteilter Grundriss</h1>
        <p className="text-sm text-muted-foreground">Brooklyn, New York · $489,000</p>
      </main>
    </>
  );
}
```

Die Parallaxe ist ein einziges Transform. Mit dem Standardwert `parallax={0.5}` bewegt sich das Foto um den halben Scroll-Offset nach unten, während die Seite darüber nach oben fährt. Es scheint also mit halber Geschwindigkeit zu scrollen. So sieht das Transform in `hero-header.tsx` aus, wobei `drift` der begrenzte Prop `parallax` ist und `mediaHeight` die gemessene Höhe des Heros:

```ts
const imageY = useTransform(() => {
  const at = offset.get();
  const size = mediaHeight.get();
  if (reduced) return 0;
  if (at < 0) return stretch ? at : 0; // Overscroll: die Oberkante festhalten
  return Math.min(at, size) * drift;
});
```

Die Funktionsform von `useTransform` abonniert jeden Motion Value, den sie liest. Deshalb liest die Komponente `mediaHeight`, bevor sie verzweigt: Ein Wert, der nur in einem Zweig gelesen wird, der beim ersten Durchlauf nicht lief, würde das Transform nie aktualisieren.

Beim Overscroll unter iOS hält das Foto seine Oberkante fest und skaliert hoch, um die Lücke zu füllen, statt den Seitenhintergrund zu zeigen. Außerdem wird das Foto beim Verlassen um bis zu 45% abgedunkelt (`fade`), und ein Verlauf oben (`scrim`) hält Statusleiste und weiße Icons auch auf hellen Fotos lesbar. Auf den letzten 32px, bevor die Unterkante des Fotos die Leiste erreicht, blendet die Glasleiste ein, der kompakte Titel steigt hinein, und die runden Glas-Buttons werden zu getönten Buttons in der Leiste.

> Live-Demo (Hero Header): Scroll durch das Inserat: Das Foto bewegt sich mit halber Geschwindigkeit und wird dunkler, dann übernimmt die Leiste, und die runden Buttons verlieren ihr Glas. Zieh auf einem iPhone über den oberen Rand, und das Foto streckt sich. Probier sie unter [Hero Header](https://wingo-ui.com/components/hero-header) aus und installier sie mit `npx wingo-ui@latest add hero-header`.

Der Prop `title` ist hier der Text für die Leiste und ist `aria-hidden`. Darum rendert die Seite ihr eigenes `h1` unter dem Hero. `height` ist standardmäßig 320px und auf 70dvh begrenzt, ein hohes Foto füllt also nie ein kleines Smartphone aus. Nimm `onCollapsedChange`, wenn die Farbe der Statusleiste der Übergabe folgen soll.

## Wie funktioniert das in einem Scroll-Container oder einer PWA?

Beide Header folgen standardmäßig dem Fenster. Wenn eine App-Shell stattdessen ein inneres Element scrollt, gib dieses Element einmal mit `ScrollContainerProvider` nach unten weiter. Halte es über eine Callback-Ref im State, damit die Kindkomponenten nach dem Mounten aktualisiert werden:

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

Jeder Header, jede Tab-Leiste und jeder Back-to-top-Button darin liest jetzt dieses Element. Für Einzelfälle nehmen beide Header auch einen Prop `scrollTarget`.

In einer eigenständigen PWA darf die Notch die Leiste nicht verdecken. `safeArea` (standardmäßig an) setzt `env(safe-area-inset-top)` als Abstand über die Leiste. Im Desktop-Browser ist dieser Wert 0, und er setzt `viewport-fit=cover` in deinem Viewport-Meta-Tag voraus. Wie du das einrichtest, steht in unserem [Guide zu Safe Area Insets mit Tailwind](https://wingo-ui.com/de/blog/tailwind-safe-area-insets). Wenn die Seite auf `bg-surface` statt auf `bg-background` liegt, setz `[--bar-bg:var(--surface)]`, damit das Glas passt. Ein solcher Header passt gut zu einer Navigationsleiste unten, wie sie das [Tutorial zur React Bottom Navigation Bar](https://wingo-ui.com/de/blog/react-bottom-navigation-bar) baut.

## Soll der Header beim Scrollen lieber verschwinden?

Auf Feeds mit einer schlichten Leiste oben lässt du ihn verschwinden, auf Screens mit einem echten Titel klappst du ihn ein. In einem Feed fährt die Leiste weg, während du nach unten scrollst, und kommt auf dem Weg nach oben zurück. Der Inhalt bekommt so die volle Höhe. Das ist `useScrollState({ hideOnScroll: true })`: `hideProgress` folgt dem Finger über `hideDistance` Pixel (standardmäßig 56) von 0 bis 1 und rastet beim Anhalten auf sichtbar oder versteckt ein.

> Live-Demo (useScrollState): Scroll durch den Feed: Die obere Leiste fährt mit deinem Finger weg und kommt zurück, sobald du nach oben scrollst, während sich die Fortschrittslinie füllt. Probier sie unter [useScrollState](https://wingo-ui.com/components/use-scroll-state) aus und installier sie mit `npx wingo-ui@latest add use-scroll-state`.

## Was ändern reduzierte Bewegung und Barrierefreiheit?

Bei `prefers-reduced-motion` wird die Übergabe beim Large Title zu einem sofortigen Wechsel, und nichts streckt sich. Der Hero verzichtet auf Parallaxe und Stretch, und seine Leiste übernimmt mit einer einzigen kurzen Überblendung. Beide Komponenten lesen die Einstellung über einen Hook, der die Hydration nicht stört. Das Problem dahinter erklärt unser [Guide zu prefers-reduced-motion in React](https://wingo-ui.com/de/blog/prefers-reduced-motion-react). Der große Titel bleibt das einzige `h1` der Seite, der Zurück-Button und die Aktionen sind auf Touchscreens 44px groß, und wenn Escape oder „Cancel“ eine Suche über die Tastatur beendet, springt der Fokus zurück auf den Titel.

Für die übrigen Details auf dem Smartphone, von Bottom Sheets bis zu Touch-Zielen, sieh dir den [Guide zu Mobile-first React Components](https://wingo-ui.com/de/blog/mobile-first-react-components) und die Kategorie [Mobile UI mit React](https://wingo-ui.com/de/blog/category/mobile-ui) an.

## Wie geht es weiter?

Wenn du nur ein Ausblenden brauchst, reicht die selbst gebaute Version von oben. Für die gemessene Übergabe, das Einrasten, das Suchfeld und den Foto-Hero sind beide Komponenten Teil von [Wingo UI Pro](https://wingo-ui.com/de/pricing): Führ `npx wingo-ui@latest add large-title-header hero-header` aus und ersetz die Demo-Texte durch deine eigenen Screens.

## Komponenten in diesem Artikel

- [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. Installation: `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. Installation: `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. Installation: `npx wingo-ui@latest add use-scroll-state`

## Häufige Fragen

### Wie verkleinere ich in React einen Header beim Scrollen ohne Re-Render?

Halte den Scroll-Offset in einem Motion Value (useScroll aus motion oder useScrollState aus Wingo UI) und leite Opacity und Transform mit useTransform davon ab. Motion Values schreiben direkt ins DOM, also rendert React nur, wenn ein Boolean wie collapsed tatsächlich umspringt.

### Soll ich die Höhe des Headers beim Einklappen animieren?

Nein. Die Höhe ist eine Layout-Eigenschaft, also berechnet der Browser in jedem Frame das Layout von allem unter dem Header neu. Lass den großen Titel mit dem Inhalt wegscrollen und blende einen kompakten Titel in eine Sticky-Leiste mit fester Höhe ein, die nur Opacity und Transform animiert.

### Können CSS Scroll-driven Animations das JavaScript ersetzen?

Für ein einfaches Ausblenden ja, in Chrome ab Version 115 und Safari ab Version 26. MDN führt animation-timeline im Oktober 2026 aber noch als eingeschränkt verfügbar, und CSS allein kann weder messen, wo der Titel endet, noch einen halb eingeklappten Header einrasten lassen oder auf ein Suchfeld reagieren. Behalte also einen Fallback.

### Welches Element ist bei einem Large Title Header das h1?

Der große Titel. Die kompakte Kopie in der Leiste ist derselbe Text, also markier sie mit aria-hidden, sonst liest der Screenreader den Titel zweimal vor. Beim Foto-Hero behält die Seite ihr eigenes h1 unter dem Foto, und der Titel in der Leiste bleibt aria-hidden.

### Sind Large Title Header und Hero Header von Wingo UI kostenlos?

Nein. Beide sind Pro-Komponenten, genau wie der Hook useScrollState, den sie gemeinsam nutzen. Mit einem Pro-Konto, angemeldet über npx wingo-ui@latest login, kopiert npx wingo-ui@latest add large-title-header die Komponente und alles, was sie importiert, in dein Projekt.

---

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