React Native Tooltip: Anchors, Edges, and Scroll
By Native Animation · Updated
A React Native tooltip provides optional help near the control that needs explanation. On a phone, make it available by an explicit tap rather than hover alone. This tutorial measures a help button relative to a bounded host, clamps the bubble away from the edges, chooses above or below, and dismisses stale help when the page scrolls.
Keep instructions needed to complete a task visible beside the field. A tooltip is useful for secondary explanations, not a hiding place for validation errors. If the bubble contains commands, build an action popover or dropdown select with the appropriate focus behavior instead.
Setup and native alternatives
This complete example uses Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. It uses ordinary Pressables, so it does not need Gesture Handler or a gesture root. Install the compatible animation packages:
npx expo install react-native-reanimated react-native-workletsUse bunx expo install in a Bun project and follow the SDK 58 Reanimated setup. React Native core does not expose a universal Tooltip component. Persistent Text is enough when the help can remain visible; accessibilityHint can add a short optional explanation for assistive technology, but should not be the only place essential instructions exist. See the React Native accessibility reference.
Save this file as TooltipExample.tsx outside your route directory and render TooltipExample in a screen. The enclosing screen must reserve its safe areas and provide a bounded, unclipped host at least 240 points tall. This host owns both the scroll area and the absolute bubble.
Build anchored touch help
import type { ComponentRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { AccessibilityInfo, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from 'react-native-reanimated';
type Rect = { x: number; y: number; width: number; height: number };
const help = 'Flexible dates include departures up to three days before or after your chosen date.';
export function TooltipExample() {
const host = useRef<ComponentRef<typeof View>>(null);
const marker = useRef<ComponentRef<typeof View>>(null);
const generation = useRef(0);
const [anchor, setAnchor] = useState<Rect | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
const [bubbleHeight, setBubbleHeight] = useState(0);
const progress = useSharedValue(0);
const reducedMotion = useReducedMotion();
const margin = 12;
const gap = 10;
const width = Math.max(0, Math.min(280, size.width - margin * 2));
const above = anchor ? Math.max(0, anchor.y - gap - margin) : 0;
const below = anchor ? Math.max(0, size.height - anchor.y - anchor.height - gap - margin) : 0;
const placeAbove = (bubbleHeight > 0 && bubbleHeight <= above) || above > below;
const available = placeAbove ? above : below;
const ready = !!anchor && bubbleHeight > 0 && width > 0;
const left = anchor ? Math.max(margin, Math.min(
anchor.x + anchor.width / 2 - width / 2, size.width - width - margin,
)) : margin;
const height = Math.min(bubbleHeight, available);
const top = anchor ? (placeAbove ? anchor.y - gap - height : anchor.y + anchor.height + gap) : 0;
function close() {
generation.current += 1;
setAnchor(null);
}
function show() {
const token = ++generation.current;
const parent = host.current;
if (!parent) return;
marker.current?.measureLayout(parent, (x, y, measuredWidth, measuredHeight) => {
if (generation.current !== token) return;
setAnchor({ x, y, width: measuredWidth, height: measuredHeight });
AccessibilityInfo.announceForAccessibility(help);
}, () => close());
}
useEffect(() => {
progress.set(ready ? withTiming(1, { duration: reducedMotion ? 0 : 160 }) : 0);
}, [progress, ready, reducedMotion]);
useEffect(() => {
if (Platform.OS !== 'web' || !anchor) return;
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
generation.current += 1;
setAnchor(null);
}
};
document.addEventListener('keydown', escape);
return () => document.removeEventListener('keydown', escape);
}, [anchor]);
useEffect(() => () => { generation.current += 1; }, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: progress.get(),
transform: [{ translateY: reducedMotion ? 0 : (1 - progress.get()) * 6 }],
}));
return (
<View ref={host} collapsable={false} style={styles.host}
onLayout={({ nativeEvent: { layout } }) => {
close();
setSize({ width: layout.width, height: layout.height });
}}>
<ScrollView onScroll={close} scrollEventThrottle={16}
contentContainerStyle={{ padding: 24, gap: 24 }}>
<Text accessibilityRole="header">Travel preferences</Text>
<Text>Choose how far your dates can move.</Text>
<View style={styles.row}>
<Text style={{ flex: 1 }}>Flexible dates</Text>
<View ref={marker} collapsable={false}>
<Pressable accessibilityRole="button" accessibilityLabel="Help for flexible dates"
accessibilityHint="Explains the flexible date range"
accessibilityState={{ expanded: !!anchor }} onPress={anchor ? close : show}
style={styles.button}><Text>Help</Text></Pressable>
</View>
</View>
<Text style={{ marginTop: 480 }}>More preferences</Text>
</ScrollView>
{anchor && (
<Animated.View onAccessibilityEscape={close}
pointerEvents={ready ? 'auto' : 'none'}
accessibilityElementsHidden={!ready} importantForAccessibility={ready ? 'auto' : 'no-hide-descendants'}
style={[styles.bubble, { left, top, width, maxHeight: available }, animatedStyle]}>
<ScrollView onContentSizeChange={(_width, contentHeight) => setBubbleHeight(contentHeight)}
style={{ maxHeight: available }} contentContainerStyle={{ padding: 16 }}>
<Text style={styles.copy}>{help}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Dismiss flexible date help"
onPress={close} style={styles.button}><Text style={styles.copy}>Dismiss help</Text></Pressable>
</ScrollView>
</Animated.View>
)}
</View>
);
}
const styles = StyleSheet.create({
host: { flex: 1, minHeight: 240, position: 'relative', backgroundColor: '#FAF8FE' },
row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
button: { minHeight: 48, minWidth: 48, padding: 12, justifyContent: 'center' },
bubble: { position: 'absolute', backgroundColor: '#302344', borderRadius: 16, overflow: 'hidden' },
copy: { color: '#FFFFFF', fontSize: 16, lineHeight: 24 },
});Keep the anchor and bubble in the same coordinate space
The anchor is a non-collapsible View around the Help button. Its measureLayout callback reports bounds relative to the host View. The overlay is a sibling of the ScrollView in that same host, so its left and top use those coordinates directly. Measuring only the button's onLayout would report coordinates relative to its immediate row, which are insufficient for a host overlay.
The generation token rejects a late measurement after closing, scrolling, a host layout change, or unmounting. This prevents a delayed callback from reopening help at an old position. If another panel changes the anchor's position without resizing this host, close help from that panel's layout callback too.
For a portal or separate native Modal, measure in window coordinates and convert into the receiving overlay's coordinate space instead. Do not mix local x/y values with window offsets. Ancestor clipping still applies to this local overlay; moving it above a clipping ancestor requires a deliberate overlay host, not a larger zIndex alone.
Handle edges, long text, and scroll
The width is at most 280 points and leaves twelve-point host margins. Center it on the anchor, then clamp its left edge. Vertical placement first tries the space above when the measured content fits, otherwise chooses the larger side. A ten-point gap separates it from the anchor.
The ScrollView measures the bubble's full content, including its dismiss button. If neither side fits, its viewport is capped to the chosen side and the help can scroll. A fade hides the first measurement pass. There is no pointer triangle in this teaching version: add one only after clamping, and point it at the anchor center rather than the bubble center.
The page's onScroll closes help, including programmatic scrolling. That is a deliberate alternative to continuously tracking the anchor. To keep a tooltip open while scrolling, remeasure the anchor after each relevant scroll/layout change, reject stale callbacks, and close when the anchor leaves the visible host. Avoid sending frame-by-frame scroll positions through React just to move an overlay.
Touch, keyboard, and screen readers
Help and Dismiss help have forty-eight-point minimum targets. The trigger reports its expanded state. On web, Pressable supports keyboard activation, and Escape closes the bubble. Native accessibility escape closes it too. An explicit dismiss button remains available, with no disappearing timeout and no full-screen touch interceptor blocking the form underneath.
The help text is announced once after a successful anchor measurement. The bubble is nonmodal and leaves focus on the trigger; screen readers can navigate forward to its content and dismiss button. It does not trap focus or present a dialog role. Reduced Motion removes the entry translation and fade. Reanimated animates opacity and translation from one shared value; there is no UI-thread completion callback here, so no scheduleOnRN crossing is needed.
Test near every host edge, after rotation, at enlarged text sizes, while a measurement is pending, and with both touch and a screen reader. In extremely constrained hosts, show the help inline rather than shrinking touch targets. Verify the real reading order and anchor measurements on your target devices; compilation alone does not prove those behaviors.
What the paid component adds
Touch Help Tooltip adds a glass bubble, pointer, spring movement between supplied anchors, automatic placement, controlled or uncontrolled active IDs, configurable copy, theme and motion props, and show/hide/reset ref methods. It keeps the closing copy mounted through its fade and excludes hidden help from accessibility and touch.
The host still supplies local anchor rectangles and must update or dismiss them after scrolling. The component preserves access to host controls, announces confirmed anchor changes, and uses a short fade under Reduced Motion. Supply Escape handling in your web host keyboard layer. It requires Expo Blur. Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release; check placement and reading order again in your own host.
For a compact control that keeps every choice visible, compare the segmented control guide. Help explains a choice; selecting a value belongs to the control itself.
For an anchored surface that runs commands and manages focus, follow the overlay and popover guide.
FAQ
Can tooltips in React Native open on hover?
A web host can add hover and focus behavior, but touch users still need an explicit trigger. This example uses a Help button on every platform and remains usable without hover or a long press.
Why does the tooltip move to the wrong place inside a ScrollView?
It usually uses row-local measurements as though they were host or window coordinates, or retains bounds from before a scroll. Measure relative to the overlay host and either update the bounds or dismiss on scrolling.
Should important field instructions live in a tooltip?
Keep required instructions and errors visible in the form. Tooltips work best for optional explanations that someone can dismiss without losing the information necessary to finish the task.