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-rail

Usage

<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 deeper level draws 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

PropTypeDefaultDescription
classNamestring
placementSectionRailPlacement'right'Which edge the rail sits against.
alignSectionRailAlign'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.
hapticsbooleanfalseTick 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.
valuestringActive section id. Controlled — usually driven by a scroll handler.
defaultValuestringStarting section when uncontrolled.
onValueChange(value: string) => voidFires when a section is chosen from the expanded panel.
openbooleanControlled expansion.
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
closeDelaynumber300How long the panel stays up after a choice, so a mis-tap can be corrected without opening it again. Set 0 to close immediately.
offsetnumber12Gap between the rail and the edge of the safe area.

SectionRail.Trigger

PropTypeDefaultDescription
classNamestring

SectionRail.Bar

PropTypeDefaultDescription
classNamestring
valuestringSection this bar stands for. Matches the root's value.
levelnumberNesting depth. Deeper levels draw a shorter bar.

SectionRail.Content

PropTypeDefaultDescription
classNamestring
maxWidthnumber | ${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

PropTypeDefaultDescription
classNamestring
valuestringSection this row jumps to. Matches the root's value.
levelnumberNesting 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-haptics

Public exports

Values: SectionRail

Types: SectionRailProps, SectionRailTriggerProps, SectionRailBarProps, SectionRailContentProps, SectionRailItemProps, SectionRailPlacement, SectionRailAlign

On this page