Hover Card Primitive
Allows users with vision to preview the content hidden behind an element before hovering or pressing.
Installation
Section titled “Installation”Install the component via your command line.
npx expo install @rn-primitives/hover-card
Install @radix-ui/react-hover-card
npx expo install @radix-ui/react-hover-card
Copy/paste the following code for web to ~/components/primitives/hover-card/hover-card.web.tsx
import * as HoverCard from '@radix-ui/react-hover-card';import { useAugmentedRef } from '~/components/primitives/hooks';import * as Slot from '~/components/primitives/slot';import * as React from 'react';import { Pressable, View } from 'react-native';import type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, SharedRootContext, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
const HoverCardContext = React.createContext<SharedRootContext | null>(null);
const Root = React.forwardRef<RootRef, RootProps>( ({ asChild, openDelay, closeDelay, 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 ( <HoverCardContext.Provider value={{ open, onOpenChange }}> <HoverCard.Root open={open} onOpenChange={onOpenChange} openDelay={openDelay} closeDelay={closeDelay} > <Component ref={ref} {...viewProps} /> </HoverCard.Root> </HoverCardContext.Provider> ); });
Root.displayName = 'RootWebHoverCard';
function useRootContext() { const context = React.useContext(HoverCardContext); if (!context) { throw new Error( 'HoverCard compound components cannot be rendered outside the HoverCard component' ); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>(({ asChild, ...props }, ref) => { const { onOpenChange } = useRootContext(); const augmentedRef = useAugmentedRef({ ref, methods: { open() { onOpenChange(true); }, close() { onOpenChange(false); }, }, });
const Component = asChild ? Slot.Pressable : Pressable; return ( <HoverCard.Trigger asChild> <Component ref={augmentedRef} {...props} /> </HoverCard.Trigger> );});
Trigger.displayName = 'TriggerWebHoverCard';
function Portal({ forceMount, container, children }: PortalProps) { return <HoverCard.Portal forceMount={forceMount} container={container} children={children} />;}
const Overlay = React.forwardRef<OverlayRef, OverlayProps>(({ asChild, ...props }, ref) => { const Component = asChild ? Slot.Pressable : Pressable; return <Component ref={ref} {...props} />;});
Overlay.displayName = 'OverlayWebHoverCard';
const Content = React.forwardRef<ContentRef, ContentProps>( ( { asChild = false, forceMount, align, side, sideOffset, alignOffset = 0, avoidCollisions = true, insets, loop: _loop, onCloseAutoFocus: _onCloseAutoFocus, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, collisionBoundary, sticky, hideWhenDetached, ...props }, ref ) => { const Component = asChild ? Slot.Pressable : Pressable; return ( <HoverCard.Content forceMount={forceMount} alignOffset={alignOffset} avoidCollisions={avoidCollisions} collisionPadding={insets} onEscapeKeyDown={onEscapeKeyDown} onPointerDownOutside={onPointerDownOutside} onFocusOutside={onFocusOutside} onInteractOutside={onInteractOutside} collisionBoundary={collisionBoundary} sticky={sticky} hideWhenDetached={hideWhenDetached} align={align} side={side} sideOffset={sideOffset} > <Component ref={ref} {...props} /> </HoverCard.Content> ); });
Content.displayName = 'ContentWebHoverCard';
export { Content, Overlay, Portal, Root, Trigger, useRootContext };
Copy/paste the following code for native to ~/components/primitives/hover-card/hover-card.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, SharedRootContext, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
interface IRootContext extends SharedRootContext { 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, openDelay: _openDelay, closeDelay: _closeDelay, 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 = 'RootNativeHoverCard';
function useRootContext() { const context = React.useContext(RootContext); if (!context) { throw new Error( 'HoverCard compound components cannot be rendered outside the HoverCard component' ); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange, 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 = 'TriggerNativeHoverCard';
/** * @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 = 'OverlayNativeHoverCard';
/** * @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, ...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 = 'ContentNativeHoverCard';
export { Content, Overlay, Portal, Root, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}
Copy/paste the following code for types to ~/components/primitives/hover-card/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
interface SharedRootContext { open: boolean; onOpenChange: (value: boolean) => void; openDelay?: number; closeDelay?: number;}
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void; /** * Platform: WEB ONLY * @default 700 */ openDelay?: number; /** * Platform: WEB ONLY * @default 300 */ closeDelay?: number;};
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;
type OverlayRef = PressableRef;type RootRef = ViewRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};type ContentRef = ViewRef;
export type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, SharedRootContext, RootProps, RootRef, TriggerProps, TriggerRef,};
Copy/paste the following code for exporting to ~/components/primitives/hover-card/index.ts
export * from './hover-card';export * from './types';
Copy/paste the following code for native to ~/components/primitives/hover-card/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, SharedRootContext, RootProps, RootRef, TriggerProps, TriggerRef,} from './types';
interface IRootContext extends SharedRootContext { 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, openDelay: _openDelay, closeDelay: _closeDelay, 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 = 'RootNativeHoverCard';
function useRootContext() { const context = React.useContext(RootContext); if (!context) { throw new Error( 'HoverCard compound components cannot be rendered outside the HoverCard component' ); } return context;}
const Trigger = React.forwardRef<TriggerRef, TriggerProps>( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { open, onOpenChange, 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 = 'TriggerNativeHoverCard';
/** * @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 = 'OverlayNativeHoverCard';
/** * @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, ...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 = 'ContentNativeHoverCard';
export { Content, Overlay, Portal, Root, Trigger, useRootContext };
function onStartShouldSetResponder() { return true;}
Copy/paste the following code for types to ~/components/primitives/hover-card/types.ts
import type { ForceMountable, PositionedContentProps, PressableRef, SlottablePressableProps, SlottableViewProps, ViewRef,} from '~/components/primitives/types';
interface SharedRootContext { open: boolean; onOpenChange: (value: boolean) => void; openDelay?: number; closeDelay?: number;}
type RootProps = SlottableViewProps & { onOpenChange?: (open: boolean) => void; /** * Platform: WEB ONLY * @default 700 */ openDelay?: number; /** * Platform: WEB ONLY * @default 300 */ closeDelay?: number;};
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;
type OverlayRef = PressableRef;type RootRef = ViewRef;type TriggerRef = PressableRef & { open: () => void; close: () => void;};type ContentRef = ViewRef;
export type { ContentProps, ContentRef, OverlayProps, OverlayRef, PortalProps, SharedRootContext, RootProps, RootRef, TriggerProps, TriggerRef,};
import * as HoverCardPrimitive from '@rn-primitives/hover-card';import { Text, View } from 'react-native';
function Example() { return ( <HoverCardPrimitive.Root> <HoverCardPrimitive.Trigger> <Text>@nextjs</Text> </HoverCardPrimitive.Trigger> <HoverCardPrimitive.Content> <View> <Text>@nextjs</Text> <Text> The React Framework – created and maintained by @vercel. </Text> <View> <Text> Joined December 2021 </Text> </View> </View> </HoverCardPrimitive.Content> </HoverCardPrimitive.Root> );}
Extends View
props
Prop | Type | Note |
---|---|---|
onOpenChange | (value: boolean) => void | (optional) |
asChild | boolean | (optional) |
relativeTo | ’longPress’ | ‘trigger’ | Native Only_(optional)_ |
Trigger
Section titled “Trigger”Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
TYPE: HoverCardTriggerRef
Section titled “TYPE: HoverCardTriggerRef”Methods | args | Note |
---|---|---|
open | opens the hover card | |
close | closes the hover card |
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) |
Overlay
Section titled “Overlay”Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined; | (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’ | (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) |
useRootContext
Section titled “useRootContext”Must be used within a Root
component. It provides the following values from the dropdown menu: open
, and onOpenChange
.