SectionRail
Floating section navigator for a long screen.
A floating navigator for a long screen: a stack of short bars pinned to one edge, one per section, that expands into a labelled panel when you touch it. Collapsed it is a position indicator you can read at a glance without giving up any content width; expanded it is a list you can jump from.
Installation
SectionRail ships with the library — no separate install.
import { SectionRail, Text, useScrollSections } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add section-railUsage
<SectionRail align="bottom" haptics value={active} onValueChange={scrollToSection}>
<SectionRail.Trigger>
<SectionRail.Bar value="intro" />
<SectionRail.Bar value="setup" level={1} />
</SectionRail.Trigger>
<SectionRail.Content>
<SectionRail.Item value="intro">Introduction</SectionRail.Item>
<SectionRail.Item value="setup" level={1}>Setup</SectionRail.Item>
</SectionRail.Content>
</SectionRail>Composition
<SectionRail>
<SectionRail.Trigger>
<SectionRail.Bar value="…" />
</SectionRail.Trigger>
<SectionRail.Content>
<SectionRail.Item value="…">…</SectionRail.Item>
</SectionRail.Content>
</SectionRail>SectionRail.Trigger— The collapsed rail. One press target over the whole stack of bars, because a hairline is not something anyone can hit.SectionRail.Bar— One section, drawn as a bar. Widens and brightens when it is the active one; a deeperleveldraws a shorter bar.SectionRail.Content— The expanded panel, mounted through a portal so it floats over everything and unmounted after it fades out.SectionRail.Item— A labelled row in the panel, indented to match its bar.
Examples
Driving it from a scroll position
The rail does not watch the scroll itself — it takes a value. useScrollSections supplies one: it records where each section landed, picks the one being read, and hands back a scrollTo that is exactly the shape onValueChange wants. It also handles the end of the page, where the last section’s top never reaches the reading line because the content runs out first.
const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });
<ScrollView ref={sections.ref} {...sections.scrollProps}>
{SECTIONS.map((section) => (
<View key={section.id} onLayout={sections.measure(section.id)}>
{/* …section… */}
</View>
))}
</ScrollView>
<SectionRail
align="bottom"
value={sections.active}
onValueChange={sections.scrollTo}
>
{/* …bars and items… */}
</SectionRail>Nesting
level does two jobs from one number: it shortens the bar and indents the row, so the collapsed rail and the expanded panel agree about the shape of the page.
<SectionRail.Trigger>
<SectionRail.Bar value="install" />
<SectionRail.Bar value="expo" level={1} />
<SectionRail.Bar value="bare" level={1} />
</SectionRail.Trigger>
<SectionRail.Content>
<SectionRail.Item value="install">Installation</SectionRail.Item>
<SectionRail.Item value="expo" level={1}>Expo</SectionRail.Item>
<SectionRail.Item value="bare" level={1}>Bare React Native</SectionRail.Item>
</SectionRail.Content>Putting it in a corner
Centred on an edge, the rail sits over the middle of the text — the part you are reading — where it is both in the way and easy to miss. align="bottom" moves it into a corner clear of the content, and the panel then opens upward out of that corner rather than across the middle of the screen.
{/* Bottom right */}
<SectionRail align="bottom" value={active} onValueChange={setActive}>…</SectionRail>
{/* Bottom left */}
<SectionRail placement="left" align="bottom" value={active} onValueChange={setActive}>
…
</SectionRail>Anchoring it to the other edge
The panel follows — it is anchored to the same edge and slides in from it.
<SectionRail placement="left" value={active} onValueChange={setActive}>
{/* … */}
</SectionRail>One section per screen
A pager needs no reading line — the active page is the scroll offset over the viewport height — so it drives the rail directly. Size the pages from the scroll view's measured height rather than the window's: anything above the pager makes the viewport shorter than the screen, and window-height pages then drift further out of alignment with each snap position until one lands between two and never shows.
const [page, setPage] = useState(0);
const [pageHeight, setPageHeight] = useState(0);
const scroller = useRef<ScrollView>(null);
<ScrollView
ref={scroller}
pagingEnabled
scrollEventThrottle={16}
onLayout={(e) => setPageHeight(e.nativeEvent.layout.height)}
onScroll={(e) => {
const { contentOffset, layoutMeasurement } = e.nativeEvent;
if (!layoutMeasurement.height) return;
setPage(Math.round(contentOffset.y / layoutMeasurement.height));
}}
>
{SECTIONS.map((s) => (
<View key={s.id} style={{ height: pageHeight || undefined }}>
{/* …page… */}
</View>
))}
</ScrollView>
<SectionRail
placement="left"
align="bottom"
haptics
value={SECTIONS[page]?.id}
onValueChange={(next) => {
const index = SECTIONS.findIndex((s) => s.id === next);
if (index < 0 || !pageHeight) return;
setPage(index);
scroller.current?.scrollTo({ y: index * pageHeight, animated: true });
}}
>
{/* …bars and items… */}
</SectionRail>API Reference
SectionRail
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
placement | SectionRailPlacement | 'right' | Which edge the rail sits against. |
align | SectionRailAlign | 'center' | Where along that edge it sits. bottom puts it in a corner, out of the way of the text — the panel then opens upward from the rail rather than centred on the screen. |
haptics | boolean | false | Tick under the finger on every change of section, however it was made — tapped in the panel, or scrolled past. Needs the optional expo-haptics package; without it this does nothing. |
value | string | — | Active section id. Controlled — usually driven by a scroll handler. |
defaultValue | string | — | Starting section when uncontrolled. |
onValueChange | (value: string) => void | — | Fires when a section is chosen from the expanded panel. |
open | boolean | — | Controlled expansion. |
defaultOpen | boolean | false | |
onOpenChange | (open: boolean) => void | — | |
closeDelay | number | 300 | How long the panel stays up after a choice, so a mis-tap can be corrected without opening it again. Set 0 to close immediately. |
offset | number | 12 | Gap between the rail and the edge of the safe area. |
SectionRail.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
SectionRail.Bar
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
value | string | — | Section this bar stands for. Matches the root's value. |
level | number | — | Nesting depth. Deeper levels draw a shorter bar. |
SectionRail.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
SectionRail.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
value | string | — | Section this row jumps to. Matches the root's value. |
level | number | — | Nesting depth. Indents the row to match its bar. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
The bars carry no labels, and that is the design rather than an omission. A permanent list of section titles down the side of a phone screen is either too small to read or too wide to keep; the bars carry the two things that survive at that size — which section you are in, and roughly how deep it sits.
The root is absolutely positioned with pointerEvents="box-none", so the rail takes touches but the empty column around it does not. Without that a strip down the side of the screen would swallow every scroll that started in it.
Choosing a section leaves the panel up for closeDelay (300ms) so a mis-tap can be corrected without opening it again. Pass 0 to close immediately.
Haptics
haptics ticks under the finger on every change of section — tapped in the panel or scrolled past, because both are the section changing. It is the light selection tick rather than an impact: anything heavier gets tiring when it can fire on every scroll.
It needs the optional expo-haptics package and does nothing without it, so it is safe to pass either way.
npx expo install expo-haptics