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
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
import * as Tooltip from '@radix-ui/react-tooltip';import { useAugmentedRef, useIsomorphicLayoutEffect } from '~/components/primitives/hooks';import * as 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);
const Root = React.forwardRef<RootRef, RootProps>( ( { asChild, delayDuration, skipDelayDuration, disableHoverableContent, onOpenChange: onOpenChangeProp, ...viewProps }, ref ) => { 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 }}> <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;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, role: _role, disabled, ...props }, ref) => { const { onOpenChange, open } = useTooltipContext(); const augmentedRef = useAugmentedRef({ ref, methods: { open() { onOpenChange(true); }, close() { onOpenChange(false); }, }, }); function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLButtonElement; augRef.dataset.state = open ? 'open' : 'closed'; augRef.type = 'button'; } }, [open]);
const Component = asChild ? Slot.Pressable : Pressable; return ( <Tooltip.Trigger disabled={disabled ?? undefined} asChild> <Component ref={augmentedRef} 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} />;}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, ...props }, ref) => { const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} {...props} />; });
Overlay.displayName = 'OverlayWebTooltip';
const Content = React.forwardRef<ContentRef, ContentProps>( ( { 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, ...props }, ref ) => { const Component = asChild ? Slot.View : 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 { 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,};
Usage
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> );}
Props
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
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
TYPE: TooltipTriggerRef
Methods | args | Note |
---|---|---|
open | opens the tooltip | |
close | closes the tooltip |
Overlay
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined; | (optional) |
closeOnPress | boolean | (optional) |
Portal
Prop | Type | Note |
---|---|---|
children* | React.ReactNode | |
forceMount | true | undefined | (optional) |
hostName | string | Web Only (optional) |
container | HTMLElement | null | undefined | Web Only (optional) |
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) |