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/tooltipInstall @radix-ui/react-tooltip
npx expo install @radix-ui/react-tooltipCopy/paste the following code for web to ~/components/primitives/tooltip/tooltip.web.tsx
import * as Tooltip from '@radix-ui/react-tooltip';import { useComposedRefs, useEffectEvent, useIsomorphicLayoutEffect } from '~/components/primitives/hooks';import { Slot } from '~/components/primitives/slot';import * as React from 'react';import { Pressable, View, type GestureResponderEvent } from 'react-native';import type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
const RootContext = React.createContext<{ open: boolean; onOpenChange: (open: boolean) => void;} | null>(null);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, delayDuration, skipDelayDuration, disableHoverableContent, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { const [open, setOpen] = React.useState(false);
function onOpenChange(value: boolean) { setOpen(value); onOpenChangeProp?.(value); }
const Component = asChild ? Slot : View; return ( <RootContext.Provider value={{ open, onOpenChange }}> <Tooltip.Provider delayDuration={delayDuration} skipDelayDuration={skipDelayDuration} disableHoverableContent={disableHoverableContent} > <Tooltip.Root open={open} onOpenChange={onOpenChange} delayDuration={delayDuration} disableHoverableContent={disableHoverableContent} > <Component ref={ref} {...viewProps} /> </Tooltip.Root> </Tooltip.Provider> </RootContext.Provider> );};
Root.displayName = 'RootWebTooltip';
function useTooltipContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Tooltip compound components cannot be rendered outside the Tooltip component'); } return context;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, role: _role, disabled, ref, ...props}: TriggerComponentProps) => { const { onOpenChange, open } = useTooltipContext(); const triggerRef = React.useRef<TriggerRef>(null);
const openTriggerEvent = useEffectEvent(() => { onOpenChange(true); }); const closeTriggerEvent = useEffectEvent(() => { onOpenChange(false); }); const composedRef = useComposedRefs( triggerRef, ref, React.useCallback((node: TriggerRef | null) => { if (!node) return; node.open = () => openTriggerEvent(); node.close = () => closeTriggerEvent(); }, []) ); function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (triggerRef.current) { const augRef = triggerRef.current as unknown as HTMLButtonElement; augRef.dataset.state = open ? 'open' : 'closed'; augRef.type = 'button'; } }, [open]);
const Component = asChild ? Slot : Pressable; return ( <Tooltip.Trigger disabled={disabled ?? undefined} asChild> <Component ref={composedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Tooltip.Trigger> );};
Trigger.displayName = 'TriggerWebTooltip';
function Portal({ forceMount, container, children }: PortalProps) { return <Tooltip.Portal forceMount={forceMount} children={children} container={container} />;}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, ref, ...props }: OverlayComponentProps) => { const Component = asChild ? Slot : Pressable; return <Component ref={ref} {...props} />;};
Overlay.displayName = 'OverlayWebTooltip';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'center', side = 'top', sideOffset = 0, alignOffset = 0, avoidCollisions = true, insets: _insets, disablePositioningStyle: _disablePositioningStyle, onCloseAutoFocus: _onCloseAutoFocus, onEscapeKeyDown, onInteractOutside: _onInteractOutside, onPointerDownOutside, sticky, hideWhenDetached, ref, ...props}: ContentComponentProps) => { const Component = asChild ? Slot : View; return ( <Tooltip.Content onEscapeKeyDown={onEscapeKeyDown} onPointerDownOutside={onPointerDownOutside} forceMount={forceMount} align={align} side={side} sideOffset={sideOffset} alignOffset={alignOffset} avoidCollisions={avoidCollisions} sticky={sticky} hideWhenDetached={hideWhenDetached} > <Component ref={ref} {...props} /> </Tooltip.Content> );};
Content.displayName = 'ContentWebTooltip';
export { Content, Overlay, Portal, Root, Trigger };Copy/paste the following code for native to ~/components/primitives/tooltip/tooltip.tsx
import { useComposedRefs, useEffectEvent, useRelativePosition, type LayoutPosition,} from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import { 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);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, delayDuration: _delayDuration, skipDelayDuration: _skipDelayDuration, disableHoverableContent: _disableHoverableContent, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { 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; 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;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { open, onOpenChange, setTriggerPosition } = useTooltipContext(); const triggerRef = React.useRef<TriggerRef>(null);
function measureTrigger() { triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); }
const openTriggerEvent = useEffectEvent(() => { onOpenChange(true); measureTrigger(); }); const closeTriggerEvent = useEffectEvent(() => { setTriggerPosition(null); onOpenChange(false); }); const composedRef = useComposedRefs( triggerRef, ref, React.useCallback((node: TriggerRef | null) => { if (!node) return; node.open = () => openTriggerEvent(); node.close = () => closeTriggerEvent(); }, []) );
function onPress(ev: GestureResponderEvent) { if (disabled) return; measureTrigger(); const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={composedRef} 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> );}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ref, ...props}: OverlayComponentProps) => { 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; return <Component ref={ref} onPress={onPress} {...props} />;};
Overlay.displayName = 'OverlayNativeTooltip';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'center', side = 'top', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, ref, ...props}: ContentComponentProps) => { 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; 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 { useComposedRefs, useEffectEvent, useRelativePosition, type LayoutPosition,} from '~/components/primitives/hooks';import { Portal as RNPPortal } from '~/components/primitives/portal';import { 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);type RootComponentProps = RootProps & React.RefAttributes<RootRef>;
const Root = ({ asChild, delayDuration: _delayDuration, skipDelayDuration: _skipDelayDuration, disableHoverableContent: _disableHoverableContent, onOpenChange: onOpenChangeProp, ref, ...viewProps}: RootComponentProps) => { 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; 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;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { open, onOpenChange, setTriggerPosition } = useTooltipContext(); const triggerRef = React.useRef<TriggerRef>(null);
function measureTrigger() { triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); }
const openTriggerEvent = useEffectEvent(() => { onOpenChange(true); measureTrigger(); }); const closeTriggerEvent = useEffectEvent(() => { setTriggerPosition(null); onOpenChange(false); }); const composedRef = useComposedRefs( triggerRef, ref, React.useCallback((node: TriggerRef | null) => { if (!node) return; node.open = () => openTriggerEvent(); node.close = () => closeTriggerEvent(); }, []) );
function onPress(ev: GestureResponderEvent) { if (disabled) return; measureTrigger(); const newValue = !open; onOpenChange(newValue); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={composedRef} 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> );}type OverlayComponentProps = OverlayProps & React.RefAttributes<OverlayRef>;
const Overlay = ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ref, ...props}: OverlayComponentProps) => { 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; return <Component ref={ref} onPress={onPress} {...props} />;};
Overlay.displayName = 'OverlayNativeTooltip';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'center', side = 'top', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, ref, ...props}: ContentComponentProps) => { 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; 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) |