Coach mark
Free · MITReact Native · ExpoAn onboarding spotlight: the screen dims around a cut-out over the measured target, and Next morphs the hole to the next target, changing shape and size in flight, while the tooltip glides alongside and a ring pulses on arrival.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/coach-mark.jsonThen run npx expo install --fix so react-native-reanimated match your Expo SDK. Files land in components/goodui-native/coach-mark.
Usage
import { useRef, useState, type ComponentRef } from "react";
import { View } from "react-native";
import { CoachMark } from "@/components/goodui-native/coach-mark/coach-mark";
import { IconButton } from "@/components/goodui-native/icon-button/icon-button";
import { SearchBar } from "@/components/goodui-native/search-bar/search-bar";
import { ComposeIcon } from "./icons"; // your own icon
export function InboxScreen() {
const searchRef = useRef<ComponentRef<typeof View>>(null);
const composeRef = useRef<ComponentRef<typeof View>>(null);
const [tour, setTour] = useState(true);
return (
<View style={{ flex: 1, padding: 16, gap: 16 }}>
<View ref={searchRef} collapsable={false}>
<SearchBar placeholder="Search messages" />
</View>
{/* message list */}
<View ref={composeRef} collapsable={false} style={{ position: "absolute", right: 16, bottom: 32 }}>
<IconButton icon={(c, s) => <ComposeIcon color={c} size={s} />} accessibilityLabel="New message" variant="solid" tone="accent" />
</View>
<CoachMark
open={tour}
onOpenChange={setTour}
steps={[
{ target: searchRef, title: "Find any message", description: "Search names, words and attachments." },
{ target: composeRef, shape: "circle", title: "Start a conversation", description: "Write to one person or a group." },
]}
/>
</View>
);
}When to use
- A short first-run tour of three to five controls on a busy screen: filters, the compose button, a tab.
- Pointing at one new feature after an update (a single step).
- Explaining the product before sign-up: use page-dots-onboarding.
- A hint that appears on demand next to one control: use popover.
- Anything the person must read to use the screen: put it in the screen itself.
Examples
Attach a target
<View ref={filtersRef} collapsable={false}>
<ChipGroup options={filters} value={value} onValueChange={setValue} />
</View>Tap anywhere to continue, localized
<CoachMark
open={open}
onOpenChange={setOpen}
steps={steps}
backdropPress="next"
labels={{ next: "Weiter", back: "Zurück", skip: "Überspringen", done: "Fertig", stepOf: (s, t) => `${s} von ${t}` }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| steps* The tour, in order. | CoachMarkStep[] | — |
| open* Whether the tour is showing. | boolean | — |
| onOpenChange* Called with `false` when the tour closes (finish, skip, Escape, Android back). | (open: boolean) => void | — |
| step Controlled step index. Pair with `onStepChange`. | number | — |
| defaultStep Uncontrolled first step. Default 0. | number | 0 |
| onStepChange Called with the new step index on Next and Back. | (step: number) => void | — |
| onFinish Called when Done is pressed on the last step. | () => void | — |
| onSkip Called with the step index when the tour is skipped or dismissed early. | (step: number) => void | — |
| labels Overrides for the built-in strings (buttons, step counter); a GooduiLocaleProvider pack sets them app-wide. | Partial<CoachMarkLabels> | — |
| backdropPress What a tap on the dimmed area does. Default `none`. | "next" | "close" | "none" | "none" |
| haptic Button and step haptics. Default `true`. | boolean | true |
| testID Identifier for end-to-end tests. | string | — |
Motion and haptics
- Open: the dim fades in and the hole starts 44 pt wider than the target and closes in on it (a focus pull) on the morph spring.
- Next and Back: the hole's position, size and corner radius spring to the next target on the morph spring, so a wide card hole becomes a round button hole in flight; the tooltip glides on the smooth spring, its arrow tracks the target and flips sides with a fade; the step text crossfades.
- A 2 pt accent ring pulses out of the hole once per arrival.
- Reduced motion: the hole and tooltip move at once and the content crossfades; no focus pull, no ring.
- Haptic: select on every step change, tap on Skip, success on Done. haptic={false} turns them off.
- The dim is one view with a very wide border whose inner edge is the hole, so the cut-out is plain layout animated on the UI thread: no svg masks, no snapshots.
- Targets are measured once per step and when the window size changes.
Accessibility
- The tooltip is modal (accessibilityViewIsModal; role dialog, aria-modal and aria-labelledby the step title on the web).
- Each step announces 'N of M', its title and description.
- Skip, Back and Next/Done are goodui Buttons. Escape on the web and the Android back button skip the tour.
- The dim blocks touches to the app; with backdropPress set it is a button labelled after what it does (Next, Done or Skip tour).
Notes for AI
- Each step's target is a ref to a View with collapsable={false} that is on screen; scroll it into view before opening or before that step.
- shape='circle' for icon buttons, FABs and avatars; padding sets the gap around the element.
- Control open; the component calls onOpenChange(false) on Done, Skip, Escape and back. onFinish runs on Done, onSkip(step) on an early exit.
- Keep tours to five steps or fewer and copy to one sentence each.
Related
A bubble anchored to an element whose arrow always points at the anchor's center, even when the bubble is pushed in from the screen edge; it flips above or below to fit and unfurls out of the arrow tip.
A full-screen onboarding pager: pages follow the finger 1:1 with parallax on their content, the page dot stretches toward the next page like a worm, and the Next button grows with the swipe into a full-width Get started on the last page.
A press-responsive button with haptics that runs async actions on its own: it gathers into a spinner, then opens into a success or error state.