Popover Primitive
Dynamic content within a portal, activated by a button press.
Installation
Section titled “Installation”Install the component via your command line.
npx expo install @rn-primitives/popoverInstall @radix-ui/react-popover
npx expo install @radix-ui/react-popoverCopy/paste the following code for web to ~/components/primitives/popover/popover.web.tsx
import * as Popover from '@radix-ui/react-popover';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 { CloseProps, CloseRef, 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 & { onOpenChange?: (open: boolean) => void;} & React.RefAttributes<RootRef>;
const Root = ({ asChild, 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 }}> <Popover.Root open={open} onOpenChange={onOpenChange}> <Component ref={ref} {...viewProps} /> </Popover.Root> </RootContext.Provider> );};
Root.displayName = 'RootWebPopover';
function useRootContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Popover compound components cannot be rendered outside the Popover component'); } return context;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, role: _role, disabled, ref, ...props}: TriggerComponentProps) => { const { onOpenChange, open } = useRootContext(); 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 ( <Popover.Trigger disabled={disabled ?? undefined} asChild> <Component ref={composedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Popover.Trigger> );};
Trigger.displayName = 'TriggerWebPopover';
function Portal({ forceMount, container, children }: PortalProps) { return <Popover.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 = 'OverlayWebPopover';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, insets: _insets, disablePositioningStyle: _disablePositioningStyle, onCloseAutoFocus, onEscapeKeyDown, onInteractOutside, onPointerDownOutside, onOpenAutoFocus, ref, ...props}: ContentComponentProps) => { const Component = asChild ? Slot : View; return ( <Popover.Content onCloseAutoFocus={onCloseAutoFocus} onEscapeKeyDown={onEscapeKeyDown} onInteractOutside={onInteractOutside} onPointerDownOutside={onPointerDownOutside} forceMount={forceMount} align={align} side={side} sideOffset={sideOffset} alignOffset={alignOffset} avoidCollisions={avoidCollisions} onOpenAutoFocus={onOpenAutoFocus} > <Component ref={ref} {...props} /> </Popover.Content> );};
Content.displayName = 'ContentWebPopover';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled, ref, ...props }: CloseComponentProps) => { const closeRef = React.useRef<CloseRef>(null); const composedRef = useComposedRefs(ref, closeRef); const { onOpenChange, open } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (closeRef.current) { const augRef = closeRef.current as unknown as HTMLButtonElement; augRef.type = 'button'; } }, []);
const Component = asChild ? Slot : Pressable; return ( <> <Popover.Close disabled={disabled ?? undefined} asChild> <Component ref={composedRef} onPress={onPress} role='button' disabled={disabled} {...props} /> </Popover.Close> </> );};
Close.displayName = 'CloseWebPopover';
export { Close, Content, Overlay, Portal, Root, Trigger, useRootContext };Copy/paste the following code for native to ~/components/primitives/popover/popover.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 { CloseProps, CloseRef, 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, 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 = 'RootNativePopover';
function useRootContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Popover compound components cannot be rendered outside the Popover component'); } return context;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { onOpenChange, open, setTriggerPosition } = useRootContext(); 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(); onOpenChange(!open); 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 = 'TriggerNativePopover';
/** * @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 = useRootContext();
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, setTriggerPosition, setContentLayout } = useRootContext();
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 = 'OverlayNativePopover';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, onOpenAutoFocus: _onOpenAutoFocus, ref, ...props}: ContentComponentProps) => { const { open, onOpenChange, contentLayout, nativeID, setContentLayout, setTriggerPosition, triggerPosition, } = useRootContext();
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, 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='dialog' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> );};
Content.displayName = 'ContentNativePopover';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: CloseComponentProps) => { const { onOpenChange, setContentLayout, setTriggerPosition } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; setTriggerPosition(null); setContentLayout(null); onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> );};
Close.displayName = 'CloseNativePopover';
export { Close, Content, Overlay, Portal, Root, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}Copy/paste the following code for types to ~/components/primitives/popover/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void };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 TriggerProps = SlottablePressableProps;type ContentProps = SlottableViewProps & PositionedContentProps & { /** * Platform: WEB ONLY */ onOpenAutoFocus?: (event: Event) => void; };type CloseProps = SlottablePressableProps;
type CloseRef = PressableRef;type ContentRef = ViewRef;type OverlayRef = PressableRef;type RootRef = ViewRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};
export type { CloseProps, CloseRef, ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,};Copy/paste the following code for exporting to ~/components/primitives/popover/index.ts
export * from './popover';export * from './types';Copy/paste the following code for native to ~/components/primitives/popover/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 { CloseProps, CloseRef, 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, 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 = 'RootNativePopover';
function useRootContext() { const context = React.useContext(RootContext); if (!context) { throw new Error('Popover compound components cannot be rendered outside the Popover component'); } return context;}type TriggerComponentProps = TriggerProps & React.RefAttributes<TriggerRef>;
const Trigger = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: TriggerComponentProps) => { const { onOpenChange, open, setTriggerPosition } = useRootContext(); 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(); onOpenChange(!open); 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 = 'TriggerNativePopover';
/** * @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 = useRootContext();
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, setTriggerPosition, setContentLayout } = useRootContext();
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 = 'OverlayNativePopover';type ContentComponentProps = ContentProps & React.RefAttributes<ContentRef>;
const Content = ({ asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, onOpenAutoFocus: _onOpenAutoFocus, ref, ...props}: ContentComponentProps) => { const { open, onOpenChange, contentLayout, nativeID, setContentLayout, setTriggerPosition, triggerPosition, } = useRootContext();
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, 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='dialog' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> );};
Content.displayName = 'ContentNativePopover';type CloseComponentProps = CloseProps & React.RefAttributes<CloseRef>;
const Close = ({ asChild, onPress: onPressProp, disabled = false, ref, ...props}: CloseComponentProps) => { const { onOpenChange, setContentLayout, setTriggerPosition } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (disabled) return; setTriggerPosition(null); setContentLayout(null); onOpenChange(false); onPressProp?.(ev); }
const Component = asChild ? Slot : Pressable; return ( <Component ref={ref} aria-disabled={disabled ?? undefined} role='button' onPress={onPress} disabled={disabled ?? undefined} {...props} /> );};
Close.displayName = 'CloseNativePopover';
export { Close, Content, Overlay, Portal, Root, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}Copy/paste the following code for types to ~/components/primitives/popover/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void };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 TriggerProps = SlottablePressableProps;type ContentProps = SlottableViewProps & PositionedContentProps & { /** * Platform: WEB ONLY */ onOpenAutoFocus?: (event: Event) => void; };type CloseProps = SlottablePressableProps;
type CloseRef = PressableRef;type ContentRef = ViewRef;type OverlayRef = PressableRef;type RootRef = ViewRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};
export type { CloseProps, CloseRef, ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef,};import * as PopoverPrimitive from '@rn-primitives/popover';import { Text } from 'react-native';
function Example() { return ( <PopoverPrimitive.Root> <PopoverPrimitive.Trigger> <Text>Open Popover</Text> </PopoverPrimitive.Trigger> <PopoverPrimitive.Portal> <PopoverPrimitive.Content> <Text>Popover Content</Text> </PopoverPrimitive.Content> </PopoverPrimitive.Portal> </PopoverPrimitive.Root> );}Extends View props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
| onOpenChange | (value: boolean) => void | (optional) |
Trigger
Section titled “Trigger”Extends Pressable props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
TYPE: TriggerRef
Section titled “TYPE: TriggerRef”| Methods | args | Note |
|---|---|---|
| open | opens the popover | |
| close | closes the popover |
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 | Native Only (optional) |
| insets | Insets | Native Only (optional) |
| avoidCollisions | boolean | Native Only (optional) |
| align | ’start’ | ‘center’ | ‘end’ | Native Only (optional) |
| side | ’top’ | ‘bottom’ | Native Only (optional) |
| sideOffset | number | Native Only (optional) |
| disablePositioningStyle | boolean | Native Only (optional) |
| onOpenAutoFocus | (ev: Event) => void | Web Only (optional) |
| onCloseAutoFocus | (ev: Event) => void | Web Only (optional) |
| onEscapeKeyDown | (ev: Event) => void | Web Only (optional) |
| onInteractOutside | (ev: Event) => void | Web Only (optional) |
| onPointerDownOutside | (ev: Event) => void | Web Only (optional) |
Extends Pressable props
| Prop | Type | Note |
|---|---|---|
| asChild | boolean | (optional) |
useRootContext
Section titled “useRootContext”Must be used within a Root component. It provides the following values from the dialog: open, and onOpenChange.