Sticky header list
Free · MITReact Native · ExpoA 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.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/sticky-header-list.jsonThen 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
- 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.
- 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
| Prop | Type | Default |
|---|---|---|
| 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. | number | 56 |
| headerHeight Height of a section header in points. Default 40. | number | 40 |
| 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`. | boolean | true |
| haptic `false` silences the index ticks. | boolean | true |
| 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.
Related
Settings and navigation cells with a token-colored icon tile, title, subtitle and a trailing value, switch, badge, check or chevron, grouped in inset sections with headers and footers. On touch down the highlight floods in, the tile sinks and the chevron leans forward; on release the highlight lingers and fades.
An iOS-style search field: focusing it shrinks the field while a Cancel button springs in beside it and scope chips cascade in below. Clear button, loading spinner and debounced queries built in.
An iOS large title that collapses into a compact bar as the content scrolls: the titles cross-fade, the bar fades in, the title stretches when pulled past the top and a half-finished collapse settles on either state.
A drag-to-sort list: a long press (or the grip) lifts a row with a little scale and a shadow, the other rows slide out of its way with a tick per swap, the list scrolls itself near the edges, and the row settles into its slot on release.