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.
Installation
npm install lp-coachmark
# or
yarn add lp-coachmark
Peer dependencies
| Package | Minimum version |
|---|---|
react | 18.0.0 |
react-native | 0.73.0 |
expo-router | 3.0.0 |
Quick start
CoachmarkProvideruseCoachmarkStep() inside screen componentsref to attach to a View.useTabCoachmark(tabKey) to auto-start// 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>
);
}
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),
}}
>
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: next → Next, done → Done, prev → Back, skip → Skip. |
CoachmarkLabels
| Key | Default | Description |
|---|---|---|
next | Next | Label for the "advance to next step" button. |
done | Done | Label shown instead of next on the last step. |
prev | Back | Label for the "go to previous step" button. |
skip | Skip | Label 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. |
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.