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, Text, SearchBar, Chip, Avatar, Tabs, ButtonGroup, Badge, SearchIcon, ChevronLeftIcon, PlusIcon, ShareNodesIcon, EllipsisIcon } from 'panelui-native';
import { View, ScrollView, FlatList, SectionList } from 'react-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add scroll-header

Usage

<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. surface picks what it is drawn on, divider puts a hairline under it, and surface="blur" frosts it rather than filling it, so the content passing underneath stays legible as shape and colour.
  • 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: in Large it is large and at rest, in Bar it 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. A Badge aligns to the top of the row rather than its centre, so give it self-center where it sits beside a taller control like a button.
  • 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. scrim washes 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>

A bar you can see through

A frosted bar keeps the rows visible as they pass under it, which is worth having where the content is pictures or colour rather than text.

It needs expo-blur, which is an optional dependency, and Reduce Transparency replaces a blur with an opaque surface. Both cases fall back to plain — a bar whose title cannot be read is a worse outcome than one that is not frosted, so there is nothing to guard here.

intensity tunes the depth and material the tint, which follows the app's theme rather than the device's.

<ScrollHeader className="flex-1">
  <ScrollHeader.Bar surface="blur" divider={false}>
    <ScrollHeader.Title>Releases</ScrollHeader.Title>
  </ScrollHeader.Bar>

  <ScrollHeader.Large>
    <ScrollHeader.Title>Releases</ScrollHeader.Title>
    <ScrollHeader.Description>40 albums</ScrollHeader.Description>
  </ScrollHeader.Large>

  <ScrollView>{artwork}</ScrollView>
</ScrollHeader>

Everything in the bar is pinned, so a tab row written there is still on screen once the title has gone. Give barHeight the full height of what the bar carries — the band is that tall and the content inset follows it, so the two cannot fall out of step.

<ScrollHeader className="flex-1" barHeight={94}>
  <ScrollHeader.Bar className="flex-col items-stretch gap-0 px-0">
    <View className="h-12 flex-row items-center gap-3 px-4">
      <ScrollHeader.Title>u/economist</ScrollHeader.Title>
      <ScrollHeader.Actions>
        <Button variant="ghost" size="icon">
          <ShareNodesIcon size={18} />
        </Button>
      </ScrollHeader.Actions>
    </View>

    <Tabs value={tab} onValueChange={setTab} variant="underline">
      <Tabs.List className="px-4">
        <Tabs.Trigger value="posts">Posts</Tabs.Trigger>
        <Tabs.Trigger value="comments">Comments</Tabs.Trigger>
      </Tabs.List>
    </Tabs>
  </ScrollHeader.Bar>

  <ScrollHeader.Large>
    <ScrollHeader.Title>Economist</ScrollHeader.Title>
    <ScrollHeader.Description>Joined 2019 · 12.4k karma</ScrollHeader.Description>
  </ScrollHeader.Large>

  <ScrollView>{posts}</ScrollView>
</ScrollHeader>

A list that recycles

The single scrollable child is cloned whatever kind it is, so a FlatList takes the scroll handler and the content inset the same way a ScrollView does. Padding of your own is added to the header's inset rather than replacing it, so paddingTop here is the gap under the band and nothing else.

<ScrollHeader className="flex-1">
  <ScrollHeader.Bar>
    <Button variant="ghost" size="icon">
      <ChevronLeftIcon size={20} />
    </Button>
    <ScrollHeader.Title className="text-center">Contacts</ScrollHeader.Title>
    <ScrollHeader.Actions>
      <Button variant="ghost" size="icon">
        <PlusIcon size={18} />
      </Button>
    </ScrollHeader.Actions>
  </ScrollHeader.Bar>

  <ScrollHeader.Large className="pb-3">
    <ScrollHeader.Title>Contacts</ScrollHeader.Title>
    <View className="mt-1 flex-row gap-2">
      {groups.map((name) => (
        <Chip key={name} selected={group === name} onPress={() => setGroup(name)}>
          {name}
        </Chip>
      ))}
    </View>
  </ScrollHeader.Large>

  <FlatList
    data={contacts}
    keyExtractor={(row) => row.id}
    contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 64 }}
    renderItem={({ item }) => (
      <Item size="sm">
        <Item.Media>
          <Avatar size="sm" fallback={item.initials} />
        </Item.Media>
        <Item.Content>
          <Item.Title>{item.name}</Item.Title>
        </Item.Content>
      </Item>
    )}
  />
