SectionRail
Floating section navigator for a long screen.
A floating navigator for a long screen: short bars pinned to one edge, one per section, that expand into a labelled panel when touched.
Collapsed it is a position indicator that can be read at a glance without giving up any content width. Expanded it is a list you can jump from.
For moving between panels of content rather than places on one long screen, use Tabs.
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. The active one is longest and brightest, the bars either side of it keep part of that, and the rest sit at the resting length — so the silhouette says roughly how far down the run you are without anybody counting bars. 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>How wide the panel opens
The panel caps at 78% of the screen and never opens narrower than 200pt. Raise the cap when the section titles are long enough that two lines is not enough either.
<SectionRail value={section} onValueChange={setSection}>
<SectionRail.Trigger>
<SectionRail.Bar value="shipping" />
<SectionRail.Bar value="returns" />
</SectionRail.Trigger>
<SectionRail.Content maxWidth="90%">
<SectionRail.Item value="shipping">Shipping and delivery times</SectionRail.Item>
<SectionRail.Item value="returns">Returns, refunds and exchanges</SectionRail.Item>
</SectionRail.Content>
</SectionRail>Versions
Bottom right
Out of the corner, clear of the text, with the panel opening upward. The placement most screens want: it is where a thumb already is, and it covers nothing worth reading.
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)}>
…
</View>
))}
</ScrollView>
<SectionRail
placement="right"
align="bottom"
haptics
value={sections.active}
onValueChange={sections.scrollTo}
>
<SectionRail.Trigger>
{SECTIONS.map((s) => (
<SectionRail.Bar key={s.id} value={s.id} level={s.level} />
))}
</SectionRail.Trigger>
<SectionRail.Content>
{SECTIONS.map((s) => (
<SectionRail.Item key={s.id} value={s.id} level={s.level}>{s.label}</SectionRail.Item>
))}
</SectionRail.Content>
</SectionRail>Bottom left
The same corner treatment against the other edge — for a screen whose right-hand corner is already spoken for by a compose button.
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)}>
…
</View>
))}
</ScrollView>
<SectionRail
placement="left"
align="bottom"
haptics
value={sections.active}
onValueChange={sections.scrollTo}
>
<SectionRail.Trigger>
{SECTIONS.map((s) => (
<SectionRail.Bar key={s.id} value={s.id} level={s.level} />
))}
</SectionRail.Trigger>
<SectionRail.Content>
{SECTIONS.map((s) => (
<SectionRail.Item key={s.id} value={s.id} level={s.level}>{s.label}</SectionRail.Item>
))}
</SectionRail.Content>
</SectionRail>Pager
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 rather than through useScrollSections.
const [page, setPage] = useState(0);
<ScrollView
pagingEnabled
onScroll={(event) => {
const { contentOffset, layoutMeasurement } = event.nativeEvent;
setPage(Math.round(contentOffset.y / layoutMeasurement.height));
}}
scrollEventThrottle={16}
>
{PAGES.map((p) => <Page key={p.id} {...p} />)}
</ScrollView>
<SectionRail placement="left" align="bottom" value={PAGES[page].id} onValueChange={goTo}>
<SectionRail.Trigger>
{PAGES.map((p) => <SectionRail.Bar key={p.id} value={p.id} />)}
</SectionRail.Trigger>
</SectionRail>Centred on the edge
Halfway down the right edge, over the content it indexes. The classic placement — closest to a scrollbar, and the one to reach for when the corners are busy.
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)}>
…
</View>
))}
</ScrollView>
<SectionRail
placement="right"
align="center"
haptics
value={sections.active}
onValueChange={sections.scrollTo}
>
<SectionRail.Trigger>
{SECTIONS.map((s) => (
<SectionRail.Bar key={s.id} value={s.id} level={s.level} />
))}
</SectionRail.Trigger>
<SectionRail.Content>
{SECTIONS.map((s) => (
<SectionRail.Item key={s.id} value={s.id} level={s.level}>{s.label}</SectionRail.Item>
))}
</SectionRail.Content>
</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 | — | |
maxWidth | number | ${number}% | '78%' | How wide the panel may grow, as a fraction of the screen or a point width. The default leaves room for the rail and the edge it is anchored to; raise it for a screen whose section titles are long enough to be worth wrapping rather than truncating. |
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.
A row wraps to two lines before it truncates, and the panel is capped at a share of the screen rather than a fixed width, so the same rail works on a phone and on a tablet. maxWidth on SectionRail.Content moves that cap when the titles need 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-hapticsPublic exports
Values: SectionRail
Types: SectionRailProps, SectionRailTriggerProps, SectionRailBarProps, SectionRailContentProps, SectionRailItemProps, SectionRailPlacement, SectionRailAlign