Sticky header list

Free · MITReact Native · Expo

A sectioned list whose sticky headers change as they stick: the title shrinks toward the leading edge and a surface with a hairline (frosted on the web) fades in under it. A contacts-style index on the side jumps between sections while a large letter follows the finger, with a tick per letter.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/sticky-header-list.json

Then run npx expo install --fix so react-native-gesture-handler, react-native-reanimated match your Expo SDK. Files land in components/goodui-native/sticky-header-list.

Usage

import { Text, View } from "react-native";
import { StickyHeaderList, type StickySection } from "@/components/goodui-native/sticky-header-list/sticky-header-list";

type Contact = { id: string; name: string };

export function Contacts({ sections }: { sections: StickySection<Contact>[] }) {
  return (
    <StickyHeaderList
      accessibilityLabel="Contacts"
      sections={sections}
      keyExtractor={(c) => c.id}
      itemHeight={56}
      style={{ flex: 1 }}
      renderItem={({ item }) => (
        <View style={{ paddingHorizontal: 16 }}>
          <Text>{item.name}</Text>
        </View>
      )}
    />
  );
}

When to use

Use it for
  • Long alphabetical lists the person scans or jumps through: contacts, countries, ingredients, members.
  • Feeds grouped by date where the current day should stay visible: transactions, messages, activity.
  • Any long list with groups that benefit from a visible group title while scrolling.
Reach for something else
  • A short settings list in a few groups: use list-row with ListSection.
  • Rows of different heights: this list jumps by fixed itemHeight and headerHeight; use a plain SectionList.
  • Collapsible groups: use accordion.
  • Rows the person sorts by hand: use reorderable-list.

Examples

Full alphabet with empty letters

<StickyHeaderList
  sections={contacts}
  indexLetters={"ABCDEFGHIJKLMNOPQRSTUVWXYZ#".split("")}
  keyExtractor={(c) => c.id}
  ListHeaderComponent={<SearchBar placeholder="Search contacts" />}
  renderItem={({ item }) => <ContactRow contact={item} />}
/>

Transactions by day, no index

<StickyHeaderList
  sections={days}
  showIndex={false}
  keyExtractor={(t) => t.id}
  renderItem={({ item }) => <TransactionRow txn={item} />}
/>

API reference

PropTypeDefault
sections*
Sections in display order.
StickySection<T>[]—
renderItem*
Renders one row, `itemHeight` tall.
(info: { item: T; index: number; section: StickySection<T> }) => ReactNode—
keyExtractor*
Stable key per row.
(item: T, index: number) => string—
itemHeight
Height of every row in points, so the list and the index can jump without measuring. Default 56.
number56
headerHeight
Height of a section header in points. Default 40.
number40
showIndex
Side index for jumping between sections (contacts style). `auto` (default) shows it with six sections or more.
boolean | "auto""auto"
indexLetters
Letters in the index. Default: the first character of each section title. Letters without a section show dimmed and jump to the next one.
string[]—
separators
Hairlines between rows, inset to the content. Default `true`.
booleantrue
haptic
`false` silences the index ticks.
booleantrue
ListHeaderComponent
Content above the first section, such as a search field or a count. It scrolls away.
ReactElement—
ListEmptyComponent
Shown when there are no sections.
ReactElement—
accessibilityLabel
Name of the list, for example "Contacts".
string—
labels
Overrides for the built-in strings; a GooduiLocaleProvider pack sets them app-wide.
Partial<StickyHeaderListLabels>—
style
Style of the outer container. Give it `flex: 1` or a height.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • Each header's stuck look is interpolated from the scroll position on the UI thread over 12 pt: the title scales to 0.8 from its leading edge and the stuck surface with its hairline fades in; scrolling back reverses it.
  • On the web the stuck surface turns translucent over a backdrop blur, so rows scrolling under it frost.
  • Index: touching it springs a 56 pt letter bubble out beside the finger (snappy), which follows the finger and fades away on release; the list jumps without animation, like the system contacts list.
  • Reduced motion: the bubble fades in without scaling; the stuck transition is tied to scrolling and stays.
  • Haptic: select for each letter the finger crosses on the index and for each keyboard or screen reader step (haptic={false} silences it)
  • Built on SectionList (virtualized) with getItemLayout from the fixed heights, so jumps to any section are exact and instant.
  • The scroll handler and every header animation run on the UI thread; React state changes only when the index letter changes.

Accessibility

  • Section headers have the header role, so screen readers can move by heading.
  • The index is one adjustable element named 'Section index' whose value is the current letter; increment and decrement move to the next and previous section that exists, and the letter is announced.
  • On the web the index is a focusable slider with aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext; arrow keys move between sections.
  • The large letter bubble is decorative and hidden from screen readers.

Notes for AI

  • Every row is itemHeight tall and every header headerHeight tall; size renderItem to fit. Wrong heights make index jumps land off target.
  • Give the list a bounded height: style={{ flex: 1 }} in a screen or a fixed height in a card.
  • Use ListHeaderComponent (not contentContainerStyle padding) for content above the first section, such as a search field; it is measured for the jump offsets.
  • Section titles feed the index by their first character. Pass indexLetters for a full A to Z: missing letters show dimmed and jump to the next section.
  • Rows get a hairline between them by default (separators); renderItem should not add its own border.