npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-native-headless-tour

v0.3.0

Published

Headless multi-instance tour/onboarding library for React Native

Readme

react-native-headless-tour

Multi-instance headless tour & onboarding library for React Native & Expo. Brings step coordinates and state — you bring the UI.

npm version license


Upgrading from 0.1.x

TourProvider now requires a steps: string[] prop — the ordered list of stepIds for the tour, declared upfront. TourStep no longer takes an order prop; position comes from where the stepId appears in TourProvider's steps array instead. This fixes tours that span multiple screens, where a step's target may not be mounted yet when the tour starts.


Why headless?

Most tour libraries force their own tooltips, arrows, and modals onto your app. This library does none of that. It measures your elements, tracks which step is active, and hands you the screen coordinates — you render whatever you want on top.

  • Build tooltips with Animated, Reanimated, NativeWind, or plain StyleSheet
  • Full control over design, animation, and positioning
  • No style overrides to fight

Features

  • Multi-instance — run independent tours simultaneously (e.g. "onboarding" and "checkout" on different screens)
  • Headless — zero UI rendered by the library
  • Automatic measurement — step coordinates update on layout changes, orientation, and scroll
  • Manual refresh — call refresh() to re-measure all steps on demand
  • Branching flowsgoTo() jumps to any step; branch() replaces the remaining flow at runtime
  • Layout-awareactiveStepLayoutIsReady tells you when an element is measured and on screen
  • Fully typed — generic metadata so your step data is strongly typed end-to-end
  • Expo compatible — works in Expo Go and Development Builds

Requirements

  • React ≥ 18.0.0
  • React Native ≥ 0.71.0

Installation

npm install react-native-headless-tour

No native modules — no extra pod install or build steps required.


Quick start

import {
  TourProvider,
  TourStep,
  useTour,
} from 'react-native-headless-tour';

// 1. Wrap your screen (or any ancestor) with TourProvider
export function HomeScreen() {
  return (
    <TourProvider
      tourId="onboarding"
      steps={['welcome', 'profile']}
      onStart={() => console.log('tour started')}
      onStepChange={(step) => console.log('step', step)}
      onComplete={() => console.log('tour done')}
    >
      <MyContent />
      <OnboardingTooltip />
    </TourProvider>
  );
}

// 2. Register elements as tour steps — stepId must match an entry in TourProvider's `steps` array
function MyContent() {
  return (
    <View>
      <TourStep
        tourId="onboarding"
        stepId="welcome"
        metadata={{ title: 'Welcome!', description: 'This is your home screen.' }}
      >
        <TouchableOpacity onPress={handlePress}>
          <Text>Get started</Text>
        </TouchableOpacity>
      </TourStep>

      <TourStep
        tourId="onboarding"
        stepId="profile"
        metadata={{ title: 'Your profile', description: 'Tap here to edit your info.' }}
      >
        <View>
          <Text>Profile</Text>
        </View>
      </TourStep>
    </View>
  );
}

// 3. Render your own tooltip anywhere in the tree
function OnboardingTooltip() {
  const { isActive, activeStepData, next, stop, currentStep, totalSteps } =
    useTour('onboarding');

  if (!isActive || !activeStepData?.layout) return null;

  const { x, y, width, height } = activeStepData.layout;

  return (
    <View
      style={{
        position: 'absolute',
        top: y + height + 8,
        left: x,
        backgroundColor: '#1a1a1a',
        padding: 16,
        borderRadius: 8,
        maxWidth: 280,
      }}
    >
      <Text style={{ color: '#fff', fontWeight: 'bold' }}>
        {activeStepData.metadata.title}
      </Text>
      <Text style={{ color: '#ccc', marginTop: 4 }}>
        {activeStepData.metadata.description}
      </Text>
      <Text style={{ color: '#888', marginTop: 8 }}>
        {currentStep + 1} / {totalSteps}
      </Text>
      <TouchableOpacity onPress={next}>
        <Text style={{ color: '#4f8ef7', marginTop: 12 }}>Next →</Text>
      </TouchableOpacity>
      <TouchableOpacity onPress={stop}>
        <Text style={{ color: '#888', marginTop: 8 }}>Skip</Text>
      </TouchableOpacity>
    </View>
  );
}

// 4. Start the tour
const { start } = useTour('onboarding');
<Button title="Start tour" onPress={start} />

Multiple independent tours

Each tourId is its own isolated instance. Providers, steps, and hooks with different IDs never interfere.

// Screen A
<TourProvider tourId="onboarding" steps={['welcome', 'profile']}>...</TourProvider>

// Screen B (different screen, different tour)
<TourProvider tourId="checkout" steps={['cart', 'payment']}>...</TourProvider>

// Hook reads only its own tour
const onboarding = useTour('onboarding');
const checkout = useTour('checkout');

Typed metadata

Pass a generic type to get end-to-end type safety on your step data:

interface StepMeta {
  title: string;
  description: string;
  cta?: string;
}

<TourStep<StepMeta>
  tourId="onboarding"
  stepId="welcome"
  metadata={{ title: 'Welcome', description: 'Start here.' }}
>
  <View />
</TourStep>

// activeStepData.metadata is typed as StepMeta
const { activeStepData } = useTour('onboarding');
activeStepData?.metadata.title; // string

Tooltip outside the Provider tree (Portals)

TourStep uses tourId as a direct prop — it does not need to be inside the TourProvider subtree. This means you can render your tooltip in a Portal or a root-level overlay and it will still work:

