Interactive Walkthrough
Aug 2026
- React Native
- TypeScript
- Reanimated
- Docusaurus
- GitHub Actions
- npm
Why fork a walkthrough library?
react-native-interactive-walkthrough is a small open-source library for building onboarding tours in React Native. You highlight a view, dim the rest of the screen, and render a tooltip or callout next to it without wrapping screens or drilling props through the component tree.
The original project had been dormant since February 2022. The last upstream release was built around React 16, React Native 0.63, TypeScript 4.3, and the old LayoutAnimation API. Its entire implementation lived in one large src/index.tsx, the public props were largely any, and there was no runnable example, no documentation site, and no CI.
On a modern Expo or React Native app, the library meant fighting old peer dependencies, global layout-animation behaviour, and a mask that was difficult to customise. I forked it because I liked its developer experience and wanted a version I could use and maintain on today’s stack.
The sprint goal was to bring the library up to date, so another developer could install it and understand it. The sprint lasted ten days and ended at 2.0.0, with a documentation site and an example app that exercises every feature. It is published on npm as rn-interactive-walkthrough.
The mental model
The library has four main pieces:
WalkthroughProviderwraps the app, owns the registered steps, and renders the overlay above the app.useWalkthroughStepregisters one step and measures the view it should highlight.WalkthroughStepis the component form of the hook: it wraps its children in aViewand measures that wrapper automatically.- The displayer draws the dimmed backdrop as four rectangles around the measured target, then renders the step’s
contentComponenton top.
A step is data: a number, an identifier, a measured rectangle, optional layout adjustments, lifecycle callbacks, and the component to show. The content component is a regular React component, so it can render a tooltip, callout, bottom sheet, centered card, or full-screen onboarding screen.
What changed
1. Replacing LayoutAnimation with Reanimated
The old mask relied on React Native’s global LayoutAnimation, including the Android opt-in through UIManager.setLayoutAnimationEnabledExperimental. That API is global, hard to compose with other animations, and has no real notion of a shared animated value. It also conflicts with apps that already use Reanimated.
The rewrite moves the mask coordinates into a Reanimated shared value and animates between steps with withTiming:
const rounded = useSharedValue<WalkthroughMaskCoordinates>(toCoordinates(mask));
useEffect(() => {
rounded.value = withTiming(toCoordinates(mask), {
duration,
easing,
});
}, [rounded, mask, easing, duration]);
Because the animation lives on the UI thread, the four backdrop regions and the interaction cover all read the same coordinates. There is no intermediate React render for every frame, and no global animation configuration to enable.
The mask itself is still built from four rectangles. React Native has no simple “cut a hole in a view” primitive, so the backdrop is tiled around the target:
// One region of the dim. Regions share the exact same animated coordinates,
// so the hole stays gap-free without overlapping views.
const DimRect = ({ kind, mask, pulseScale, color, onPress }: DimRectProps) => {
const animatedStyle = useAnimatedStyle(() => {
const m = scaleMask(mask.value, pulseScale.value);
switch (kind) {
case "top":
return { top: 0, left: 0, right: 0, height: m.y };
case "left":
return { top: m.y, left: 0, width: m.x, height: m.height };
case "right":
return { top: m.y, left: m.x + m.width, right: 0, height: m.height };
case "bottom":
return { top: m.y + m.height, left: 0, right: 0, bottom: 0 };
}
}, [kind]);
// ...
};
The rewrite fixed two visual issues:
- No overlapping dim regions. The left and right regions used to run all the way to the bottom of the screen, which double-painted the backdrop and produced visible seams. Constraining them to the mask height fixed the visual glitch and removed the overlapping hit areas.
- Full-screen steps cross-fade. A full-screen step is rendered as a single backdrop instead of animating a zero-sized hole off the screen, so entering and leaving a welcome or finale screen fades cleanly.
I also removed support for multiple masks in a single step. I do not think highlighting two targets at once makes for good UX, and dropping it removed a lot of complexity from the displayer. The single-mask model covers the onboarding flows I needed.
2. Turning the navigation into an actual state machine
The old next and previous functions were arithmetic:
next: () => setCurrentStepNumber((x) => (x ?? 0) + 1);
previous: () => setCurrentStepNumber((x) => (x === 0 || x === undefined ? 0 : x - 1));
That only works if steps are numbered 1, 2, 3.... It also let next() run before a walkthrough had started, and it drifted to undefined step numbers at the end.
The new provider treats the walkthrough as a small state machine. Steps are sorted by their number, and navigation moves through the registered list, not through arithmetic:
const next = useCallback(() => {
setCurrentStepNumber((current) => {
if (current === undefined) {
return current;
}
const index = steps.findIndex((step) => step.number === current);
if (index === -1 || index >= steps.length - 1) {
return undefined;
}
return steps[index + 1].number;
});
}, [steps]);
The change also fixed several navigation bugs and enabled:
- Non-contiguous step numbers now work. A tour can use
1, 3, 7without breaking. nextandpreviousare no-ops while inactive. Onlystart()can leave the inactive state, so a content component can callctx.next()without checking whether the tour is running.start(stepNumber?)can begin on a specific step, which is useful when a tour is resumed from a notification or help menu.isFirstStepandisLastStepare derived from the sorted list rather than assuming step numbers equal indexes.stop()works from either state and simply clears the active step.
3. Type safety without prop drilling
The old API used any for overlay props and spread the whole context into every content component. It worked, but it gave no autocomplete and no protection when a step’s props changed.
The new API is generic over the content component:
interface TooltipProps extends ContentComponentProps {
title: string;
text: string;
}
const Tooltip = ({ title, text, ctx }: TooltipProps) => {
return (
<View>
<Text>{title}</Text>
<Text>{text}</Text>
<Button title="Next" onPress={ctx.next} />
</View>
);
};
const { onLayout } = useWalkthroughStep<TooltipProps>({
number: 2,
contentComponent: Tooltip,
contentComponentProps: { title: "Nearby users", text: "..." },
});
WalkthroughStepProps<P> and the provider’s contentComponent use the same generic, so contentComponentProps is checked against the component that will actually render. The provider can define a default tooltip for every step while individual steps override it without losing their own prop types.
I also cleaned up the public names. The old IWalkthroughStep, IOverlayComponentProps, overlayComponent, and overlayComponentProps became WalkthroughStepType, ContentComponentProps, contentComponent, and contentComponentProps. The entry point now exports only the components, hooks, and types that are meant to be public, instead of leaking internal helpers.
4. Measurement that survives real screens
The hook measures the target with measureInWindow and re-registers the step whenever the rectangle changes. Several bugs had to be fixed to make that reliable:
- The stale closure in
measureMask. The callback could capture an oldstepobject and compare new coordinates against stale ones. Keeping the latest step in a ref fixed the comparison. - Steps left behind on unmount. A screen could register a step and then disappear, leaving a ghost entry in the provider. The hook now calls
unregisterStepon unmount. - Provider-level defaults.
layoutAdjustmentsandmaskAllowInteractioncan now be set once on the provider and overridden per step, which removes repeated configuration from every call site. layoutLockfor scrollable screens. The docs explain the pattern: lock the layout measurement, then callcurrentStep.measureMask()fromonScrolland the scroll-end events so the mask follows a target inside aScrollView.
The common case is now a one-liner:
<WalkthroughStep
number={1}
contentComponent={Tooltip}
contentComponentProps={{ title: "Start here" }}
style={styles.card}
>
<Text>This entire card is highlighted.</Text>
</WalkthroughStep>
I added WalkthroughStep late in the sprint because the hook required every caller to wire onLayout manually. It wraps its children in a View and measures that wrapper. The hook is still available when you need custom onLayout handling or want to measure an existing view.
5. Splitting the source tree
The old file mixed the context, provider, displayer, mask geometry, measurement hook, keyboard handling, Android back handling, and animation setup. Every change required understanding the whole file, and the public API was impossible to type properly.
The new src/ tree separates responsibilities:
src/
index.tsx public exports
types.ts the complete public type surface
context.ts context + useWalkthrough
utils.ts animation defaults, merging, press helpers
components/
WalkthroughProvider.tsx state, registration, navigation
WalkthroughDisplayer.tsx lifecycle callbacks + rendering
ViewMask.tsx animated backdrop and mask
WalkthroughStep.tsx component wrapper for measurement
hooks/
useWalkthroughStep.ts registration + measurement
useContentPlacement.ts safe-area-aware tooltip placement
Each module now has its own file, and src/index.tsx only exports the public API.
6. Tooltip placement and the pulsing mask
The example app exposed two missing pieces.
useContentPlacement solves the “tooltip goes off screen” problem. It measures the content, decides whether it fits below the mask, flips it above when it doesn’t, and clamps the final position to the safe area:
const safeTop = insets.top + margin;
const safeBottom = frameHeight - insets.bottom - margin;
const maskBottom = mask.y + mask.height;
const fitsBelow = maskBottom + gap + height <= safeBottom;
const side = fitsBelow ? "below" : "above";
const rawTop = fitsBelow ? maskBottom + gap : mask.y - gap - height;
const top = clamp(rawTop, safeTop, safeBottom - height);
The hook returns ready: false until the content has been measured, so a tooltip can stay invisible for one frame instead of flashing at a provisional position.
The pulsing mask is optional. Once a step has finished morphing into place, the highlighted area scales up and down around its center:
pulseScale.value = withDelay(
duration + effectivePulse.delay,
withRepeat(
withTiming(effectivePulse.scale, {
duration: effectivePulse.duration,
easing: effectivePulse.easing,
}),
-1,
true,
),
);
The delay starts after the mask transition, so the pulse does not overlap the morph between steps. It restarts on every step change, including when navigating backwards, and every option can be overridden per step.
Example app and docs
Expo example app
The example/ app is a small Expo app, which is the setup I already knew and could get running quickly. It is a gallery of small demos:
- smart tooltips that flip above or below the target
- interactive targets that stay usable while highlighted
- tap-to-advance tours using the mask and backdrop as buttons
- full-screen welcome and finale overlays
- callouts, bottom sheets, centered cards, and themed tooltips
- a pulsing mask with per-step timing
Each demo is a standalone screen, so the example can be used as a reference.
The repo keeps the library at the root and the example in example/, so Metro has to resolve rn-interactive-walkthrough to the local package in the parent directory. The config watches the repo root, excludes duplicate copies of peer dependencies, and maps the package name to the root. This is adapted from the setup used by react-native-keyboard-controller’s example, with Expo’s Metro config instead of the React Native CLI one.
A multi-package repo with workspaces could have made that resolution easier, but it would also have added tooling and structure for a single library and one example. Keeping the library at the top level was simpler.
Documentation site
The docs grew from generated TypeDoc markdown in the repository into a Docusaurus 3 site deployed on GitHub Pages. It keeps the TypeDoc output as the API reference and adds four guides (getting started, usage, overlays, and advanced topics) plus local search.
The guides are written around the questions I actually had while rebuilding the examples: how to start a tour, how to type a content component, how to position a tooltip, how to handle scrollable targets, how to tune the mask, and how to avoid the common measurement mistakes.
A CI script rebuilds TypeDoc and fails if the committed output differs, which keeps the API reference in sync with the source.
CI and releases
The GitHub Actions workflow runs lint, typechecking for the library, example, and docs, plus the generated-docs check on every push and pull request. Releases use Conventional Commits and release-it, which produced the changelog and the version bumps from 0.2.0 to 2.0.0.
Reflection
At the time of this sprint, I had about a year and a half of TypeScript experience, and this project is the clearest example of what I could do with it and with the React Native ecosystem. It brought together generic component APIs, Reanimated worklets and shared values, package publishing, generated documentation, and CI.
It was also my first public npm package. Seeing it install from npm, with docs, an example app, releases, and CI behind it, made the whole process feel real. Using TypeScript generics throughout was the part I enjoyed most. Replacing the old any-heavy API with a properly typed one took time, but seeing the compiler catch mistakes across the provider, hooks, and content components was very satisfying.