useScrollSections

Track which section of a scroll view is being read.

Records where each section of a scroll view sits, reports which one is being read, and scrolls back to any of them. Built for SectionRail, but it has nothing to do with the rail — anything that needs to follow a long page can use it.

import { SectionRail, useScrollSections } from 'panelui-native';

const SECTIONS = [
  { id: 'intro', label: 'Introduction' },
  { id: 'setup', label: 'Setup' },
  { id: 'faq', label: 'Frequently asked' },
];

function Page() {
  const sections = useScrollSections({ ids: SECTIONS.map((s) => s.id) });

  return (
    <View className="flex-1">
      <ScrollView ref={sections.ref} {...sections.scrollProps}>
        {SECTIONS.map((section) => (
          <View key={section.id} onLayout={sections.measure(section.id)}>
            <Text size="2xl" weight="semibold">{section.label}</Text>
            {/* …section body… */}
          </View>
        ))}
      </ScrollView>

      <SectionRail
        align="bottom"
        value={sections.active}
        onValueChange={sections.scrollTo}
      >
        <SectionRail.Trigger>
          {SECTIONS.map((s) => <SectionRail.Bar key={s.id} value={s.id} />)}
        </SectionRail.Trigger>
        <SectionRail.Content>
          {SECTIONS.map((s) => (
            <SectionRail.Item key={s.id} value={s.id}>{s.label}</SectionRail.Item>
          ))}
        </SectionRail.Content>
      </SectionRail>
    </View>
  );
}

The reading line

The active section is the last one whose top has passed a line offset pixels down the viewport — 120 by default. The line is not at the very top on purpose: a heading you have just scrolled past should still count as the section you are in, not the one you have left.

Raise offset to switch later, lower it to switch sooner.

const sections = useScrollSections({ ids, offset: 200 });

The last section

This is the part worth knowing about, because it is the bug every hand-written scrollspy has.

The reading line alone cannot reach the final section. Its top may never travel far enough up the screen, because the content runs out before it does — so its entry never highlights, and users find they have to over-scroll past the bottom of the page to make it light up.

So the hook treats being at the bottom of the scroll view as being in the last section, whatever the reading line says. There is no more scrolling left with which to get there, so there is nothing else it could reasonably be.

endThreshold (24px) is how close to the bottom counts as at it.

Scrolling back

scrollTo takes a section id and animates to it, which is exactly the shape a rail's onValueChange wants:

<SectionRail value={sections.active} onValueChange={sections.scrollTo}>

Use scrollPadding to stop a section landing flush against the top edge, or under a floating header:

const sections = useScrollSections({ ids, scrollPadding: 64 });

API Reference

Options

OptionTypeDefaultDescription
idsstring[]Section ids, in the order they appear down the page.
offsetnumber120How far down the viewport the reading line sits.
endThresholdnumber24How close to the bottom counts as at the bottom.
scrollPaddingnumber0Gap left above a section when scrolling to it.

Returns

ValueTypeDescription
refRefObject<ScrollView>Attach to the ScrollView, so scrollTo has something to drive.
activestring | undefinedThe section being read.
scrollPropsobjectSpread onto the ScrollView — the scroll handler and its throttle.
measure(id) => (event) => voidonLayout for a section wrapper: onLayout={measure(id)}.
scrollTo(id) => voidScroll a section to the top.

Notes

measure reads each section's y relative to the scroll view's content, so the wrapper you attach it to has to be a direct child of the ScrollView. Nest it inside another view and the offsets are measured against that instead, and every section will read as the first one.

For a pager — one full-screen section per swipe — you do not need this hook. The active page is contentOffset.y / layoutMeasurement.height, and there is no reading line to place.

On this page