// Root layout
<View style={{ flex: 1 }}>
  <Stack />
  {/* Tooltip rendered at root level, outside any TourProvider */}
  <GlobalTooltip />
</View>

API

<TourProvider>

Registers a tour instance. Cleans up automatically on unmount.

| Prop | Type | Required | Description | |---|---|---|---| | tourId | string | ✅ | Unique identifier for this tour | | steps | string[] | ✅ | Ordered list of stepIds — the sole source of truth for tour order. Independent of which TourSteps are currently mounted, so steps on a screen the user hasn't navigated to yet are still counted. | | onStart | () => void | | Called when start() is invoked | | onStepChange | (step: number) => void | | Called on each step advance (0-based index) | | onComplete | () => void | | Called after the last step | | children | ReactNode | ✅ | |


<TourStep>

Wraps a native element, measures its position, and registers it in the tour. Renders the child unchanged — no wrapping View.

| Prop | Type | Required | Description | |---|---|---|---| | tourId | string | ✅ | Which tour this step belongs to | | stepId | string | ✅ | Must match one entry in the owning TourProvider's steps array | | metadata | TMeta | ✅ | Free-form data (title, description, etc.) | | children | ReactElement | ✅ | Exactly one native host element |

children must be a single native element that accepts a ref (View, TouchableOpacity, Pressable, etc.). Custom components must use React.forwardRef.


useTour(tourId: string): TourControls

Subscribes to a tour instance. Re-renders only when the relevant tour's state changes.

interface TourControls {
  // State
  isActive: boolean;
  currentStep: number;             // 0-based index
  totalSteps: number;
  activeStepData: TourStep | null;
  activeStepLayoutIsReady: boolean; // true when element is measured and within screen bounds
  activeStepRequiresInteraction: boolean;
  activeStepInteracted: boolean;

  // Navigation
  start: () => void;
  next: () => void;
  previous: () => void;
  stop: () => void;
  refresh: () => void;             // re-measures all registered steps
  markInteracted: () => void;

  // Branching
  goTo: (stepId: string) => void;  // jump to a step in the configured steps array
  branch: (stepIds: string[]) => void; // replace remaining steps with a new sequence
}

TourStep data shape

interface TourStep<TMeta extends Record<string, unknown>> {
  id: string;
  metadata: TMeta;
  layout: StepLayout | null;  // null until first layout measurement
}

interface StepLayout {
  x: number;      // distance from left edge of screen
  y: number;      // distance from top edge of screen
  width: number;
  height: number;
}

Coordinates are relative to the window (screen), suitable for position: 'absolute' overlays.


How measurement works

| Trigger | Action | |---|---| | TourStep mounts | Measures after InteractionManager.runAfterInteractions | | onLayout fires | Re-measures (covers scroll, resize, orientation change) | | refresh() called | Re-measures all registered steps |

layout will be null until the first layout event fires. Always guard before positioning your tooltip:

if (!activeStepData?.layout) return null;

Step ordering

The steps array passed to <TourProvider> is the sole source of truth for step order. Steps are toured through in the order they appear in this array, regardless of when TourStep components mount or unmount. This means you can have steps on screens the user hasn't visited yet, and they'll still be counted in the total and ordered correctly.


Branching flows

For tours that need to follow different paths based on user decisions, use goTo or branch.

goTo(stepId) — jump within a fixed steps array

Use when all possible steps are known upfront. Define all of them in TourProvider's steps, then jump to the right one:

const { goTo } = useTour('onboarding');

// After the user makes a choice on the current step:
if (userSelectedPro) {
  goTo('pro-feature-intro');   // must exist in TourProvider's steps array
} else {
  goTo('basic-feature-intro');
}

branch(stepIds[]) — replace the remaining flow at runtime

Use when the next steps depend on a runtime decision and you don't want to pre-declare all possible flows in TourProvider. branch discards all steps after the current one and inserts the new sequence:

const { branch, next } = useTour('onboarding');

// User completes step "choose-plan" — switch to the matching flow
if (userChosePro) {
  branch(['pro-billing', 'pro-features', 'pro-finish']);
} else {
  branch(['free-limits', 'free-finish']);
}
// next() will now follow the new sequence
next();

branch is additive from the current step: previous steps are preserved, only future ones are replaced.

When to use each

| | goTo | branch | |---|---|---| | Steps known at config time | ✅ | — | | Steps determined at runtime | — | ✅ | | Jumps backwards | ✅ | — | | Replaces remaining flow | — | ✅ |


Layout readiness

activeStepLayoutIsReady is true only when the active step's element has been measured and its coordinates fall within the visible screen bounds. Use it to avoid showing your tooltip before the element is positioned, or when the element belongs to a screen that is mounted in the navigation stack but not currently visible:

const { isActive, activeStepLayoutIsReady } = useTour('onboarding');

if (!isActive || !activeStepLayoutIsReady) return null;

This is particularly useful in stack navigators (e.g. Expo Router, React Navigation) where previous screens stay mounted — their TourStep elements remain registered with stale off-screen coordinates until the screen regains focus.


Step ordering

The steps array passed to <TourProvider> is the sole source of truth for step order. Steps are toured through in the order they appear in this array, regardless of when TourStep components mount or unmount. This means you can have steps on screens the user hasn't visited yet, and they'll still be counted in the total and ordered correctly.


License

MIT © Ismael Castillo