Bring your own tooltip UI

Your design system already has a tooltip. Your Figma file already shows what the tour should look like. What you actually need from a tour library is everything underneath the tooltip — measurement, auto-scroll, placement math, safe-area handling, Android quirks, step sequencing, persistence — with none of its opinions about pixels.

That is how this library is built. The default tooltip is just a reference implementation of a public contract; replace as little or as much of it as you want. There are three levels, and you can stop at any of them.

Level 1 — restyle the built-in tooltip

Colors, radius, and per-element text styles, without replacing anything:

startTour(steps, {
  tooltipStyles: {
    backgroundColor: '#12141A',
    borderRadius: 20,
    titleColor: '#FFFFFF',
    descriptionColor: '#AEB4C2',
    primaryButtonColor: '#4F46E5',
    titleStyle: { fontFamily: 'InterDisplay-SemiBold' },
  },
});

If you set a light backgroundColor and no text colors, readable dark defaults are derived automatically — white-on-white is not a failure mode you can ship.

Level 2 — swap individual pieces with component slots

Keep the layout, replace only the parts your design system owns. Each slot is an ordinary component; anything you don’t provide keeps the default:

import { Button, Stepper } from '@your-org/design-system';

startTour(steps, {
  components: {
    NextButton: ({ label, onPress, disabled, isLast }) => (
      <Button
        variant={isLast ? 'success' : 'primary'}
        onPress={onPress}
        disabled={disabled}   // respects `completed` gating automatically
      >
        {label}
      </Button>
    ),
    SkipButton: ({ label, onPress }) => (
      <Button variant="ghost" onPress={onPress}>{label}</Button>
    ),
    ProgressDots: ({ currentStep, totalSteps }) => (
      <Stepper current={currentStep} total={totalSteps} />
    ),
    // PrevButton and StepCounter work the same way
  },
});

Level 3 — a fully custom tooltip from your Figma file

renderTooltip hands your component the complete headless contract — TooltipProps — and steps aside. The engine still measures the target, picks the side with room, computes safe-area-aware coordinates, scrolls off-screen targets into view, and sequences steps. You only draw.

import type { TooltipProps } from '@wrack/react-native-tour-guide';
import { View, Text, Pressable, StyleSheet } from 'react-native';

function BrandTooltip({
  title, description,
  position,            // spotlight-anchored { x, y } in window coordinates
  tooltipPosition,     // resolved side: 'top' | 'bottom' | 'left' | 'right'
  targetWidth, targetHeight,
  currentStep, totalSteps, isLastStep,
  onNext, onPrev, onSkip, nextDisabled,
  screenWidth, screenHeight, insets,
}: TooltipProps) {
  // Place yourself relative to the target — here: under it, clamped to insets.
  const top =
    tooltipPosition === 'top'
      ? undefined
      : Math.min(position.y + targetHeight + 16, screenHeight - (insets?.bottom ?? 0) - 220);

  return (
    <View style={[styles.card, { top, left: 24, right: 24 }]}>
      <Text style={styles.kicker}>{currentStep + 1} / {totalSteps}</Text>
      <Text style={styles.title}>{title}</Text>
      <Text style={styles.body}>{description}</Text>
      <View style={styles.row}>
        {currentStep > 0 && <Pressable onPress={onPrev}><Text style={styles.ghost}>Back</Text></Pressable>}
        <Pressable onPress={onSkip}><Text style={styles.ghost}>Skip</Text></Pressable>
        <Pressable onPress={onNext} disabled={nextDisabled} style={styles.cta}>
          <Text style={styles.ctaText}>{isLastStep ? 'Done' : 'Next'}</Text>
        </Pressable>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  card: { position: 'absolute', backgroundColor: '#0E1116', borderRadius: 24, padding: 20 },
  kicker: { color: '#6B7280', fontSize: 12, fontVariant: ['tabular-nums'] },
  title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginTop: 4 },
  body: { color: '#AEB4C2', fontSize: 14, lineHeight: 20, marginTop: 6 },
  row: { flexDirection: 'row', gap: 16, alignItems: 'center', justifyContent: 'flex-end', marginTop: 16 },
  ghost: { color: '#8A93A6', fontSize: 14, fontWeight: '600' },
  cta: { backgroundColor: '#4F46E5', borderRadius: 12, paddingHorizontal: 18, paddingVertical: 10 },
  ctaText: { color: '#FFFFFF', fontWeight: '700' },
});

// Global:
startTour(steps, { renderTooltip: (props) => <BrandTooltip {...props} /> });

// Or for a single step only:
const steps = [
  { id: 'special', targetId: 'promo', title: '', description: '',
    renderTooltip: (props) => <BrandTooltip {...props} /> },
];

That’s the whole integration — about 40 lines for a production tooltip.

The contract

TooltipProps is a stable public API. Everything a custom tooltip needs is on it:

Group Fields
Content title, description
Geometry position (spotlight-anchored), targetWidth, targetHeight, screenWidth, screenHeight
Placement tooltipPosition — the side the engine chose after checking available space
Layout safety insets — resolved safe-area + extra insets to stay within
Progress currentStep, totalSteps, isLastStep
Navigation onNext, onPrev (undefined on the first step), onSkip
Gating nextDisabled — true while a completed: false step is unfinished
Visibility hideNextButton, hidePrevButton, hideSkipButton — per-step flags to honour
Everything else config — the full tour config, for your own conventions

The engine guarantees still hold with a custom tooltip: targets are measured and re-measured, off-screen targets are scrolled into view, steps are announced to screen readers, and an unmeasurable target falls back to a centered tooltip instead of trapping the user.