</ScrollHeader>

A grouped list

A SectionList under a bar drawn on the card token with no hairline, so the header and the rows read as one surface. Leave stickySectionHeadersEnabled off: a sticky header pins to the top of the scroller, which is behind the band, so it would disappear under the bar rather than sit below it.

<ScrollHeader className="flex-1">
  <ScrollHeader.Bar surface="muted" divider={false}>
    <ScrollHeader.Title>Preferences</ScrollHeader.Title>
  </ScrollHeader.Bar>

  <ScrollHeader.Large>
    <ScrollHeader.Title>Preferences</ScrollHeader.Title>
    <ScrollHeader.Description>{sections.length} groups</ScrollHeader.Description>
  </ScrollHeader.Large>

  <SectionList
    sections={sections}
    keyExtractor={(row) => row.id}
    stickySectionHeadersEnabled={false}
    contentContainerStyle={{ paddingHorizontal: 16, paddingBottom: 64 }}
    renderSectionHeader={({ section }) => (
      <Text size="xs" weight="semibold" muted className="pb-1 pt-4 uppercase">
        {section.title}
      </Text>
    )}
    renderItem={({ item }) => (
      <Item size="sm" variant="muted" className="mb-1">
        <Item.Content>
          <Item.Title>{item.label}</Item.Title>
        </Item.Content>
      </Item>
    )}
  />
</ScrollHeader>

The collapse, borrowed

progress mirrors the transition into a shared value you own, so something outside the header can move at the pace the title leaves at. Drive what is drawn from progress and what is reachable from onCollapsedChange: a control faded to nothing still takes touches and is still read out, so the crossing is what gates it.

const collapse = useSharedValue(0);
const [collapsed, setCollapsed] = useState(false);

const pill = useAnimatedStyle(() => ({
  opacity: collapse.value,
  transform: [{ translateY: interpolate(collapse.value, [0, 1], [16, 0], 'clamp') }],
}));

<View className="flex-1">
  <ScrollHeader className="flex-1" progress={collapse} onCollapsedChange={setCollapsed}>
    <ScrollHeader.Bar divider={false}>
      <ScrollHeader.Title>Drafts</ScrollHeader.Title>
    </ScrollHeader.Bar>

    <ScrollHeader.Large>
      <ScrollHeader.Title>Drafts</ScrollHeader.Title>
      <ScrollHeader.Description>{drafts.length} unsent</ScrollHeader.Description>
    </ScrollHeader.Large>

    <ScrollView>{rows}</ScrollView>
  </ScrollHeader>

  <Animated.View
    pointerEvents={collapsed ? 'box-none' : 'none'}
    accessibilityElementsHidden={!collapsed}
    style={pill}
    className="absolute bottom-8 end-4"
  >
    <Button size="sm">New draft</Button>
  </Animated.View>
</View>

A gradient instead of a picture

Cover takes colors instead of a source for a ramp mixed from the theme's own tokens. stretch={false} holds the band at its resting height, which is what a gradient wants — a photograph gains from being pulled taller and a two-stop ramp has nothing to gain. With the block's title gone the bar still has something worth pinning, so a segmented control stands where the bar title would.

<ScrollHeader className="flex-1" stretch={false}>
  <ScrollHeader.Cover colors={[ramp[2], ramp[3]]} scrim={false} />

  <ScrollHeader.Bar surface="none" divider={false}>
    <ButtonGroup size="sm" className="mx-auto">
      {views.map((name) => (
        <Button
          key={name}
          variant={view === name ? 'secondary' : 'ghost'}
          onPress={() => setView(name)}
        >
          {name}
        </Button>
      ))}
    </ButtonGroup>
  </ScrollHeader.Bar>

  <ScrollHeader.Large className="h-40 justify-end pb-5">
    <ScrollHeader.Title className="text-white">Playlists</ScrollHeader.Title>
    <ScrollHeader.Description className="text-white/80">
      Made for you
    </ScrollHeader.Description>
  </ScrollHeader.Large>

  <ScrollView>{playlists}</ScrollView>
