Hover Card Primitive
Allows users with vision to preview the content hidden behind an element before hovering or pressing.
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,};
Usage
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> );}
Props
Root
Extends View
props
Prop | Type | Note |
---|---|---|
onOpenChange | (value: boolean) => void | (optional) |
asChild | boolean | (optional) |
relativeTo | ’longPress’ | ‘trigger’ | Native Only_(optional)_ |
Trigger
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
TYPE: HoverCardTriggerRef
Methods | args | Note |
---|---|---|
open | opens the hover card | |
close | closes the hover card |
Portal
Prop | Type | Note |
---|---|---|
children* | React.ReactNode | |
forceMount | true | undefined | (optional) |
hostName | string | Web Only (optional) |
container | HTMLElement | null | undefined | Web Only (optional) |
Overlay
Extends Pressable
props
Prop | Type | Note |
---|---|---|
asChild | boolean | (optional) |
forceMount | true | undefined; | (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’ | (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
Must be used within a Root
component. It provides the following values from the dropdown menu: open
, and onOpenChange
.