onboarding
Guided Lens
A measured onboarding lens follows real targets, retries virtualized content, and positions guidance around safe screen geometry.
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @shopify/react-native-skia react-native-reanimated react-native-workletsanatomy.tsx<GuidedLens> <GuidedLens.Step /> <GuidedLens.Target /></GuidedLens>Behavior
- The active target is measured with
measureInWindow. - The Skia cutout and coachmark animate with the same timing.
- The coachmark chooses space above or below the target and remains inside horizontal screen insets.
- Screen-size changes trigger a new measurement.
- Missing targets are retried for virtualized or delayed content.
skippable={false}removes Skip and prevents system dismissal.
Install tailwind variants
pnpm add tailwind-variantsUsage
GuidedLensExample.tsximport { Pressable, Text, View } from "react-native";import { GuidedLens } from "@animations/ui/components/onboarding/guided-lens/uniwind";const steps = [ <GuidedLens.Step id="search-step" key="search-step" targetId="search"> <View className="gap-1"> <Text className="text-xl font-black text-zinc-950"> Find quickly </Text> <Text className="text-sm text-zinc-600"> Search every saved component. </Text> </View> </GuidedLens.Step>, <GuidedLens.Step id="save-step" key="save-step" targetId="save"> <View className="gap-1"> <Text className="text-xl font-black text-zinc-950"> Save favorites </Text> <Text className="text-sm text-zinc-600"> Keep useful components close. </Text> </View> </GuidedLens.Step>,];export function GuidedLensExample() { return ( <GuidedLens classNames={{ card: "rounded-[28px]", next: "bg-violet-600", }} defaultVisible skippable={false} > {steps} <GuidedLens.Target id="search"> <Pressable className="rounded-2xl bg-neutral-100 p-4"> <Text>Search components</Text> </Pressable> </GuidedLens.Target> <GuidedLens.Target id="save"> <Pressable className="rounded-2xl bg-neutral-100 p-4"> <Text>Save component</Text> </Pressable> </GuidedLens.Target> </GuidedLens> );}Controlled tour
ControlledTourExample.tsximport { useState } from "react";import { Pressable, Text } from "react-native";import { GuidedLens } from "@animations/ui/components/onboarding/guided-lens/uniwind";export function ControlledTourExample() { const [stepIndex, setStepIndex] = useState(0); const [visible, setVisible] = useState(false); return ( <GuidedLens onFinish={() => setVisible(false)} onSkip={() => setVisible(false)} onStepIndexChange={setStepIndex} stepIndex={stepIndex} visible={visible} > <GuidedLens.Step id="search-step" targetId="search"> <Text>Use search to find saved components.</Text> </GuidedLens.Step> <GuidedLens.Target id="search"> <Pressable onPress={() => setVisible(true)}> <Text>Search components</Text> </Pressable> </GuidedLens.Target> </GuidedLens> );}ScrollView and FlatList targets
Use prepareTarget to scroll before the component measures the target:
ScrollViewAndFlatListTargetsExample.tsximport { useRef } from "react";import { FlatList, Text } from "react-native";import { GuidedLens } from "@animations/ui/components/onboarding/guided-lens/uniwind";const items = [ { id: "inbox", label: "Inbox" }, { id: "saved", label: "Saved components" },];export function ScrollViewAndFlatListTargetsExample() { const listRef = useRef<FlatList<(typeof items)[number]>>(null); return ( <GuidedLens prepareTarget={async (targetId) => { const index = items.findIndex((item) => `item-${item.id}` === targetId); listRef.current?.scrollToIndex({ animated: true, index }); await new Promise((resolve) => setTimeout(resolve, 300)); }} > <GuidedLens.Step id="saved-step" targetId="item-saved"> <Text>Your saved components live here.</Text> </GuidedLens.Step> <FlatList data={items} keyExtractor={(item) => item.id} ref={listRef} renderItem={({ item }) => ( <GuidedLens.Target id={`item-${item.id}`}> <Text>{item.label}</Text> </GuidedLens.Target> )} /> </GuidedLens> );}If a virtualized target is not mounted yet, Guided Lens calls
onTargetMissing and retries measurement. The callback can call
scrollToIndex, expand a collapsed section, or load the required route.
ScrollViewAndFlatListTargetsExample.tsximport { useRef } from "react";import { FlatList, Text } from "react-native";import { GuidedLens } from "@animations/ui/components/onboarding/guided-lens/uniwind";const items = [ { id: "inbox", label: "Inbox" }, { id: "saved", label: "Saved components" },];export function ScrollViewAndFlatListTargetsExample() { const listRef = useRef<FlatList<(typeof items)[number]>>(null); const indexByTargetId = { "item-inbox": 0, "item-saved": 1 }; return ( <GuidedLens onTargetMissing={(targetId) => { listRef.current?.scrollToIndex({ animated: true, index: indexByTargetId[targetId], }); }} > <GuidedLens.Step id="saved-step" targetId="item-saved"> <Text>Your saved components live here.</Text> </GuidedLens.Step> <FlatList data={items} keyExtractor={(item) => item.id} ref={listRef} renderItem={({ item }) => ( <GuidedLens.Target id={`item-${item.id}`}> <Text>{item.label}</Text> </GuidedLens.Target> )} /> </GuidedLens> );}Step contract
StepContractExample.tsximport { Text, View } from "react-native";import { GuidedLens } from "@animations/ui/components/onboarding/guided-lens/uniwind";export function StepContractExample() { return ( <GuidedLens.Step id="search-step" targetId="search"> <View> <Text>Search your library</Text> <Text>Find saved components by name or category.</Text> </View> </GuidedLens.Step> );}The step's children own its complete presentation. They can contain headings, descriptions, illustrations, progress details, or contextual controls without forcing every tour into a title-and-description template.
guided-lens/card.tsximport type { ReactNode } from "react";import type { StyleProp, ViewStyle } from "react-native";import Animated from "react-native-reanimated";import type { GuidedLensRect } from "./use-guided-lens";import { useGuidedLensCardStyle } from "./use-guided-lens-card-style";export function GuidedLensCard({ children, className, screenHeight, screenWidth, style, target,}: { children: ReactNode; className?: string; screenHeight: number; screenWidth: number; style?: StyleProp<ViewStyle>; target: GuidedLensRect;}) { const animatedStyle = useGuidedLensCardStyle( target, screenHeight, screenWidth, ); return ( <Animated.View className={className} style={[style, animatedStyle]} > {children} </Animated.View> );}guided-lens/config.tsexport const GUIDED_LENS_CONFIG = { cardEstimatedHeight: 190, cardGap: 18, cardWidth: 320, screenInset: 16, transitionDuration: 320,} as const;guided-lens/context.tsximport { createContext, forwardRef, useContext, useEffect, useRef,} from "react";import { View } from "react-native";import type { GuidedLensTargetProps } from "./types";type TargetRegistry = Map<string, View | null>;export const GuidedLensContext = createContext<TargetRegistry | null>(null);export const GuidedLensTarget = forwardRef< View, GuidedLensTargetProps>(function GuidedLensTarget({ id, ...props }, forwardedRef) { const registry = useContext(GuidedLensContext); const localRef = useRef<View>(null); useEffect(() => { registry?.set(id, localRef.current); return () => { registry?.delete(id); }; }, [id, registry]); return ( <View {...props} collapsable={false} ref={(node) => { localRef.current = node; if (typeof forwardedRef === "function") { forwardedRef(node); } else if (forwardedRef) { forwardedRef.current = node; } }} /> );});guided-lens/mask.tsximport { Canvas, DiffRect, rect, rrect,} from "@shopify/react-native-skia";import { useEffect } from "react";import { StyleSheet } from "react-native";import { useDerivedValue, useReducedMotion, useSharedValue, withTiming,} from "react-native-reanimated";import type { GuidedLensRect } from "./use-guided-lens";import { GUIDED_LENS_CONFIG } from "./config";export function GuidedLensMask({ color, height, target, width,}: { color: string; height: number; target: GuidedLensRect; width: number;}) { const padding = 8; const reducedMotion = useReducedMotion(); const x = useSharedValue(target.x); const y = useSharedValue(target.y); const targetWidth = useSharedValue(target.width); const targetHeight = useSharedValue(target.height); useEffect(() => { const duration = reducedMotion ? 0 : GUIDED_LENS_CONFIG.transitionDuration; x.set(withTiming(target.x, { duration })); y.set(withTiming(target.y, { duration })); targetWidth.set(withTiming(target.width, { duration })); targetHeight.set(withTiming(target.height, { duration })); }, [ reducedMotion, target.height, target.width, target.x, target.y, targetHeight, targetWidth, x, y, ]); const inner = useDerivedValue(() => rrect( rect( x.get() - padding, y.get() - padding, targetWidth.get() + padding * 2, targetHeight.get() + padding * 2, ), 18, 18, ), ); return ( <Canvas pointerEvents="none" style={StyleSheet.absoluteFill}> <DiffRect color={color} inner={inner} outer={rrect(rect(0, 0, width, height), 0, 0)} /> </Canvas> );}guided-lens/types.tsimport type { ReactNode } from "react";import type { ViewProps } from "react-native";export type GuidedLensStep = { children: ReactNode; id: string; targetId: string;};export type GuidedLensStepProps = GuidedLensStep;export type GuidedLensTargetProps = ViewProps & { children: ReactNode; id: string;};export type GuidedLensBaseProps = { accentColor?: string; children: ReactNode; defaultStepIndex?: number; defaultVisible?: boolean; onFinish?: () => void; onSkip?: () => void; onStepIndexChange?: (index: number) => void; onTargetMissing?: (targetId: string) => void; overlayColor?: string; prepareTarget?: (targetId: string) => Promise<void> | void; skippable?: boolean; stepIndex?: number; visible?: boolean;};guided-lens/uniwind/index.tsximport { Children, isValidElement,} from "react";import { Modal, Pressable, Text, View,} from "react-native";import type { SlotsToClasses } from "#shared/types/slots";import { GuidedLensCard } from "../card";import { GuidedLensContext, GuidedLensTarget as GuidedLensTargetPrimitive,} from "../context";import { GuidedLensMask } from "../mask";import type { GuidedLensBaseProps, GuidedLensStep as GuidedLensStepData, GuidedLensStepProps, GuidedLensTargetProps,} from "../types";import { useGuidedLens } from "../use-guided-lens";import { type GuidedLensSlot, guidedLensVariants,} from "./variants";export type GuidedLensProps = GuidedLensBaseProps & { classNames?: SlotsToClasses<GuidedLensSlot>;};/** Registers a measurable native target by id. */function GuidedLensTarget(props: GuidedLensTargetProps) { return <GuidedLensTargetPrimitive {...props} />;}/** Declares one ordered coachmark and connects it to a target. */function GuidedLensStep({ children,}: GuidedLensStepProps) { return children;}/** Provides tour state, overlay, and active target measurement. */function GuidedLensRoot({ accentColor = "#6f5cff", children, classNames, defaultStepIndex, defaultVisible, onFinish, onSkip, onStepIndexChange, onTargetMissing, overlayColor = "rgba(10,10,8,0.72)", prepareTarget, skippable = true, stepIndex, visible,}: GuidedLensProps) { const childNodes = Children.toArray(children); const steps = childNodes.flatMap<GuidedLensStepData>( (child) => isValidElement<GuidedLensStepProps>(child) && child.type === GuidedLensStep ? [child.props] : [], ); const content = childNodes.filter( (child) => !isValidElement(child) || child.type !== GuidedLensStep, ); const state = useGuidedLens({ defaultStepIndex, defaultVisible, onFinish, onSkip, onStepIndexChange, onTargetMissing, prepareTarget, stepIndex, steps, visible, }); const { actions, card, next, nextLabel, previousLabel, progress, } = guidedLensVariants(); return ( <GuidedLensContext.Provider value={state.registry}> {content} <Modal animationType="fade" onRequestClose={skippable ? state.skip : undefined} transparent visible={ state.resolvedVisible && Boolean(state.step && state.targetRect) } > {state.step && state.targetRect && ( <View className="flex-1"> <GuidedLensMask color={overlayColor} height={state.dimensions.height} target={state.targetRect} width={state.dimensions.width} /> <GuidedLensCard className={card({ className: classNames?.card })} screenHeight={state.dimensions.height} screenWidth={state.dimensions.width} target={state.targetRect} > <Text className={progress({ className: classNames?.progress, })} > {state.resolvedIndex + 1} / {steps.length} </Text> {state.step.children} <View className={actions({ className: [ state.resolvedIndex === 0 && !skippable && "justify-end", classNames?.actions, ], })} > {(state.resolvedIndex > 0 || skippable) && ( <Pressable className="py-2.5" onPress={ state.resolvedIndex === 0 ? state.skip : state.previous } > <Text className={previousLabel({ className: classNames?.previousLabel, })} > {state.resolvedIndex === 0 ? "Skip" : "Back"} </Text> </Pressable> )} <Pressable className={next({ className: classNames?.next })} onPress={state.next} style={{ backgroundColor: accentColor }}> <Text className={nextLabel({ className: classNames?.nextLabel })}>{state.resolvedIndex === steps.length - 1 ? "Finish" : "Next"}</Text> </Pressable> </View> </GuidedLensCard> </View> )} </Modal> </GuidedLensContext.Provider> );}export const GuidedLens = Object.assign(GuidedLensRoot, { Step: GuidedLensStep, Target: GuidedLensTarget,});guided-lens/uniwind/variants.tsimport { tv } from "tailwind-variants";export const guidedLensSlots = { actions: "flex-row items-center justify-between gap-2.5", card: "absolute w-[82%] max-w-[320px] gap-3 rounded-3xl bg-white p-4.5", next: "min-w-21.5 items-center rounded-[14px] px-4 py-2.75", nextLabel: "text-[13px] font-black text-white", previousLabel: "text-xs font-extrabold text-[#77776f]", progress: "text-[10px] font-black tracking-[1.2px] text-[#77776f]",} as const;export type GuidedLensSlot = keyof typeof guidedLensSlots;export const guidedLensVariants = tv({ slots: guidedLensSlots });guided-lens/use-guided-lens-card-style.tsimport { useEffect } from "react";import { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming,} from "react-native-reanimated";import { GUIDED_LENS_CONFIG } from "./config";import type { GuidedLensRect } from "./use-guided-lens";function getCardPosition( target: GuidedLensRect, screenHeight: number, screenWidth: number,) { const spaceBelow = screenHeight - (target.y + target.height); const top = spaceBelow >= GUIDED_LENS_CONFIG.cardEstimatedHeight + GUIDED_LENS_CONFIG.cardGap ? target.y + target.height + GUIDED_LENS_CONFIG.cardGap : Math.max( GUIDED_LENS_CONFIG.screenInset, target.y - GUIDED_LENS_CONFIG.cardEstimatedHeight - GUIDED_LENS_CONFIG.cardGap, ); const left = Math.max( GUIDED_LENS_CONFIG.screenInset, Math.min( screenWidth - GUIDED_LENS_CONFIG.cardWidth - GUIDED_LENS_CONFIG.screenInset, target.x + target.width / 2 - GUIDED_LENS_CONFIG.cardWidth / 2, ), ); return { left, top };}export function useGuidedLensCardStyle( target: GuidedLensRect, screenHeight: number, screenWidth: number,) { const position = getCardPosition( target, screenHeight, screenWidth, ); const reducedMotion = useReducedMotion(); const translateX = useSharedValue(position.left); const translateY = useSharedValue(position.top); useEffect(() => { const duration = reducedMotion ? 0 : GUIDED_LENS_CONFIG.transitionDuration; translateX.set( withTiming(position.left, { duration }), ); translateY.set( withTiming(position.top, { duration }), ); }, [ position.left, position.top, reducedMotion, translateX, translateY, ]); return useAnimatedStyle(() => ({ transform: [ { translateX: translateX.get() }, { translateY: translateY.get() }, ], }));}guided-lens/use-guided-lens.tsimport { useEffect, useRef, useState } from "react";import { useWindowDimensions, type View,} from "react-native";import type { GuidedLensBaseProps, GuidedLensStep,} from "./types";export type GuidedLensRect = { height: number; width: number; x: number; y: number;};export function useGuidedLens({ defaultStepIndex = 0, defaultVisible = false, onFinish, onSkip, onStepIndexChange, onTargetMissing, prepareTarget, stepIndex, steps, visible,}: Pick< GuidedLensBaseProps, | "defaultStepIndex" | "defaultVisible" | "onFinish" | "onSkip" | "onStepIndexChange" | "onTargetMissing" | "prepareTarget" | "stepIndex" | "visible"> & { steps: readonly GuidedLensStep[];}) { const registry = useRef(new Map<string, View | null>()); const [internalIndex, setInternalIndex] = useState(defaultStepIndex); const [internalVisible, setInternalVisible] = useState(defaultVisible); const [targetRect, setTargetRect] = useState<GuidedLensRect | null>(null); const dimensions = useWindowDimensions(); const resolvedIndex = stepIndex ?? internalIndex; const resolvedVisible = visible ?? internalVisible; const step = steps[resolvedIndex]; useEffect(() => { if (!resolvedVisible || !step) { setTargetRect(null); return; } let cancelled = false; let retryTimer: ReturnType<typeof setTimeout>; let attempts = 0; async function measure() { await prepareTarget?.(step.targetId); if (cancelled) { return; } const target = registry.current.get(step.targetId); if (!target) { onTargetMissing?.(step.targetId); attempts += 1; if (attempts < 6) { retryTimer = setTimeout(measure, 120); } return; } target.measureInWindow((x, y, width, height) => { if (!cancelled && width > 0 && height > 0) { setTargetRect({ height, width, x, y }); } }); } requestAnimationFrame(measure); return () => { cancelled = true; clearTimeout(retryTimer); }; }, [ dimensions.height, dimensions.width, onTargetMissing, prepareTarget, resolvedVisible, step, ]); function changeStep(nextIndex: number) { if (stepIndex === undefined) { setInternalIndex(nextIndex); } onStepIndexChange?.(nextIndex); } function next() { if (resolvedIndex >= steps.length - 1) { if (visible === undefined) { setInternalVisible(false); } onFinish?.(); return; } changeStep(resolvedIndex + 1); } function previous() { changeStep(Math.max(0, resolvedIndex - 1)); } function skip() { if (visible === undefined) { setInternalVisible(false); } onSkip?.(); } return { dimensions, next, previous, registry: registry.current, resolvedIndex, resolvedVisible, skip, step, targetRect, };}GuidedLens
Provides tour state, overlay, and active target measurement.
GuidedLens.Step
Declares one ordered coachmark and connects it to a target.
GuidedLens.Target
Registers a measurable native target by id.
GuidedLens.Target extends all props from ViewProps, with the additional component-specific props shown below.
