/**
* Tooltip — a small label that names the thing under your finger.
*
* A popover is a panel you open and deal with; a tooltip is a whisper. It
* carries a word or two about what a control does, appears without taking the
* screen, and goes away on its own. That is why it is inverted by default, why
* it is not dismissible with a scrim, and why it closes after a beat instead of
* waiting to be told.
*
* The inversion is a default rather than a rule, because the whisper has a
* larger sibling: a tooltip that carries a heading and a sentence stops reading
* as a different layer and starts reading as a panel with the wrong colours.
* `variant="surface"` makes it one, and the sizing props — `width`,
* `minWidth`, `maxHeight`, `scrollable` — are what let it hold that much.
*
* On touch there is no hover to open it, so the gesture is a long press by
* default — the platform's own "tell me more" gesture — with `openOn="press"`
* for the cases where a tap should reveal it instead.
*
* ```tsx
*
*
* } />
*
*
*
* Copy link
*
*
* ```
*
* Placement is a preference, not a promise: the trigger is measured when it is
* pressed, the label measures itself on its first layout, and the two are
* reconciled against the safe area — so `placement="top"` means *above, if
* above fits*, and a trigger near the top edge shows its tooltip below instead.
*/
import {
Children,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import {
Pressable,
ScrollView,
useWindowDimensions,
View,
type LayoutChangeEvent,
type ViewProps,
} from 'react-native';
import Animated, {
FadeOut,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { tv } from 'tailwind-variants';
import { Portal } from '../../primitives/portal';
import { Text, type TextProps } from '../../primitives/text';
import { cn } from '../../utils/cn';
/** Gap between the trigger and the label. */
const DEFAULT_OFFSET = 6;
/** Smallest gap allowed between the label and the edge of the safe area. */
const SCREEN_MARGIN = 12;
/** Side of the arrow square before it is rotated 45°. */
const ARROW_SIZE = 10;
/** How long the label stays up before hiding itself, in milliseconds. */
const DEFAULT_DURATION = 1500;
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
export type TooltipAlign = 'start' | 'center' | 'end';
export type TooltipVariant = 'inverted' | 'surface';
/**
* Every colour the tooltip has, in one place.
*
* They were three literals at three call sites — the panel, the arrow and the
* default text — which meant retheming a tooltip took three `className`
* overrides that each had to be kept in step with the others. As a variant it
* is one prop, and the arrow and the text read it off the context rather than
* being told again.
*/
const tooltipVariants = tv({
slots: {
content: 'rounded-lg px-2.5 py-1.5 shadow-md',
text: 'text-sm font-medium',
title: 'text-sm font-semibold',
description: 'text-sm',
arrow: 'rounded-[1px]',
},
variants: {
variant: {
/*
* The default, and deliberately not a surface colour: a whisper over the
* page should read as a different layer rather than as another panel of
* it. It is the treatment a one-line label wants.
*/
inverted: {
content: 'bg-foreground',
text: 'text-background',
title: 'text-background',
description: 'text-background/70',
arrow: 'bg-foreground',
},
/*
* For a tooltip carrying more than a label — a heading, a sentence, a row
* of controls. At that size the inversion stops reading as a whisper and
* starts reading as a panel with the wrong colours, so it becomes one.
*/
surface: {
content: 'border border-border bg-popover',
text: 'text-popover-foreground',
title: 'text-popover-foreground',
description: 'text-muted-foreground',
arrow: 'border-border bg-popover',
},
},
},
defaultVariants: {
variant: 'inverted',
},
});
interface TriggerRect {
x: number;
y: number;
width: number;
height: number;
}
interface TooltipContextValue {
open: boolean;
show: () => void;
hide: () => void;
trigger: TriggerRect | null;
setTrigger: (rect: TriggerRect | null) => void;
/** Resolved placement, published by Content so Arrow knows which way to point. */
placement: TooltipPlacement;
setPlacement: (placement: TooltipPlacement) => void;
/** Trigger centre along the cross axis, relative to the label origin. */
arrowOffset: number;
setArrowOffset: (offset: number) => void;
/** How the trigger opens the tooltip. */
openOn: TooltipOpenOn;
/** Accessibility label carried onto the trigger. */
label?: string;
/** Which set of colours the panel, arrow and text draw from. */
variant: TooltipVariant;
setVariant: (variant: TooltipVariant) => void;
}
export type TooltipOpenOn = 'longPress' | 'press';
const TooltipContext = createContext(null);
function useTooltip(component: string): TooltipContextValue {
const context = useContext(TooltipContext);
if (!context) {
throw new Error(`${component} must be used within a `);
}
return context;
}
export interface TooltipProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Initial state when uncontrolled. */
defaultOpen?: boolean;
/**
* Whether a long press or a plain press reveals the label. Long press is the
* default because it does not steal a tappable control's own press.
*/
openOn?: TooltipOpenOn;
/**
* How long the label stays up before hiding itself, in milliseconds. `0`
* keeps it up until it is dismissed by a tap outside or the trigger again.
*/
duration?: number;
/**
* The label's text, mirrored onto the trigger as its accessibility label so
* a screen reader announces what the tooltip says without opening it. Set it
* whenever the trigger has no text of its own — an icon-only button.
*/
label?: string;
}
function TooltipRoot({
children,
open,
onOpenChange,
defaultOpen = false,
openOn = 'longPress',
duration = DEFAULT_DURATION,
label,
}: TooltipProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [trigger, setTrigger] = useState(null);
const [placement, setPlacement] = useState('top');
const [arrowOffset, setArrowOffset] = useState(0);
/*
* Published by Content rather than passed to it, because the arrow and the
* text sit beside the panel in the tree and would otherwise each have to be
* told which colours to use.
*/
const [variant, setVariant] = useState('inverted');
const isControlled = open !== undefined;
const resolvedOpen = isControlled ? open : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
const show = useCallback(() => setOpen(true), [setOpen]);
const hide = useCallback(() => setOpen(false), [setOpen]);
// A tooltip is not a thing you deal with, so it hides itself after a beat.
// The timer is armed on open and cleared on close, and `duration` of 0 opts
// out — for a tooltip that stays until the next tap.
useEffect(() => {
if (!resolvedOpen || duration <= 0) return;
const timer = setTimeout(() => setOpen(false), duration);
return () => clearTimeout(timer);
}, [resolvedOpen, duration, setOpen]);
const context = useMemo(
() => ({
open: resolvedOpen,
show,
hide,
trigger,
setTrigger,
placement,
setPlacement,
arrowOffset,
setArrowOffset,
openOn,
label,
variant,
setVariant,
}),
[resolvedOpen, show, hide, trigger, placement, arrowOffset, openOn, label, variant]
);
return {children};
}
export interface TooltipTriggerProps {
/**
* Classes on the wrapper the child is measured through. It shrinks to the
* child by default; widen it only if the label should be anchored to
* something bigger than the control.
*/
className?: string;
children: ReactElement<{
onPress?: (...args: unknown[]) => void;
onLongPress?: (...args: unknown[]) => void;
accessibilityLabel?: string;
accessibilityHint?: string;
}>;
}
/**
* Wraps its child and reveals the label on it — a long press by default, a
* press when the root asks for one. It is also what gets measured, so the
* label knows where to sit.
*
* The child is wrapped in a view rather than handed a ref: the ref has to
* survive whatever the child is — a button, a plain Pressable, an icon — and
* only a wrapper we own is guaranteed to be measurable.
*
* That wrapper shrinks to its child on purpose. A view stretches to its parent
* by default, and a wrapper that filled the row would be measured as the whole
* row — putting a centred label over the middle of the screen rather than over
* the control it names.
*/
function TooltipTrigger({ className, children }: TooltipTriggerProps) {
const { open, show, hide, setTrigger, openOn, label } = useTooltip('Tooltip.Trigger');
const ref = useRef(null);
const measureThenShow = () => {
// Measured on every open rather than on layout: the trigger may have
// scrolled since it was laid out, and a stale rect anchors the label to
// where the trigger used to be.
ref.current?.measureInWindow((x, y, width, height) => {
setTrigger({ x, y, width, height });
show();
});
};
const handleLongPress = (...args: unknown[]) => {
if (isValidElement(children)) children.props.onLongPress?.(...args);
if (openOn === 'longPress') measureThenShow();
};
const handlePress = (...args: unknown[]) => {
if (isValidElement(children)) children.props.onPress?.(...args);
if (openOn !== 'press') return;
if (open) hide();
else measureThenShow();
};
// A trigger that is not an element has nothing to clone handlers onto, so it
// is passed through — wrapped, if it is bare text, since a string cannot be
// a child of a view.
if (!isValidElement(children)) {
return typeof children === 'string' || typeof children === 'number' ? (
{children}
) : (
<>{children}>
);
}
return (
{cloneElement(children, {
onPress: handlePress,
onLongPress: handleLongPress,
// An icon-only trigger has nothing for a screen reader to read; the
// label fills that in without the sighted user having to open it.
accessibilityLabel: children.props.accessibilityLabel ?? label,
})}
);
}
export interface TooltipContentProps extends ViewProps {
className?: string;
/** Preferred side of the trigger. Flipped when that side does not fit. */
placement?: TooltipPlacement;
/** Where the label sits along the trigger's other axis. */
align?: TooltipAlign;
/** Gap between the trigger and the label, in pixels. */
offset?: number;
/** Nudge along the alignment axis, in pixels. */
alignOffset?: number;
/**
* Which set of colours the panel, its arrow and its text draw from.
*
* `inverted` is the default and right for a label: a whisper over the page
* should read as a different layer rather than as another panel of it.
* `surface` matches the popover — reach for it once the tooltip carries a
* heading and a sentence, where the inversion stops reading as a whisper.
*/
variant?: TooltipVariant;
/**
* `content-fit` sizes to the content, `trigger` matches the trigger's width,
* `full` spans the safe area, and a number is that many pixels. Worth setting
* for anything longer than a label, which would otherwise run to whatever
* width the sentence happens to want.
*/
width?: number | 'trigger' | 'full' | 'content-fit';
/** Floor for the panel's width, in pixels. */
minWidth?: number;
/**
* Ceiling for the panel's height, in pixels. Always clamped to the room
* inside the safe area, which is also the default.
*/
maxHeight?: number;
/**
* Scroll the body when it is taller than `maxHeight`. Off by default — a
* label has nothing to scroll, and a scroller around one only adds a bounce.
*/
scrollable?: boolean;
children?: ReactNode;
}
function TooltipContent({
className,
placement = 'top',
align = 'center',
offset = DEFAULT_OFFSET,
alignOffset = 0,
variant = 'inverted',
width = 'content-fit',
minWidth,
maxHeight,
scrollable = false,
children,
onLayout: onLayoutProp,
style,
...props
}: TooltipContentProps) {
const context = useTooltip('Tooltip.Content');
const { open, hide, trigger, setPlacement, setArrowOffset, setVariant } = context;
const slots = tooltipVariants({ variant });
// The arrow and the text sit beside this panel in the tree, so the chosen
// colours are published rather than passed down.
useEffect(() => {
setVariant(variant);
}, [variant, setVariant]);
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [size, setSize] = useState<{ width: number; height: number } | null>(null);
// Label size changes with its text, so it is re-measured rather than measured
// once — a tooltip whose text is swapped should not stay the old size.
const onLayout = (event: LayoutChangeEvent) => {
const { width: w, height: h } = event.nativeEvent.layout;
setSize((current) =>
current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1
? current
: { width: w, height: h }
);
onLayoutProp?.(event);
};
/*
* Forgetting the size on close is what makes the second open behave like the
* first. Held, it would place the reopened label using the last label's
* dimensions for a frame — and, because the entrance is driven off whether a
* position exists at all, a size that never went away means a tooltip that
* animates in once and afterwards just appears.
*/
useEffect(() => {
if (!open) setSize(null);
}, [open]);
const bounds = {
left: insets.left + SCREEN_MARGIN,
right: screenWidth - insets.right - SCREEN_MARGIN,
top: insets.top + SCREEN_MARGIN,
bottom: screenHeight - insets.bottom - SCREEN_MARGIN,
};
const available = bounds.right - bounds.left;
const requestedWidth =
width === 'content-fit'
? undefined
: width === 'trigger'
? trigger?.width
: width === 'full'
? available
: width;
// The floor never wins past the space there actually is — a panel wider than
// the screen is worse than a cramped one.
const resolvedWidth =
requestedWidth === undefined
? minWidth === undefined
? undefined
: Math.min(minWidth, available)
: Math.min(Math.max(requestedWidth, minWidth ?? 0), available);
const room = bounds.bottom - bounds.top;
const resolvedMaxHeight = Math.min(maxHeight ?? room, room);
const position =
trigger && size
? place({ trigger, size, placement, align, offset, alignOffset, bounds })
: null;
// Publish the side actually used and where the trigger centre landed, so the
// arrow points at the trigger even after a flip or a clamp.
const resolvedPlacement = position?.placement;
const resolvedArrow = position?.arrowOffset;
useEffect(() => {
if (resolvedPlacement) setPlacement(resolvedPlacement);
if (resolvedArrow !== undefined) setArrowOffset(resolvedArrow);
}, [resolvedPlacement, resolvedArrow, setPlacement, setArrowOffset]);
/*
* The entrance is driven by hand rather than by an `entering` preset, and the
* reason is the measuring frame. A layout animation fires on mount — which
* here is the frame *before* the label knows where it goes, so the whole
* animation would play at the origin, invisibly, and the label would then
* snap into place fully formed. Holding the values until a position exists is
* the only way to have both the animation and the correct position.
*/
const appear = useSharedValue(0);
const settle = useSharedValue(0);
const reducedMotion = useReducedMotion();
const placed = !!position;
useEffect(() => {
if (!placed) {
appear.value = 0;
settle.value = 0;
return;
}
if (reducedMotion) {
appear.value = 1;
settle.value = 1;
return;
}
appear.value = withTiming(1, { duration: 100 });
settle.value = withSpring(1, { damping: 20, stiffness: 300, mass: 0.5 });
}, [placed, reducedMotion, appear, settle]);
// Starts slightly small and shifted towards the trigger, so the label
// appears to unfold from it rather than fade in over it.
const origin = ENTRY_SHIFT[resolvedPlacement ?? placement];
const labelStyle = useAnimatedStyle(() => ({
opacity: appear.value,
transform: [
{ translateX: origin.x * (1 - settle.value) },
{ translateY: origin.y * (1 - settle.value) },
{ scale: 0.9 + 0.1 * settle.value },
],
}));
if (!open || !trigger) return null;
return (
{/* Portal content mounts under PortalHost, outside this provider's
subtree — re-provide the context so Tooltip.Arrow keeps working. */}
{/* No scrim — a tooltip does not dim the screen. The transparent
catcher only lets a tap anywhere dismiss the label. */}
{/*
Two views, not one: a Reanimated rule forbids a layout animation and
an animated style driving the same property on one component, or the
layout animation silently wins. The exit fade and `labelStyle`'s
opacity both want it — so the outer view owns the position and the
exit, the inner one the entrance and the label's own surface.
*/}
{/*
Wrapped one child at a time, not all-or-nothing. The label is
almost always written as an arrow followed by its text, which
makes `children` an array — so a check against the whole of it
is never a string, and the text underneath would reach this
view bare. Only the text nodes need the treatment; an element
is already responsible for itself.
*/}
{scrollable ? (
{wrapTooltipText(children)}
) : (
wrapTooltipText(children)
)}
);
}
/** Bare strings become the label's default text; elements speak for themselves. */
function wrapTooltipText(children: ReactNode) {
return Children.map(children, (child) =>
typeof child === 'string' || typeof child === 'number' ? (
{child}
) : (
child
)
);
}
/** The label's default text, coloured to whatever the panel is made of. */
function TooltipText({ className, ...props }: TextProps) {
const { variant } = useTooltip('Tooltip.Text');
const { text } = tooltipVariants({ variant });
return ;
}
TooltipText.displayName = 'Tooltip.Text';
TooltipTitle.displayName = 'Tooltip.Title';
TooltipDescription.displayName = 'Tooltip.Description';
export interface TooltipTitleProps extends TextProps {
className?: string;
}
/** A heading, for a tooltip carrying more than a label. */
function TooltipTitle({ className, ...props }: TooltipTitleProps) {
const { variant } = useTooltip('Tooltip.Title');
const { title } = tooltipVariants({ variant });
return (
);
}
export interface TooltipDescriptionProps extends TextProps {
className?: string;
}
/** The sentence under a `Tooltip.Title`, in the panel's secondary colour. */
function TooltipDescription({ className, ...props }: TooltipDescriptionProps) {
const { variant } = useTooltip('Tooltip.Description');
const { description } = tooltipVariants({ variant });
return ;
}
export interface TooltipArrowProps extends ViewProps {
className?: string;
}
/**
* A small square rotated into a diamond, half-buried under the label so only
* the point shows. It shares the label's fill and points at the trigger's
* centre, which Content resolves and publishes — so when `align` shifts the
* label off-centre, or a clamp slides it back on screen, the arrow stays over
* the trigger rather than over the label's middle.
*/
function TooltipArrow({ className, style, ...props }: TooltipArrowProps) {
const { trigger, placement, arrowOffset, variant } = useTooltip('Tooltip.Arrow');
const { arrow } = tooltipVariants({ variant });
if (!trigger) return null;
const vertical = placement === 'top' || placement === 'bottom';
// The arrow sits on the edge facing the trigger, the opposite edge to the
// placement: a label placed above the trigger has its arrow on the bottom.
const edge = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[placement];
return (
);
}
/* -------------------------------------------------------------------------- */
/* Placement */
/* -------------------------------------------------------------------------- */
interface PlaceArgs {
trigger: TriggerRect;
size: { width: number; height: number };
placement: TooltipPlacement;
align: TooltipAlign;
offset: number;
alignOffset: number;
bounds: { left: number; right: number; top: number; bottom: number };
}
/**
* Resolves the label's window position.
*
* Two passes, in this order: the flip picks a *side* and only fires when the
* preferred one genuinely has less room than its opposite; the clamp then
* slides the label along the other axis to keep it on screen. Doing the clamp
* first would let a label be nudged inside the bounds and so look like it fits,
* hiding the fact that the wrong side was chosen.
*/
function place({ trigger, size, placement, align, offset, alignOffset, bounds }: PlaceArgs) {
const roomAfter = {
bottom: bounds.bottom - (trigger.y + trigger.height + offset),
top: trigger.y - offset - bounds.top,
right: bounds.right - (trigger.x + trigger.width + offset),
left: trigger.x - offset - bounds.left,
};
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
const needed = placement === 'top' || placement === 'bottom' ? size.height : size.width;
const resolved =
roomAfter[placement] < needed && roomAfter[opposite[placement]] > roomAfter[placement]
? opposite[placement]
: placement;
const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(value, Math.max(min, max)));
if (resolved === 'top' || resolved === 'bottom') {
const top =
resolved === 'bottom'
? trigger.y + trigger.height + offset
: trigger.y - size.height - offset;
const left =
align === 'start'
? trigger.x + alignOffset
: align === 'end'
? trigger.x + trigger.width - size.width + alignOffset
: trigger.x + trigger.width / 2 - size.width / 2 + alignOffset;
const clampedLeft = clamp(left, bounds.left, bounds.right - size.width);
const triggerCentreX = trigger.x + trigger.width / 2;
return {
placement: resolved,
top: clamp(top, bounds.top, bounds.bottom - size.height),
left: clampedLeft,
arrowOffset: clamp(triggerCentreX - clampedLeft, ARROW_SIZE, size.width - ARROW_SIZE),
};
}
const left =
resolved === 'right' ? trigger.x + trigger.width + offset : trigger.x - size.width - offset;
const top =
align === 'start'
? trigger.y + alignOffset
: align === 'end'
? trigger.y + trigger.height - size.height + alignOffset
: trigger.y + trigger.height / 2 - size.height / 2 + alignOffset;
const clampedTop = clamp(top, bounds.top, bounds.bottom - size.height);
const triggerCentreY = trigger.y + trigger.height / 2;
return {
placement: resolved,
top: clampedTop,
left: clamp(left, bounds.left, bounds.right - size.width),
arrowOffset: clamp(triggerCentreY - clampedTop, ARROW_SIZE, size.height - ARROW_SIZE),
};
}
/**
* Where the label starts, relative to where it ends: towards the trigger, on
* whichever side was resolved, so it appears to grow out of the control.
*/
const ENTRY_SHIFT: Record = {
bottom: { x: 0, y: -6 },
top: { x: 0, y: 6 },
right: { x: -6, y: 0 },
left: { x: 6, y: 0 },
};
export const Tooltip = Object.assign(TooltipRoot, {
Trigger: TooltipTrigger,
Content: TooltipContent,
Arrow: TooltipArrow,
Title: TooltipTitle,
Description: TooltipDescription,
Text: TooltipText,
});