Tooltip Primitive
A pop-up presenting relevant information pertaining to an element whenever the element gains keyboard focus or when the mouse hovers over it.
Installation
Section titled “Installation”Install the component via your command line.
npx expo install @rn-primitives/tooltip
Install @radix-ui/react-tooltip
npx expo install @radix-ui/react-tooltip
Copy/paste the following code for web to ~/components/primitives/tooltip/tooltip.web.tsx
Copy/paste the following code for native to ~/components/primitives/tooltip/tooltip.tsx
import { useAugmentedRef, useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { BackHandler, Pressable, View, type GestureResponderEvent, type LayoutChangeEvent, type LayoutRectangle,} from 'react-native';import type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
interface IRootContext { open: boolean; onOpenChange: (open: boolean) => void; triggerPosition: LayoutPosition | null; setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; contentLayout: LayoutRectangle | null; setContentLayout: (contentLayout: LayoutRectangle | null) => void; nativeID: string;}
const RootContext = React.createContext<IRootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, delayDuration: _delayDuration, skipDelayDuration: _skipDelayDuration, disableHoverableContent: _disableHoverableContent, onOpenChange: onOpenChangeProp, ...viewProps }, ref ) => { const nativeID = React.useId(); const [triggerPosition, setTriggerPosition] = React.useState<LayoutPosition | null>(null); const [contentLayout, setContentLayout] = React.useState<LayoutRectangle | null>(null); const [open, setOpen] = React.useState(false);
function onOpenChange(value: boolean) { setOpen(value); onOpenChangeProp?.(value); }
const Component = asChild ? Slot.View : View; return ( <RootContext.Provider value={{ open, onOpenChange, contentLayout, nativeID, setContentLayout, setTriggerPosition, triggerPosition, }} > <Component ref={ref} {...viewProps} /> </RootContext.Provider> ); });
Root.displayName = 'RootNativeTooltip';
function useTooltipContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Tooltip compound components cannot be rendered outside the Tooltip component'); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange, setTriggerPosition } = useTooltipContext();
const augmentedRef = useAugmentedRef({ ref, methods: { open: () => { onOpenChange(true); augmentedRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); }, close: () => { setTriggerPosition(null); onOpenChange(false); }, }, });
function onPress(ev: GestureResponderEvent) { if (disabled) return; augmentedRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={augmentedRef} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Trigger.displayName = 'TriggerNativeTooltip';
/** * @warning when using a custom `<PortalHost />`, you might have to adjust the Content's sideOffset to account for nav elements like headers. */function Portal({ forceMount, hostName, children }: PortalProps) { const value = useTooltipContext();
if (!value.triggerPosition) { return null; }
if (!forceMount) { if (!value.open) { return null; } }
return ( <RNPPortal hostName={hostName} name={`${value.nativeID}_portal`}> <RootContext.Provider value={value}>{children}</RootContext.Provider> </RNPPortal> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { const { open, onOpenChange, setContentLayout, setTriggerPosition } = useTooltipContext();
function onPress(ev: GestureResponderEvent) { if (closeOnPress) { setTriggerPosition(null); setContentLayout(null); onOpenChange(false); } OnPressProp?.(ev); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativeTooltip';
/** * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior on native by setting `disablePositioningStyle` to `true`. */const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align = 'center', side = 'top', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, ...props }, ref ) => { const { open, onOpenChange, nativeID, contentLayout, setContentLayout, setTriggerPosition, triggerPosition, } = useTooltipContext();
React.useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { setTriggerPosition(null); setContentLayout(null); onOpenChange(false); return true; });
return () => { setContentLayout(null); backHandler.remove(); }; }, []);
const positionStyle = useRelativePosition({ align, avoidCollisions, triggerPosition, contentLayout, alignOffset, insets, sideOffset, side: getNativeSide(side), disablePositioningStyle, });
function onLayout(event: LayoutChangeEvent) { setContentLayout(event.nativeEvent.layout); onLayoutProp?.(event); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='tooltip' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativeTooltip';
export { Content, Overlay, Portal, Root, Trigger };
function onStartShouldSetResponder() { return true;}
function getNativeSide(side: 'left' | 'right' | 'top' | 'bottom') { if (side === 'left' || side === 'right') { return 'top'; } return side;}
Copy/paste the following code for types to ~/components/primitives/tooltip/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void; /** * Platform: WEB ONLY * @default 700 */ delayDuration?: number; /** * Platform: WEB ONLY * @default 300 */ skipDelayDuration?: number; /** * Platform: WEB ONLY */ disableHoverableContent?: boolean;};
interface PortalProps extends ForceMountable { children: React.ReactNode; /** * Platform: NATIVE ONLY */ hostName?: string; /** * Platform: WEB ONLY */ container?: HTMLElement | null | undefined;}
type OverlayProps = ForceMountable & SlottablePressableProps & { closeOnPress?: boolean; };
type ContentProps = SlottableViewProps & Omit<PositionedContentProps, 'side'> & { /** * `left` and `right` are only supported on web. */ side?: 'top' | 'right' | 'bottom' | 'left'; };
type TriggerProps = SlottablePressableProps;
type RootRef = ViewRef;type ContentRef = ViewRef;type OverlayRef = PressableRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};
export type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,};
Copy/paste the following code for exporting to ~/components/primitives/tooltip/index.ts
export * from './tooltip';export * from './types';
Copy/paste the following code for native to ~/components/primitives/tooltip/index.tsx
import { useAugmentedRef, useRelativePosition, type LayoutPosition } from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { BackHandler, Pressable, View, type GestureResponderEvent, type LayoutChangeEvent, type LayoutRectangle,} from 'react-native';import type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
interface IRootContext { open: boolean; onOpenChange: (open: boolean) => void; triggerPosition: LayoutPosition | null; setTriggerPosition: (triggerPosition: LayoutPosition | null) => void; contentLayout: LayoutRectangle | null; setContentLayout: (contentLayout: LayoutRectangle | null) => void; nativeID: string;}
const RootContext = React.createContext<IRootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, delayDuration: _delayDuration, skipDelayDuration: _skipDelayDuration, disableHoverableContent: _disableHoverableContent, onOpenChange: onOpenChangeProp, ...viewProps }, ref ) => { const nativeID = React.useId(); const [triggerPosition, setTriggerPosition] = React.useState<LayoutPosition | null>(null); const [contentLayout, setContentLayout] = React.useState<LayoutRectangle | null>(null); const [open, setOpen] = React.useState(false);
function onOpenChange(value: boolean) { setOpen(value); onOpenChangeProp?.(value); }
const Component = asChild ? Slot.View : View; return ( <RootContext.Provider value={{ open, onOpenChange, contentLayout, nativeID, setContentLayout, setTriggerPosition, triggerPosition, }} > <Component ref={ref} {...viewProps} /> </RootContext.Provider> ); });
Root.displayName = 'RootNativeTooltip';
function useTooltipContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Tooltip compound components cannot be rendered outside the Tooltip component'); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange, setTriggerPosition } = useTooltipContext();
const augmentedRef = useAugmentedRef({ ref, methods: { open: () => { onOpenChange(true); augmentedRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); }, close: () => { setTriggerPosition(null); onOpenChange(false); }, }, });
function onPress(ev: GestureResponderEvent) { if (disabled) return; augmentedRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot.Pressable : Pressable; return ( <Component ref={augmentedRef} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> ); });
Trigger.displayName = 'TriggerNativeTooltip';
/** * @warning when using a custom `<PortalHost />`, you might have to adjust the Content's sideOffset to account for nav elements like headers. */function Portal({ forceMount, hostName, children }: PortalProps) { const value = useTooltipContext();
if (!value.triggerPosition) { return null; }
if (!forceMount) { if (!value.open) { return null; } }
return ( <RNPPortal hostName={hostName} name={`${value.nativeID}_portal`}> <RootContext.Provider value={value}>{children}</RootContext.Provider> </RNPPortal> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { const { open, onOpenChange, setContentLayout, setTriggerPosition } = useTooltipContext();
function onPress(ev: GestureResponderEvent) { if (closeOnPress) { setTriggerPosition(null); setContentLayout(null); onOpenChange(false); } OnPressProp?.(ev); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativeTooltip';
/** * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior on native by setting `disablePositioningStyle` to `true`. */const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align = 'center', side = 'top', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, ...props }, ref ) => { const { open, onOpenChange, nativeID, contentLayout, setContentLayout, setTriggerPosition, triggerPosition, } = useTooltipContext();
React.useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { setTriggerPosition(null); setContentLayout(null); onOpenChange(false); return true; });
return () => { setContentLayout(null); backHandler.remove(); }; }, []);
const positionStyle = useRelativePosition({ align, avoidCollisions, triggerPosition, contentLayout, alignOffset, insets, sideOffset, side: getNativeSide(side), disablePositioningStyle, });
function onLayout(event: LayoutChangeEvent) { setContentLayout(event.nativeEvent.layout); onLayoutProp?.(event); }
if (!forceMount) { if (!open) { return null; } }
const Component = asChild ? Slot.View : View; return ( <Component ref={ref} role='tooltip' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativeTooltip';
export { Content, Overlay, Portal, Root, Trigger };
function onStartShouldSetResponder() { return true;}
function getNativeSide(side: 'left' | 'right' | 'top' | 'bottom') { if (side === 'left' || side === 'right') { return 'top'; } return side;}
Copy/paste the following code for types to ~/components/primitives/tooltip/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void; /** * Platform: WEB ONLY * @default 700 */ delayDuration?: number; /** * Platform: WEB ONLY * @default 300 */ skipDelayDuration?: number; /** * Platform: WEB ONLY */ disableHoverableContent?: boolean;};
interface PortalProps extends ForceMountable { children: React.ReactNode; /** * Platform: NATIVE ONLY */ hostName?: string; /** * Platform: WEB ONLY */ container?: HTMLElement | null | undefined;}
type OverlayProps = ForceMountable & SlottablePressableProps & { closeOnPress?: boolean; };
type ContentProps = SlottableViewProps & Omit<PositionedContentProps, 'side'> & { /** * `left` and `right` are only supported on web. */ side?: 'top' | 'right' | 'bottom' | 'left'; };
type TriggerProps = SlottablePressableProps;
type RootRef = ViewRef;type ContentRef = ViewRef;type OverlayRef = PressableRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};
export type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,};
import * as TooltipPrimitive from '@rn-primitives/tooltip';import { Platform, Text } from 'react-native';
function Example() { return ( <TooltipPrimitive.Root> <TooltipPrimitive.Trigger> <Text>{Platform.OS === 'web' ? "Hover me" : "Press me"}</Text> </TooltipPrimitive.Trigger> <TooltipPrimitive.Portal> <TooltipPrimitive.Content> <Text>Tooltip Content</Text> </TooltipPrimitive.Content> </TooltipPrimitive.Portal> </TooltipPrimitive.Root> );}
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
onOpenChange | (value: boolean) => void | (optional) |
delayDuration | number | Web Only - defaults to 700 (optional) |
skipDelayDuration | number | Web Only - defaults to 300 (optional) |
disableHoverableContent | boolean | (optional) |
Trigger
Section titled “Trigger”Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
TYPE: TooltipTriggerRef
Section titled “TYPE: TooltipTriggerRef”Methods | args | Note |
---|---|---|
open | opens the tooltip | |
close | closes the tooltip |
Overlay
Section titled “Overlay”Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined; | (optional) |
closeOnPress | boolean | (optional) |
Portal
Section titled “Portal”Prop | Type | Note |
---|---|---|
children* | React.ReactNode | |
forceMount | true | undefined | (optional) |
hostName | string | Web Only (optional) |
container | HTMLElement | null | undefined | Web Only (optional) |
Content
Section titled “Content”Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined | (optional) |
alignOffset | number | (optional) |
insets | Insets | (optional) |
avoidCollisions | boolean | (optional) |
align | start | center | end | (optional) |
side | top | bottom | left | right | left and right are web only (optional) |
sideOffset | number | (optional) |
disablePositioningStyle | boolean | Native Only (optional) |
collisionBoundary | Element | null | Array<Element | null> | Web Only (optional) |
sticky | partial | always | Web Only (optional) |
hideWhenDetached | boolean | Web Only (optional) |
onEscapeKeyDown | (ev: Event) => void | Web Only (optional) |
onPointerDownOutside | (ev: Event) => void | Web Only (optional) |