Veltrix

onboarding

Guided Lens

A measured onboarding lens follows real targets, retries virtualized content, and positions guidance around safe screen geometry.

AppleiOSAndroidAndroidExpoExpo Go
skiareanimatedworklets
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add @shopify/react-native-skia react-native-reanimated react-native-worklets

Anatomy

1 file

anatomy.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-variants

Usage

GuidedLensExample.tsx
import { 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.tsx
import { 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.tsx
import { 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.tsx
import { 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.tsx
import { 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.

Component files

9 files

guided-lens/card.tsx
import 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.ts
export const GUIDED_LENS_CONFIG = {  cardEstimatedHeight: 190,  cardGap: 18,  cardWidth: 320,  screenInset: 16,  transitionDuration: 320,} as const;
guided-lens/context.tsx
import {  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.tsx
import {  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.ts
import 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.tsx
import {  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.ts
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

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.