</ScrollHeader>

Left where you let go

snap={false} leaves a part-scrolled band exactly where the finger lifted, instead of settling it at whichever end is nearer. Reach for it where the block is reference material rather than a title, so a half-open header is a state worth being left in.

<ScrollHeader className="flex-1" snap={false}>
  <ScrollHeader.Bar>
    <ScrollHeader.Title>Archive</ScrollHeader.Title>
    <ScrollHeader.Actions>
      <Badge variant="secondary" className="self-center">
        {archive.length}
      </Badge>
      <Button variant="ghost" size="icon">
        <EllipsisIcon size={18} />
      </Button>
    </ScrollHeader.Actions>
  </ScrollHeader.Bar>

  <ScrollHeader.Large>
    <ScrollHeader.Title>Archive</ScrollHeader.Title>
    <ScrollHeader.Description>Last synced 11 minutes ago</ScrollHeader.Description>
  </ScrollHeader.Large>

  <ScrollView>{documents}</ScrollView>
</ScrollHeader>

Variants

surface

  • plain (default)
  • muted
  • none
  • blur
<ScrollHeader.Bar surface="plain">…</ScrollHeader.Bar>
<ScrollHeader.Bar surface="muted">…</ScrollHeader.Bar>
<ScrollHeader.Bar surface="none">…</ScrollHeader.Bar>
<ScrollHeader.Bar surface="blur">…</ScrollHeader.Bar>

divider

  • true (default)
  • false
<ScrollHeader.Bar divider>…</ScrollHeader.Bar>
<ScrollHeader.Bar divider={false}>…</ScrollHeader.Bar>

API Reference

ScrollHeader

PropTypeDefaultDescription
classNamestring—
barHeightnumber48Height 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.
thresholdnumber1How 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.
snapbooleantrueSettle a part-scrolled band open or closed when the finger lifts, rather than leaving the header half-collapsed.
stretchbooleantrueLet 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.
insetbooleantrueAdd 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.
progressSharedValue<number>—A shared value to mirror the collapse into, 0 to 1, for animating something outside the header against the same transition.

ScrollHeader.Bar

PropTypeDefaultDescription
classNamestring—
surfaceScrollHeaderSurfaceplainWhat the bar is drawn on once it has taken over. none leaves it clear, for a bar over a cover that should stay visible. blur frosts it, so the content passing under the bar stays legible as shape and colour. blur needs expo-blur, which is optional, and it is replaced by an opaque bar under Reduce Transparency. Both fall back to plain — a bar whose title cannot be read is a worse answer than one that is not frosted.
dividerbooleantrueA hairline under the bar, drawn with its surface.
intensitynumber40Depth of the frost, on expo-blur's 0–100 scale. Defaults to 40 — heavier than a scrim's, because a scrim covers a whole screen and this is a thin band read against content moving under it. Ignored unless surface is blur.
materialScrollHeaderMaterial'default'Which way the frost tints. Defaults to the app's theme rather than the device's, so an app running light inside a dark OS frosts light. Ignored unless surface is blur.

ScrollHeader.Large

PropTypeDefaultDescription
classNamestring—

ScrollHeader.Actions

PropTypeDefaultDescription
classNamestring—

ScrollHeader.Cover

PropTypeDefaultDescription
classNamestring—
sourceImageSourcePropType—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.
scrimbooleantrueA 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.

A frosted bar is optional twice over. surface="blur" needs expo-blur, which is an optional dependency reached through a lazy require, and Reduce Transparency is a preference that outranks the design. Where either says no the bar draws plain instead, so nothing has to be guarded at the call site — but pick the rest of the screen so it still works with a solid bar, because for some readers that is the only bar there is.

Public exports

Values: ScrollHeader

Types: ScrollHeaderProps, ScrollHeaderBarProps, ScrollHeaderLargeProps, ScrollHeaderActionsProps, ScrollHeaderCoverProps, ScrollHeaderMaterial, ScrollHeaderSurface

On this page