ScrollHeader

A large title that collapses into a pinned bar as the page scrolls, with a cover, a blur bar and snap.

Open Source

Installation

ScrollHeader ships with the library — install it once, then import:

import { ScrollHeader } from "@delacour/react-native-ui/scroll-header";

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

bunx delacour@latest add scroll-header

Usage

A large title over a list
app/index.tsx
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { ListGroup } from "@/components/ui/list-group";
import { ScrollHeader } from "@/components/ui/scroll-header";
import { Text } from "@/components/ui/text";
import { IconMagnifyingGlass } from "@/lib/icons/central";
import { View } from "react-native";

const ALBUMS = ["Blue", "Kind of Blue", "Rumours", "Pet Sounds", "Abbey Road", "Horses", "Hounds of Love"];

export default function Library() {
  return (
    <ScrollHeader>
      <ScrollHeader.Bar>
        <ScrollHeader.BarTitle>Library</ScrollHeader.BarTitle>
        <View className="ml-auto">
          <Button accessibilityLabel="Search" size="icon-sm" variant="secondary">
            <Icon icon={IconMagnifyingGlass} />
          </Button>
        </View>
      </ScrollHeader.Bar>
      <ScrollHeader.Large>
        <Text.Title>Library</Text.Title>
        <Text.Caption>{`${ALBUMS.length} albums`}</Text.Caption>
      </ScrollHeader.Large>
      <ScrollHeader.ScrollView contentContainerClassName="px-screen-gutter pb-12">
        <ListGroup>
          {ALBUMS.map((album) => (
            <ListGroup.Item key={album}>
              <ListGroup.ItemTitle>{album}</ListGroup.ItemTitle>
            </ListGroup.Item>
          ))}
        </ListGroup>
      </ScrollHeader.ScrollView>
    </ScrollHeader>
  );
}

The title block scrolls away while the bar's title fades in to replace it, and the bar's surface and hairline arrive with it. Let go half way and the header settles open or closed.

The root is flex-1: give its parent a height, or it collapses to nothing.

Anatomy

PartWhat it is
ScrollHeader.BarThe pinned bar — the safe-area band, then one row of controls. Its surface fades in as the header collapses; its controls never fade.
ScrollHeader.BarTitleThe bar's title, the compact half of the crossfade.
ScrollHeader.LargeThe large title block. Measured, so its height is the collapse distance. Display-only: it takes no touches.
ScrollHeader.CoverAn image or a gradient behind the bar and the title.
ScrollHeader.ScrollView / .FlatListThe scroller, already wired. Exactly one per header.
ScrollHeader.InsetThe spacer a custom scroller renders at its top.

The parts may come in any order — they stack by z-index, not by position.

Cover and stretch

A cover that stretches
<ScrollHeader>
  <ScrollHeader.Cover source={{ uri: "https://picsum.photos/id/1018/1200/800" }} />
  <ScrollHeader.Bar>
    <Button accessibilityLabel="Back" onPress={router.back} size="icon-sm" variant="secondary">
      <Icon icon={IconChevronLeft} />
    </Button>
    <ScrollHeader.BarTitle>Ridge walk</ScrollHeader.BarTitle>
  </ScrollHeader.Bar>
  <ScrollHeader.Large className="pt-24">
    <Text.Title className="text-white">Ridge walk</Text.Title>
  </ScrollHeader.Large>
  <ScrollHeader.ScrollView>{stops}</ScrollHeader.ScrollView>
</ScrollHeader>

The cover ends exactly where the first row begins. Pull down and it grows from its top edge; pass stretch={false} to have it ride down with the content instead. colors draws a gradient in place of an image, and scrim={false} drops the darkening wash.

There is no back-button part: pass a Button with a chevron and your router's back.

The blur bar

A frosted bar over media

surface="blur" frosts the bar with the blur engine ScrollBlurLayerProvider names — set that up once at the root, as on the ScrollBlur page. With no engine, or with Reduce Transparency on, the bar is plain.

<ScrollHeader.Bar surface="blur" intensity={60}>…</ScrollHeader.Bar>

plain is the page colour, muted a muted fill, and none no backing at all.

Early handover

threshold is the fraction of the Large block's height at which the bar has fully taken over. threshold={0.6} suits a tall block with a paragraph under its title.

<ScrollHeader threshold={0.6}>…</ScrollHeader>

Reacting to collapse

Reacting to the collapse

onCollapsedChange fires once per crossing, on the JS thread. progress is written every frame, 0 open to 1 collapsed, for effects on the UI thread.

const progress = useSharedValue(0);
const [isCollapsed, setIsCollapsed] = useState(false);
const avatarStyle = useAnimatedStyle(() => ({ opacity: progress.value }));

<ScrollHeader onCollapsedChange={setIsCollapsed} progress={progress}>
  <ScrollHeader.Bar>
    <Animated.View style={avatarStyle}>
      <Avatar name="Mihi Paewai" size="sm" />
    </Animated.View>
    <ScrollHeader.BarTitle>Mihi Paewai</ScrollHeader.BarTitle>
    {isCollapsed ? <Button size="sm"><Button.Label>Follow</Button.Label></Button> : null}
  </ScrollHeader.Bar>
  …
</ScrollHeader>;

Using another scroller

For a SectionList, a LegendList or any other scrollable, spread useScrollHeader().scrollProps onto its animated form and render ScrollHeader.Inset at the top of its content. The scroller has to be a child of the header.

function Sections() {
  const { scrollProps } = useScrollHeader();

  return (
    <Animated.SectionList
      ListHeaderComponent={<ScrollHeader.Inset />}
      renderItem={renderItem}
      sections={sections}
      {...scrollProps}
    />
  );
}

A scroller part's own onScroll is composed with the header's: a useAnimatedScrollHandler runs on the UI thread, a plain function receives the NativeScrollEvent on the JS thread. RefreshControl works unchanged.

For a page too short to scroll, contentContainerStyle={{ flexGrow: 1 }} keeps it filling the screen; the header stays open.

Inside a Screen

ScrollHeader takes the place of Screen.Navbar. Inside a <Screen>, mount no Screen.Navbar and use ScrollHeader.ScrollView or .FlatList — not Screen.ScrollArea, whose own top inset would stack on the header's. Screen.Footer composes fine.

<Screen>
  <ScrollHeader>
    <ScrollHeader.Bar>…</ScrollHeader.Bar>
    <ScrollHeader.Large>…</ScrollHeader.Large>
    <ScrollHeader.ScrollView>…</ScrollHeader.ScrollView>
  </ScrollHeader>
  <Screen.Footer>…</Screen.Footer>
</Screen>

Accessibility

One title is announced at a time: the bar title is hidden from screen readers while the header is open, and the Large block once it has collapsed. The bar's controls stay reachable in both states. Under Reduce Motion the cover does not stretch and the snap is instant; the crossfade still follows your finger. Under Reduce Transparency the blur bar is plain.

API Reference

ScrollHeader

Extends React Native's ViewProps.

Prop

Type

ScrollHeader.Bar

Prop

Type

ScrollHeader.BarTitle

Prop

Type

ScrollHeader.Cover

Takes source or colors, never both.

Prop

Type

ScrollHeader.ScrollView / ScrollHeader.FlatList

Every ScrollView / FlatList prop, plus:

Prop

Type

useScrollHeader

Prop

Type

On this page