CtrlF
Documentation

lp-coachmark

A portable guided tour (coachmark) system for React Native and Expo Router apps. Highlights any View with a spotlight cutout and walks users through onboarding steps tab by tab.

✦ Rect & circle spotlight ✦ Auto-scroll support ✦ tapHint mode ✦ Per-tab tours ✦ Pluggable storage ✦ Zero native modules ✦ Animated transitions

Installation

npm install lp-coachmark
# or
yarn add lp-coachmark

Peer dependencies

Package Minimum version
react18.0.0
react-native0.73.0
expo-router3.0.0

Quick start

1
Wrap your root layout with CoachmarkProvider
This must be an ancestor of every screen that uses coachmarks.
2
Call useCoachmarkStep() inside screen components
Each call registers one step and returns a ref to attach to a View.
3
Add useTabCoachmark(tabKey) to auto-start
The tour starts automatically the first time the user visits the tab.
// app/_layout.tsx
import { CoachmarkProvider } from 'lp-coachmark';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';

export default function RootLayout() {
  const insets = useSafeAreaInsets();
  return (
    <CoachmarkProvider
      tabBarHeight={84}
      safeAreaTop={insets.top}
      storage={{
        get: AsyncStorage.getItem,
        set: AsyncStorage.setItem,
      }}
    >
      <Stack />
    </CoachmarkProvider>
  );
}
// app/(tabs)/home.tsx
import { View, TouchableOpacity, Text } from 'react-native';
import { useCoachmarkStep, useTabCoachmark } from 'lp-coachmark';

export default function HomeScreen() {
  useTabCoachmark('home');

  const addRef = useCoachmarkStep({
    key: 'home-add-btn',
    tabKey: 'home',
    title: 'Create your first item',
    description: 'Tap this button to get started.',
  });

  return (
    <View style={{ flex: 1 }}>
      <TouchableOpacity ref={addRef}>
        <Text>+ Add</Text>
      </TouchableOpacity>
    </View>
  );
}
💡 During development, pass alwaysShow={true} to CoachmarkProvider to bypass the "already shown" storage check and replay the tour on every launch.

Storage adapters

The storage prop accepts any object that implements get(key) and set(key, value) — both returning Promises. This makes the library compatible with any persistence layer.

AsyncStorage

import AsyncStorage from '@react-native-async-storage/async-storage';

<CoachmarkProvider
  storage={{
    get: AsyncStorage.getItem,
    set: AsyncStorage.setItem,
  }}
>

expo-sqlite (custom functions)

<CoachmarkProvider
  storage={{
    get: (key) => getSetting(db, key),
    set: (key, value) => saveSetting(db, key, value),
  }}
>
ℹ️ If storage is omitted, tour completion is not persisted — tours will re-run on every app launch.

CoachmarkProvider

Root context provider. Must wrap every screen that uses coachmarks.

import { CoachmarkProvider } from 'lp-coachmark';

<CoachmarkProvider
  tabBarHeight={84}
  safeAreaTop={insets.top}
  enabled={true}
  alwaysShow={false}
  storage={{ get, set }}
  labels={{ next: 'Next', done: 'Done', prev: 'Back', skip: 'Skip' }}
>
  {children}
</CoachmarkProvider>

Props

Prop Type Required Default Description
children ReactNode App content.
tabBarHeight number 0 Height of the tab bar including safe-area bottom inset. Blocks taps on the tab bar while the overlay is active.
safeAreaTop number 0 Safe-area top inset (insets.top from useSafeAreaInsets). Keeps the tooltip below the notch / Dynamic Island on devices that have one.
enabled boolean true Master switch. Set to false to disable all tours globally (e.g. for users who have completed onboarding).
alwaysShow boolean false Ignore the "already shown" flag in storage. Useful during development.
storage CoachmarkStorage undefined Adapter for persisting tour completion. If omitted, tours run on every app launch.
labels CoachmarkLabels undefined Override button labels. Falls back to English defaults: nextNext, doneDone, prevBack, skipSkip.

CoachmarkLabels

Key Default Description
nextNextLabel for the "advance to next step" button.
doneDoneLabel shown instead of next on the last step.
prevBackLabel for the "go to previous step" button.
skipSkipLabel for the dismiss-tour button.
// Example: Ukrainian labels
<CoachmarkProvider
  labels={{ next: 'Далі', done: 'Готово', prev: 'Назад', skip: 'Пропустити' }}
>
  {children}
</CoachmarkProvider>

