SectionProgress

A floating pill over a long read — a ring filling with scroll progress, the section being read, and a list to jump between them.

Open Source

Installation

SectionProgress ships with the library — install it once, then import:

import { SectionProgress } from "@delacour/react-native-ui/section-progress";

Or copy the source into your project, to own and edit it:

bunx delacour@latest add section-progress

Usage

app/policy.tsx
import { useScrollSections } from "@/hooks/use-scroll-sections";
import { SectionProgress } from "@/components/ui/section-progress";
import { Text } from "@/components/ui/text";
import { View } from "react-native";
import Animated from "react-native-reanimated";

const SECTIONS = [
  { id: "intro", title: "Introduction", body: "What this policy covers, and who it applies to." },
  { id: "pricing", title: "Pricing", body: "What each plan costs, and when you are billed." },
  { id: "faq", title: "Questions", body: "The answers people ask for most often." },
];

export default function Policy() {
  const sections = useScrollSections({ readingLine: 96 });

  return (
    <View className="flex-1">
      <Animated.ScrollView
        ref={sections.scrollRef}
        onScroll={sections.scrollHandler}
        scrollEventThrottle={16}
        contentContainerClassName="pb-32"
      >
        {SECTIONS.map((section) => (
          <View key={section.id} onLayout={sections.register(section.id)}>
            <Text.Header>{section.title}</Text.Header>
            <Text.Paragraph>{section.body}</Text.Paragraph>
          </View>
        ))}
      </Animated.ScrollView>

      <SectionProgress scroll={sections} value={sections.activeId} onValueChange={sections.scrollTo}>
        {SECTIONS.map((section) => (
          <SectionProgress.Item key={section.id} value={section.id}>
            {section.title}
          </SectionProgress.Item>
        ))}
      </SectionProgress>
    </View>
  );
}

useScrollSections tracks the scroll view; the pill reads its progress and scrollY through scroll, its activeId as value and its scrollTo as onValueChange. The ring is drawn with Skia and fills on the UI thread, so scrolling renders nothing — the label changes only when the section does. Then the pill eases to the new title's width while the old title slides out and the new one slides in: up for a later section, down for an earlier one.

Mount the pill as the last child of the screen's container, a sibling of the scroll view. It positions itself absolutely inside that container and is not portalled. Leave bottom padding at the end of the scroll content so the pill never covers the last line.

Each registered section must be a direct child of the scroll content: onLayout reports y relative to its parent.

Placements

Six placements, offset points in from the safe area. A top-* pill opens its panel downward and a bottom-* one upward; start and end mirror under right-to-left.

Placements
<SectionProgress placement="top-end" offset={12} …>…</SectionProgress>

Reveal

The pill appears once the reader scrolls past revealAt (64 points by default) and stays from then on, so it never blinks near the top. revealAt={0} shows it at once.

Coloured sections

An item's color paints its marker in the panel, and the ring while that section is being read.

Coloured sections
<SectionProgress.Item value="known-issues" color="warning">Known issues</SectionProgress.Item>

Manual progress

progress overrides the scroll — a number or a shared value from 0 to 1. Small steps track directly; a jump of more than a tenth eases over 180ms.

Manual progress
<SectionProgress progress={step / steps.length} revealAt={0} value={current}>…</SectionProgress>

Controlled open

Tapping the pill grows it into the card — one surface, its width, height and corners easing together while the list fades in — and the card hugs its sections up to six, past which the list scrolls. isOpen and onOpenChange hand the panel to you. The pill, an outside tap, the escape gesture and Android back all close it through the same callback.

Controlled open
const [isOpen, setOpen] = useState(false);

<SectionProgress isOpen={isOpen} onOpenChange={setOpen} …>…</SectionProgress>

Accessibility

  • The pill is one button, read as "Sections, Pricing, 42 percent", with an expanded state.
  • The percent only reaches React while a screen reader is running; with none, scrolling renders nothing.
  • Opening moves focus to the active section. The panel is modal to VoiceOver, and the escape gesture closes it.
  • Sections are buttons; the active one is announced selected. The ring and the markers are hidden.
  • Android back closes the open panel.
  • Reduce motion snaps the pill's and the card's size, and turns the list's reveal and the title's slide into fades. The ring keeps tracking — it is information, not decoration.

API Reference

Anatomy

PartWhat it is
SectionProgressThe floating pill, its panel and the outside-tap scrim
SectionProgress.ItemOne section — a coloured marker and its title, which is also the pill's label while active

SectionProgress

Extends ViewProps.

Prop

Type

SectionProgress.Item

A Pressable, so feedback, haptic and the rest are inherited — see Pressable.

Prop

Type

Exports

import {
  SectionProgress,
  type SectionProgressProps,
  type SectionProgressItemProps,
  SECTION_PROGRESS_PLACEMENTS,
  SECTION_PROGRESS_COLORS,
  useSectionProgress,
} from "@delacour/react-native-ui/section-progress";

On this page