Popover Primitive
Dynamic content within a portal, activated by a button press.
Installation
Install the component via your command line.
npx expo install @rn-primitives/popover
Install @radix-ui/react-popover
npx expo install @radix-ui/react-popover
Copy/paste the following code for web to ~/components/primitives/popover/popover.web.tsx
import * as Popover from '@radix-ui/react-popover';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 { 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);
const Root = React.forwardRef<RootRef, RootProps & { onOpenChange?: (open: boolean) => void }>( ({ asChild, 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 }}> <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;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, role: _role, disabled, ...props }, ref) => { const { onOpenChange, open } = useRootContext(); 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 ( <Popover.Trigger disabled={disabled ?? undefined} asChild> <Component ref={augmentedRef} 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} />;}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, ...props }, ref) => { const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} {...props} />; });
Overlay.displayName = 'OverlayWebPopover';
const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, insets: _insets, disablePositioningStyle: _disablePositioningStyle, onCloseAutoFocus, onEscapeKeyDown, onInteractOutside, onPointerDownOutside, onOpenAutoFocus, ...props }, ref ) => { const Component = asChild ? Slot.View : 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';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled, ...props }, ref) => { const augmentedRef = useAugmentedRef({ ref }); const { onOpenChange, open } = useRootContext();
function onPress(ev: GestureResponderEvent) { if (onPressProp) { onPressProp(ev); } onOpenChange(!open); }
useIsomorphicLayoutEffect(() => { if (augmentedRef.current) { const augRef = augmentedRef.current as unknown as HTMLButtonElement; augRef.type = 'button'; } }, []);
const Component = asChild ? Slot.Pressable : Pressable; return ( <> <Popover.Close disabled={disabled ?? undefined} asChild> <Component ref={augmentedRef} 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 { 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 { 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);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, 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 = '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;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { onOpenChange, open, setTriggerPosition } = useRootContext();
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 }); }); onOpenChange(!open); 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 = '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> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { 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 : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativePopover';
/** * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. */const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, onOpenAutoFocus: _onOpenAutoFocus, ...props }, ref ) => { 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 : View; return ( <Component ref={ref} role='dialog' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativePopover';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { 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 : 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 { 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 { 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);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, 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 = '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;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { onOpenChange, open, setTriggerPosition } = useRootContext();
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 }); }); onOpenChange(!open); 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 = '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> );}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>( ({ asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref) => { 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 : Pressable; return <Component ref={ref} onPress={onPress} {...props} />; });
Overlay.displayName = 'OverlayNativePopover';
/** * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. */const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align = 'start', side = 'bottom', sideOffset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, onOpenAutoFocus: _onOpenAutoFocus, ...props }, ref ) => { 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 : View; return ( <Component ref={ref} role='dialog' nativeID={nativeID} aria-modal={true} style={[positionStyle, style]} onLayout={onLayout} onStartShouldSetResponder={onStartShouldSetResponder} {...props} /> ); });
Content.displayName = 'ContentNativePopover';
const Close = React.forwardRef<CloseRef, CloseProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { 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 : 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,};
Usage
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> );}
Props
Root
Extends View
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
onOpenChange | (value: boolean) => void | (optional) |
Trigger
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
TYPE: PopoverTriggerRef
Methods | args | Note |
---|---|---|
open | opens the popover | |
close | closes the popover |
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 | 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) |
Close
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
useRootContext
Must be used within a Root
component. It provides the following values from the dialog: open
, and onOpenChange
.