useCoachmarkStep

Registers a single coachmark step and returns a ref to attach to the target View.

import { useCoachmarkStep } from 'lp-coachmark';

const ref = useCoachmarkStep({
  key: 'home-button',
  tabKey: 'home',
  title: 'Add item',
  description: 'Tap to create a new item.',
});

return <View ref={ref}>...</View>;

Options

Option Type Required Default Description
key string Unique identifier for this step. Steps are displayed in the order they were registered (i.e., hook call order).
tabKey string The tab this step belongs to. Must match the value passed to useTabCoachmark and start().
title string Heading text shown in the tooltip.
description string Body text shown in the tooltip.
shape 'rect' | 'circle' 'rect' Shape of the spotlight cutout. Use 'circle' for icon buttons or avatar-like elements.
padding number 8 Extra space (px) around the target element inside the spotlight.
scrollRef RefObject<ScrollView> Ref of the ScrollView that contains this element. When provided, the overlay auto-scrolls to make the element visible.
scrollOffset number center Custom scroll offset relative to the element. Defaults to centering the element on screen.
clampToScreen boolean false Clamp the spotlight to screen boundaries on the X axis. Use when the target element is wider than the screen.
tapHint boolean false Enables "do it yourself" mode: the overlay shows the spotlight mask and a pulsing hand icon instead of a tooltip. Call resumeAfterTap() inside onTap to advance to the next step.
onTap () => void Called when the user taps the spotlight zone in tapHint mode. Perform the action here, then call resumeAfterTap().
hidePrev boolean false Hide the "Back" button on this step. Useful for the first step or steps that follow a tapHint step.
enabled boolean true When false, the step is not registered. Use to conditionally include steps based on feature flags or user roles.

useTabCoachmark

Automatically starts the tour when the screen tab gains focus. Checks storage to ensure the tour runs only once per user.

import { useTabCoachmark } from 'lp-coachmark';

export default function HomeScreen() {
  useTabCoachmark('home');
  // ...
}

Parameters

Parameter Type Required Description
tabKey string The tab identifier. Must match the tabKey used in useCoachmarkStep calls on this screen.
Timing: first visit — 100 ms delay. Subsequent visits in the same session — 1 200 ms delay (gives navigation animation time to complete before the overlay appears).

useCoachmark

Returns the full context value. Use when you need to start a tour manually, call resumeAfterTap, or hook into the finish lifecycle.

import { useCoachmark } from 'lp-coachmark';

const { start, resumeAfterTap, afterFinish, isActive } = useCoachmark();

Returned values

Value Type Description
start (tabKey, fromIndex?, onFinish?) => void Start the tour for the given tab. fromIndex defaults to 0. onFinish is called when the user completes or skips the tour.
resumeAfterTap () => void Call after the user performs the action in a tapHint step. The overlay advances to the next step. Safe to call at any time — ignored if no tour is active.
afterFinish (cb: () => void) => void Register a one-shot callback fired immediately after the overlay disappears. Use to open a modal or navigate after the tour ends.
isActive boolean true while a tour is in progress.
registerStep (key, step) => void Low-level: register a step manually. Prefer useCoachmarkStep.
unregisterStep (key) => void Low-level: unregister a step. Called automatically by useCoachmarkStep on unmount.

Types

interface CoachmarkStorage {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string) => Promise<void>;
}

interface CoachmarkStep {
  targetRef: RefObject<View | null>;
  title: string;
  description: string;
  tabKey: string;
  shape?: 'rect' | 'circle';
  padding?: number;
  scrollRef?: RefObject<ScrollView | null>;
  scrollOffset?: number;
  clampToScreen?: boolean;
  tapHint?: boolean;
  onTap?: () => void;
  hidePrev?: boolean;
}

interface CoachmarkMeasure {
  x: number;
  y: number;
  width: number;
  height: number;
}

Basic step

The simplest possible coachmark: one highlighted button, auto-started when the tab gains focus.

export default function HomeScreen() {
  useTabCoachmark('home');

  const addRef = useCoachmarkStep({
    key: 'home-add',
    tabKey: 'home',
    title: 'Create your first item',
    description: 'Tap the button below to get started.',
  });

  return (
    <View style={{ flex: 1 }}>
      <TouchableOpacity ref={addRef}>
        <Text>+ Add</Text>
      </TouchableOpacity>
    </View>
  );
}

Multiple steps in order

Steps are displayed in the order the hooks run in the component — top to bottom.

