react-native-nitro-spotlight
v0.1.8
Published
Walkthtought the app using nitro view wiht react native teleport
Readme
react-native-nitro-spotlight ✨
This is a vibe project — built for playful product tours, polished onboarding, and UI moments that feel alive.
A tiny native spotlight overlay for React Native. Dim the whole screen, cut a buttery hole around any view, and build product tours that feel clean instead of clunky.
Powered by Nitro Modules. Built for the New Architecture.
<Spotlight controls={spotlight} />Why it slaps
- 🎯 Highlight any React Native view by ref
- 🪄 Smooth native cutout animations
- 🧭 Built-in multi-step onboarding tours
- 👆 Touches inside the cutout pass through by default
- 🌑 Custom dim opacity, radius, padding, border width, and border color
- ⚡ Nitro-powered native view, no heavy JS overlay games
Requirements
- React Native New Architecture
react-native-nitro-modules
Installation
npm install react-native-nitro-spotlight react-native-nitro-modulesor
yarn add react-native-nitro-spotlight react-native-nitro-modulesQuick start
Create spotlight controls, attach a ref to the thing you want to highlight, then render <Spotlight /> once near the root of the screen.
import { useRef, type ComponentRef } from 'react';
import { Button, Text, View } from 'react-native';
import { Spotlight, useSpotlight } from 'react-native-nitro-spotlight';
export function Example() {
const spotlight = useSpotlight();
const cardRef = useRef<ComponentRef<typeof View>>(null);
return (
<View style={{ flex: 1, padding: 24 }}>
<View ref={cardRef} style={{ padding: 20, borderRadius: 16 }}>
<Text>Main character energy</Text>
</View>
<Button
title="Highlight card"
onPress={() => spotlight.highlight(cardRef, { durationMs: 400 })}
/>
<Button title="Clear" onPress={spotlight.clear} />
<Spotlight
controls={spotlight}
dimOpacity={0.68}
borderRadius={22}
padding={8}
borderColor="#FFFFFF"
/>
</View>
);
}That’s it. No measuring. No provider. No portal juggling. No chaos.
No provider needed
react-native-nitro-spotlight is intentionally local-first:
- call
useSpotlight()inside the screen/component that owns the spotlight - pass the returned controls to
<Spotlight controls={spotlight} /> - call
spotlight.highlight(ref)from any button or callback in that same scope
You do not need to wrap your app in a provider.
function Screen() {
const spotlight = useSpotlight();
const targetRef = useRef<ComponentRef<typeof View>>(null);
return (
<View style={{ flex: 1 }}>
<View ref={targetRef} />
<Button title="Show" onPress={() => spotlight.highlight(targetRef)} />
<Spotlight controls={spotlight} />
</View>
);
}For multi-step flows, use useSpotlightTour() instead of a provider. The tour hook keeps its own target map and exposes getTargetProps(id).
Using with react-native-teleport 🌀
You usually do not need Teleport. Spotlight mounts its native overlay for you.
Use react-native-teleport when you want the Spotlight anchor to be pre-mounted offscreen and moved into a screen only when that screen provides a host. This follows Teleport’s preloading heavy components pattern.
Important: Spotlight itself has no provider. Teleport has its own PortalProvider; that provider is only for Teleport.
Install Teleport:
npm install react-native-teleport1. Preload the Spotlight anchor offscreen
Create a small component that owns the spotlight controls and renders <Spotlight /> inside a fixed Portal hostName. When no matching PortalHost exists, Teleport keeps the portal content in place — offscreen. When a screen mounts a matching host, the native view is moved there instead of recreated.
import { createContext, useContext, type ReactNode } from 'react';
import { StyleSheet, View } from 'react-native';
import { Portal } from 'react-native-teleport';
import { Spotlight, useSpotlight, type SpotlightControls } from 'react-native-nitro-spotlight';
const SpotlightContext = createContext<SpotlightControls | null>(null);
export function useAppSpotlight() {
const spotlight = useContext(SpotlightContext);
if (!spotlight) {
throw new Error('useAppSpotlight must be used inside PreloadedSpotlight');
}
return spotlight;
}
export function PreloadedSpotlight({ children }: { children: ReactNode }) {
const spotlight = useSpotlight();
return (
<SpotlightContext.Provider value={spotlight}>
{children}
<View style={styles.offscreen}>
<Portal hostName="spotlight-overlay" style={styles.portal}>
<Spotlight
controls={spotlight}
dimOpacity={0.68}
borderRadius={22}
padding={8}
/>
</Portal>
</View>
</SpotlightContext.Provider>
);
}
const styles = StyleSheet.create({
offscreen: {
position: 'absolute',
top: -9999,
},
portal: {
width: 1,
height: 1,
},
});2. Mount it once at the app root
import { PortalProvider } from 'react-native-teleport';
import { AppNavigator } from './AppNavigator';
import { PreloadedSpotlight } from './PreloadedSpotlight';
export function App() {
return (
<PortalProvider>
<PreloadedSpotlight>
<AppNavigator />
</PreloadedSpotlight>
</PortalProvider>
);
}3. Pull it into a screen with PortalHost
import { useRef, type ComponentRef } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { PortalHost } from 'react-native-teleport';
import { useAppSpotlight } from './PreloadedSpotlight';
export function DetailsScreen() {
const spotlight = useAppSpotlight();
const actionRef = useRef<ComponentRef<typeof View>>(null);
return (
<View style={{ flex: 1, padding: 24 }}>
<View ref={actionRef}>
<Text>Primary action</Text>
</View>
<Button
title="Show me"
onPress={() => spotlight.highlight(actionRef, { durationMs: 400 })}
/>
<PortalHost name="spotlight-overlay" style={StyleSheet.absoluteFill} />
</View>
);
}How it works:
- App startup: the Spotlight anchor mounts offscreen inside
Portal. - Screen opens:
PortalHost name="spotlight-overlay"mounts and pulls the same native view on-screen. - Screen closes: the host unmounts and the Spotlight anchor returns offscreen.
- Target refs stay on the real views.
highlight(ref)usesmeasureInWindow, so it still works after teleporting.
If you do not need preloading/re-parenting behavior, render <Spotlight controls={spotlight} /> directly in the screen instead.
Product tour mode 🧭
Use useSpotlightTour() when you want a real walkthrough.
Each step has an id. Spread getTargetProps(id) on the matching view, then call tour.start().
import { useMemo } from 'react';
import { Button, Text, View } from 'react-native';
import { Spotlight, useSpotlightTour } from 'react-native-nitro-spotlight';
export function TutorialExample() {
const steps = useMemo(
() => [
{
id: 'filter',
title: 'Filter stuff',
description: 'Use this to find exactly what you need.',
},
{
id: 'item-1',
title: 'Open an item',
description: 'Tap a result to see the details.',
},
{
id: 'save',
title: 'Save it',
description: 'Keep your favorites for later.',
},
],
[]
);
const tour = useSpotlightTour({ steps });
return (
<View style={{ flex: 1, padding: 24 }}>
<View {...tour.getTargetProps('filter')}>
<Text>Filter</Text>
</View>
<View {...tour.getTargetProps('item-1')}>
<Text>Item 1</Text>
</View>
<View {...tour.getTargetProps('save')}>
<Text>Save</Text>
</View>
<Button title="Start tour" onPress={() => tour.start()} />
{tour.currentStep && (
<View style={{ marginTop: 'auto', padding: 16 }}>
<Text>{tour.currentStep.title}</Text>
<Text>{tour.currentStep.description}</Text>
<Button title="Next" onPress={tour.next} />
</View>
)}
<Spotlight
controls={tour.spotlight}
dimOpacity={0.68}
borderRadius={20}
padding={8}
borderColor="#FFFFFF"
onBackdropPress={tour.stop}
/>
</View>
);
}Jump around whenever you need:
tour.start('filter'); // start at a specific step
tour.goTo('item-1'); // jump by id
tour.goTo(2); // jump by index
tour.previous(); // go back
tour.stop(); // end the tourKeep your
stepsstable withuseMemo, and start the tour only after target views have mounted.
Touch behavior
The default behavior is usually what you want:
- touches inside the cutout pass through to your app
- touches on the dimmed backdrop are blocked
onBackdropPressfires when the backdrop is tapped
allowOverlayClick means “let the user click buttons under the dim overlay.” It does not disable onBackdropPress — the callback still fires when the backdrop is tapped.
Remove the ring:
<Spotlight controls={spotlight} borderWidth={0} />Let the dimmed backdrop pass touches through to buttons underneath:
<Spotlight
controls={spotlight}
allowOverlayClick
onBackdropPress={() => console.log('Backdrop tapped, but touch still passes through')}
/>Close on backdrop tap:
<Spotlight controls={spotlight} onBackdropPress={spotlight.clear} />API
useSpotlight()
const spotlight = useSpotlight();| Field | Type | What it does |
| --- | --- | --- |
| highlight | (viewRef, options?) => void | Measures a view ref and animates the cutout to it. |
| clear | () => void | Hides the overlay. |
| _ref | RefObject | Internal native ref. Use <Spotlight controls={spotlight} /> instead of touching this directly. |
highlight(viewRef, options?)
| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| durationMs | number | 300 | Animation duration in milliseconds. |
<Spotlight />
Render one per screen or flow.
| Prop | Type | What it does |
| --- | --- | --- |
| controls | SpotlightControls | Controls from useSpotlight() or tour.spotlight. |
| dimOpacity | number | Opacity of the dim overlay. |
| borderRadius | number | Radius of the cutout. |
| padding | number | Extra space around the highlighted view. |
| borderWidth | number | Width of the cutout ring. Use 0 to hide it. |
| borderColor | string | Ring color. Hex strings like "#FFFFFF" are supported. |
| allowOverlayClick | boolean | Lets backdrop touches pass through to views/buttons underneath. onBackdropPress still fires. |
| onBackdropPress | () => void | Called when the backdrop outside the cutout is tapped. |
| style | ViewStyle | Style for the zero-size native anchor. Usually not needed. |
| spotlightRef | RefObject<SpotlightRef \| null> | Deprecated escape hatch. Prefer controls. |
useSpotlightTour({ steps })
const tour = useSpotlightTour({ steps });| Field | Type | What it does |
| --- | --- | --- |
| spotlight | SpotlightControls | Pass this to <Spotlight controls={tour.spotlight} />. |
| steps | SpotlightTourStep[] | Your tour config. |
| currentStep | SpotlightTourStep \| null | Active step, or null when idle. |
| currentIndex | number | Active step index, or -1 when idle. |
| isActive | boolean | Whether the tour is currently active. |
| getTargetProps | (id: string) => { ref, collapsable: false } | Spread on the target view for that step. |
| start | (idOrIndex?: string \| number) => void | Start the tour. Defaults to the first step. |
| goTo | (idOrIndex: string \| number) => void | Jump to a step. |
| next | () => void | Move forward. Stops at the end. |
| previous | () => void | Move back one step. |
| stop | () => void | Clear the spotlight and end the tour. |
Step shape:
type SpotlightTourStep = {
id: string;
title?: string;
description?: string;
durationMs?: number;
};SpotlightView
Low-level native view export for custom wiring.
Most apps should use:
<Spotlight controls={spotlight} />Only reach for SpotlightView if you need direct native ref control.
import { SpotlightView } from 'react-native-nitro-spotlight';Demo
Example app
Run the example to see multiple targets, animated transitions, backdrop behavior, and tour navigation.
yarn example startTips
- Render
<Spotlight />once, near the root of the screen. - No provider is required; keep spotlight state local to the screen or flow.
- Use
collapsable={false}on custom target views if you wire refs manually. - Keep tour steps stable with
useMemo. - Avoid triggering the same highlight repeatedly during an active animation; the hook already guards against duplicate same-target calls.
React Navigation back behavior
If a tour is active and the user presses back, clear the tour before the screen is removed. With @react-navigation/native-stack, prefer usePreventRemove over a raw beforeRemove listener.
import { useNavigation, usePreventRemove } from '@react-navigation/native';
import { Spotlight, useSpotlightTour } from 'react-native-nitro-spotlight';
function TourScreen() {
const navigation = useNavigation();
const tour = useSpotlightTour({ steps });
usePreventRemove(tour.isActive, ({ data }) => {
tour.stop();
navigation.dispatch(data.action);
});
return <Spotlight controls={tour.spotlight} />;
}For native-stack screens, also disable the Android/iOS back-button history menu for that route:
<Stack.Screen
name="Tour"
component={TourScreen}
options={{ headerBackButtonMenuEnabled: false }}
/>This keeps the navigation workaround at the app/example level instead of coupling the library to React Navigation.
Agent Skills
This repo includes Agent Skills so coding agents can learn this library faster and generate better code.
Add the skill to your app project
From your app repo, install the user-facing skill:
npx skills add chanphiromsok/react-native-nitro-spotlightThen ask your agent something like:
Use react-native-nitro-spotlight to add a 3-step onboarding tour to this screen.The skill teaches agents:
- how to use
Spotlight,useSpotlight, anduseSpotlightTour - when to use
react-native-teleport - how
allowOverlayClickandonBackdropPressbehave - how to avoid duplicate animation hitches
Included skills:
react-native-nitro-spotlight— for app developers using the libraryreact-native-nitro-spotlight-maintainer— for contributors working on this repo
After the repo is public and installable, it can be discovered through the skills ecosystem / skills.sh.
License
MIT
