ScrollHeader
A screen title that hands over to a compact bar as the page scrolls.
Use it for the top of a screen whose content scrolls: the title sits large and unhurried at rest, and hands over to a compact pinned bar once the reader is into the page.
It needs a height to fill and exactly one scrollable child. Give the root flex-1, or a fixed height, and put the ScrollView or FlatList inside it — the child is cloned with the scroll handler and the content inset composed onto it, so nothing about the list has to change.
The distance the header collapses over is the measured height of ScrollHeader.Large. Whatever goes in that block — a title, a description, a search field, a row of chips — sets the distance, so there is no height prop that has to be kept in agreement with the contents.
For a profile's cover, face and actions, which do not collapse, use PageHeader. For an indicator of how far down a long page you are, use SectionProgress.
Installation
ScrollHeader ships with the library — no separate install.
import { ScrollHeader, Button, Item, SearchBar, SearchIcon, ChevronLeftIcon, EllipsisIcon, Badge } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add scroll-headerUsage
<ScrollHeader className="flex-1">
<ScrollHeader.Bar>
<ScrollHeader.Title>Library</ScrollHeader.Title>
<ScrollHeader.Actions>
<Button variant="ghost" size="icon">
<SearchIcon size={18} />
</Button>
</ScrollHeader.Actions>
</ScrollHeader.Bar>
<ScrollHeader.Large>
<ScrollHeader.Title>Library</ScrollHeader.Title>
<ScrollHeader.Description>128 components</ScrollHeader.Description>
</ScrollHeader.Large>
<ScrollView>
{components.map((component) => (
<Item key={component.id}>
<Item.Content>
<Item.Title>{component.name}</Item.Title>
<Item.Description>{component.summary}</Item.Description>
</Item.Content>
</Item>
))}
</ScrollView>
</ScrollHeader>Composition
<ScrollHeader>
<ScrollHeader.Bar> {/* pinned; its surface fades in */}
<ScrollHeader.Title>…</ScrollHeader.Title>
<ScrollHeader.Actions>…</ScrollHeader.Actions>
</ScrollHeader.Bar>
<ScrollHeader.Cover /> {/* optional; fills the band and stretches with it */}
<ScrollHeader.Large> {/* scrolls away; its height is the distance */}
<ScrollHeader.Title>…</ScrollHeader.Title>
<ScrollHeader.Description>…</ScrollHeader.Description>
</ScrollHeader.Large>
<ScrollView>…</ScrollView> {/* exactly one scrollable */}
</ScrollHeader>ScrollHeader.Bar— The pinned bar. Its contents never move — only its surface fades in, which is what makes the change read as one crossfade rather than two things happening at once.surfacepicks what it is drawn on anddividerputs a hairline under it.ScrollHeader.Large— The block that scrolls away. Its measured height is the distance the header collapses over, so it can hold anything and the transition follows. Once the bar has taken over it is faded out, hidden from screen readers, and stops taking touches meant for the content behind it.ScrollHeader.Title— The screen's title. Write it in both halves: inLargeit is large and at rest, inBarit is compact and fades in. The part reads which half it is in and styles itself accordingly, so the two are one element written twice rather than two components to keep in step.ScrollHeader.Description— The quiet line under the title — a count, a byline, a date.ScrollHeader.Actions— The controls at the trailing end of the bar. They stay put and stay reachable throughout: only the bar's surface and title are part of the transition.ScrollHeader.Cover— A picture or a gradient filling the band. It has no height of its own, so an over-scroll stretches it by laying it out taller rather than by scaling it up — a photograph stretches without going soft.scrimwashes it down so a title stays legible over a bright picture.
Examples
A large title over a list
The plain arrangement: a title and a count at rest, and a pinned bar carrying the same title once the list is moving.
<ScrollHeader className="flex-1">
<ScrollHeader.Bar>
<ScrollHeader.Title>Library</ScrollHeader.Title>
<ScrollHeader.Actions>
<Button variant="ghost" size="icon">
<SearchIcon size={18} />
</Button>
</ScrollHeader.Actions>
</ScrollHeader.Bar>
<ScrollHeader.Large>
<ScrollHeader.Title>Library</ScrollHeader.Title>
<ScrollHeader.Description>128 components</ScrollHeader.Description>
</ScrollHeader.Large>
<ScrollView contentContainerStyle={{ padding: 16, gap: 8 }}>
{components.map((component) => (
<Item key={component.id} variant="outline">
<Item.Content>
<Item.Title>{component.name}</Item.Title>
<Item.Description>{component.summary}</Item.Description>
</Item.Content>
</Item>
))}
</ScrollView>
</ScrollHeader>A cover behind the title
Cover fills the band, so pulling the list down stretches the picture instead of opening a gap above it. surface="none" keeps the bar clear over the picture until the title has gone.
<ScrollHeader className="flex-1">
<ScrollHeader.Cover source={{ uri: cover }} />
<ScrollHeader.Bar surface="none" divider={false}>
<Button variant="ghost" size="icon">
<ChevronLeftIcon size={20} />
</Button>
<ScrollHeader.Title>Sierra Nevada</ScrollHeader.Title>
</ScrollHeader.Bar>
<ScrollHeader.Large className="pb-5">
<ScrollHeader.Title className="text-white">Sierra Nevada</ScrollHeader.Title>
<ScrollHeader.Description className="text-white/80">
14 photographs
</ScrollHeader.Description>
</ScrollHeader.Large>
<ScrollView contentContainerStyle={{ padding: 16, gap: 8 }}>{rows}</ScrollView>
</ScrollHeader>Handing over early
A tall block whose last few points are not worth waiting for. threshold={0.6} finishes the crossfade six tenths of the way down; the block still travels its whole height.
<ScrollHeader className="flex-1" threshold={0.6}>
<ScrollHeader.Bar>
<ScrollHeader.Title>Inbox</ScrollHeader.Title>
</ScrollHeader.Bar>
<ScrollHeader.Large className="pb-4">
<ScrollHeader.Title>Inbox</ScrollHeader.Title>
<ScrollHeader.Description>3 unread</ScrollHeader.Description>
<SearchBar placeholder="Search mail" className="mt-2" />
</ScrollHeader.Large>
<FlatList data={messages} renderItem={renderMessage} keyExtractor={(m) => m.id} />
</ScrollHeader>Reacting to the crossing
onCollapsedChange fires once each way, on the crossing rather than every frame — and not on mount, which is not a crossing — enough to swap a status bar style or reveal a control that only belongs on the bar.
const [collapsed, setCollapsed] = useState(false);
<ScrollHeader className="flex-1" onCollapsedChange={setCollapsed}>
<ScrollHeader.Bar>
<ScrollHeader.Title>Portfolio</ScrollHeader.Title>
<ScrollHeader.Actions>
{collapsed ? <Badge variant="success">+2.4%</Badge> : null}
</ScrollHeader.Actions>
</ScrollHeader.Bar>
<ScrollHeader.Large>
<ScrollHeader.Title>Portfolio</ScrollHeader.Title>
<ScrollHeader.Description>$48,210.55 · +2.4% today</ScrollHeader.Description>
</ScrollHeader.Large>
<ScrollView>{holdings}</ScrollView>
</ScrollHeader>A bar on its own
With no Large block there is no distance to cross, so the bar's surface appears as soon as the content has moved at all. Use it for a screen that wants the hairline but not the large title.
<ScrollHeader className="flex-1">
<ScrollHeader.Bar>
<Button variant="ghost" size="icon">
<ChevronLeftIcon size={20} />
</Button>
<ScrollHeader.Title>Settings</ScrollHeader.Title>
<ScrollHeader.Actions>
<Button variant="ghost" size="icon">
<EllipsisIcon size={18} />
</Button>
</ScrollHeader.Actions>
</ScrollHeader.Bar>
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>{panels}</ScrollView>
</ScrollHeader>Variants
surface
plain(default)mutednone
<ScrollHeader.Bar surface="plain">…</ScrollHeader.Bar>
<ScrollHeader.Bar surface="muted">…</ScrollHeader.Bar>
<ScrollHeader.Bar surface="none">…</ScrollHeader.Bar>divider
true(default)false
<ScrollHeader.Bar divider>…</ScrollHeader.Bar>
<ScrollHeader.Bar divider={false}>…</ScrollHeader.Bar>API Reference
ScrollHeader
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
barHeight | number | 48 | Height of the pinned bar in points, before the device's top inset. The inset is added on top of this rather than taken out of it, so the bar's contents keep this much room on every device. |
threshold | number | 1 | How much of the large block has to leave before the bar has fully taken over, as a fraction of its height. Below 1 the crossing happens early, which suits a tall block whose last few points are not worth waiting for. |
snap | boolean | true | Settle a part-scrolled band open or closed when the finger lifts, rather than leaving the header half-collapsed. |
stretch | boolean | true | Let the band grow past its resting height when the scroller is pulled down. A cover fills the band, so this is what stretches it. Off under Reduce Motion. |
inset | boolean | true | Add the device's top inset above the bar. Off inside a screen that already has one. |
onCollapsedChange | (collapsed: boolean) => void | — | Called as the bar takes over, and again when the large block comes back. Fires on the crossing, not on every frame. |
progress | SharedValue<number> | — | A shared value to mirror the collapse into, 0 to 1, for animating something outside the header against the same transition. |
ScrollHeader.Bar
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
surface | ScrollHeaderSurface | plain | What the bar is drawn on once it has taken over. none leaves it clear, for a bar over a cover that should stay visible. |
divider | boolean | true | A hairline under the bar, drawn with its surface. |
ScrollHeader.Large
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
ScrollHeader.Actions
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
ScrollHeader.Cover
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
source | ImageSourcePropType | — | A picture behind the header. Laid out to fill the band, so it stretches with it. |
colors | [string, string, ...string[]] | — | The gradient drawn when there is no picture, or under one that has not loaded. Defaults to two of the theme's series tokens, so an app that puts its charts on brand puts this on brand with them. |
scrim | boolean | true | A wash over the cover, so a title stays legible on a bright picture. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Give the root a height. It fills its parent, and inside a container with no height of its own it collapses to nothing. className="flex-1" on a screen is the usual answer.
Exactly one scrollable child. Anything that is not a Bar, a Large or a Cover is taken to be the scroller, and the first such child wins. A ScrollView, a FlatList and a SectionList all work, and so does an Animated.ScrollView or Animated.FlatList you have already animated yourself — that one is used as it stands rather than wrapped again.
An onScroll of your own is kept, either kind. A handler from useAnimatedScrollHandler composes onto the header's and stays on the UI thread. A plain function is called across the bridge instead, once for every scroll event the child delivers — which scrollEventThrottle decides, and which is every frame at the default of 16 — and receives a { nativeEvent } carrying every field React Native puts on a scroll event, velocity and targetContentOffset included. What it does not carry is the synthetic wrapper around it, which is a live object with methods on it and cannot cross. Prefer the animated handler where the work can be done in a worklet.
The content inset is applied for you, as paddingTop on the child's content container, and it is the band's full resting height. Top padding of your own is read off contentContainerStyle and added to it, because a style array overrides rather than adds — and you could not write the sum yourself in any case, since the band's height is measured rather than known. A percentage is left out of the sum rather than guessed at, and the inset alone is used.
The two titles are both in the tree the whole time, and only the one being shown is exposed to a screen reader — the other is hidden as the header crosses. Nothing else about the bar is gated: a back button and the Actions stay reachable at every scroll position.
snap settles a part-scrolled band at whichever end is nearer once the finger lifts and nothing else is going to move it. A fling is left alone, because settling under momentum fights the gesture rather than finishing it.
Under Reduce Motion the band stops growing past its resting height, so an over-scroll no longer stretches the cover. The crossfade itself is scroll-linked rather than autonomous and is left as it is — it moves only as far as the finger does.
With no Large block there is no distance to interpolate over, so the bar's surface is timed in over 200ms rather than appearing between one frame and the next. With a block, the hand-over is scroll-linked and moves only as far as the finger does.
On Android the band carries a small elevation so it draws over the scroller, which orders by elevation before z-order. That is also what draws its shadow, so surface="none" still lifts very slightly.
Public exports
Values: ScrollHeader
Types: ScrollHeaderProps, ScrollHeaderBarProps, ScrollHeaderLargeProps, ScrollHeaderActionsProps, ScrollHeaderCoverProps, ScrollHeaderSurface