export default function DashboardScreen() {
  useTabCoachmark('dashboard');

  const searchRef = useCoachmarkStep({
    key: 'dashboard-search', // step 1
    tabKey: 'dashboard',
    title: 'Search',
    description: 'Find anything quickly using the search bar.',
  });

  const chartRef = useCoachmarkStep({
    key: 'dashboard-chart',  // step 2
    tabKey: 'dashboard',
    title: 'Your statistics',
    description: 'Here you can see your progress over time.',
    padding: 12,
  });

  const profileRef = useCoachmarkStep({
    key: 'dashboard-profile', // step 3
    tabKey: 'dashboard',
    title: 'Profile settings',
    description: 'Tap your avatar to open account settings.',
    shape: 'circle',
  });

  return (
    <View>
      <View ref={searchRef}><SearchBar /></View>
      <View ref={chartRef}><Chart /></View>
      <View ref={profileRef}><Avatar /></View>
    </View>
  );
}

Circle spotlight

Use shape: 'circle' for round buttons, icons, or avatar-like elements.

const micRef = useCoachmarkStep({
  key: 'record-mic',
  tabKey: 'record',
  title: 'Record audio',
  description: 'Hold this button to start recording.',
  shape: 'circle',
  padding: 16,
});

return <TouchableOpacity ref={micRef}><MicIcon /></TouchableOpacity>;

Auto-scroll to a step

If the target element is inside a ScrollView and might be off-screen, pass scrollRef so the overlay scrolls to it automatically before showing the tooltip.

export default function ListScreen() {
  useTabCoachmark('list');
  const scrollRef = useRef<ScrollView>(null);

  const lastItemRef = useCoachmarkStep({
    key: 'list-last-item',
    tabKey: 'list',
    title: 'Swipe to delete',
    description: 'Swipe any row to the left to reveal delete.',
    scrollRef,       // overlay will scroll this ScrollView
    scrollOffset: 40 // extra offset in px (optional)
  });

  return (
    <ScrollView ref={scrollRef}>
      {items.map((item) => <Row key={item.id} />)}
      <View ref={lastItemRef}>
        <LastRow />
      </View>
    </ScrollView>
  );
}

tapHint mode — "do it yourself" step

In tapHint mode the tooltip is hidden and a pulsing hand icon appears over the spotlight. The user must tap the highlighted element. Call resumeAfterTap() inside onTap to advance the tour.

export default function OnboardingScreen() {
  useTabCoachmark('onboarding');
  const { resumeAfterTap } = useCoachmark();

  const swipeCardRef = useCoachmarkStep({
    key: 'onboarding-swipe',
    tabKey: 'onboarding',
    title: '',       // not shown in tapHint mode
    description: '',
    tapHint: true,
    onTap: () => {
      performSwipeAnimation(); // trigger the real action
      resumeAfterTap();        // advance to next step
    },
  });

  const nextRef = useCoachmarkStep({
    key: 'onboarding-done',
    tabKey: 'onboarding',
    title: 'Great!',
    description: 'You just learned how to swipe a card.',
    hidePrev: true, // can't go back after the swipe
  });

  return (
    <View>
      <View ref={swipeCardRef}><Card /></View>
      <View ref={nextRef}><ResultView /></View>
    </View>
  );
}

Conditional step

Use the enabled option to include a step only for certain users — based on role, feature flag, or subscription tier.

const { isPremium } = useUser();

const premiumRef = useCoachmarkStep({
  key: 'home-premium-badge',
  tabKey: 'home',
  title: 'Upgrade to Premium',
  description: 'Unlock all features with a Premium subscription.',
  enabled: !isPremium, // only show for free-tier users
});

Manual trigger

Start the tour from any interaction — a help button, a menu item, or a settings toggle.

import { useCoachmark } from 'lp-coachmark';

function HelpButton() {
  const { start } = useCoachmark();

  return (
    <TouchableOpacity onPress={() => start('home', 0)}>
      <Text>Replay tour</Text>
    </TouchableOpacity>
  );
}

Action after the tour ends

Use afterFinish to open a modal, navigate, or trigger any side-effect right after the overlay disappears.

import { useCoachmark } from 'lp-coachmark';

function HomeScreen() {
  const { afterFinish } = useCoachmark();

  useEffect(() => {
    afterFinish(() => {
      router.push('/create'); // open create screen once tour is done
    });
  }, []);
}
ℹ️ afterFinish registers a one-shot callback — it fires once and is then cleared automatically.