Anchored bubble with arrow and dismiss on tap-out.
components/ui/popover/popover.tsx@lumioui/core@latestreact-native-reanimated@*react-native-safe-area-context@*| Popover | ||
| children | React.ReactNode | |
| open? | boolean | Controlled open state. |
| defaultOpen? | boolean | Uncontrolled initial state. |
| onOpenChange? | (open: boolean) => void | |
| PopoverTrigger | ||
| children | React.ReactNode | |
| className? | string | |
| PopoverContent | ||
| className? | string | |
| align? | "start" | "center" | "end" | Horizontal alignment relative to the trigger. Default "center". |
| side? | "top" | "bottom" | Which side of the trigger to render on. Default "bottom". |
| sideOffset? | number | Gap between trigger and content. Default 6. |
| contentWidth? | number | Max content width — the panel hugs content up to this cap, then clamps inside the safe window. Default 224. |
Anchored bubble with arrow and dismiss on tap-out.
import { Popover } from "@/components/ui/popover/popover";
export function Example() {
return (
<Popover />
);
} import * as React from "react";
import { createContext, forwardRef, useContext } from "react";
import { Modal, Platform, Pressable, View, useWindowDimensions, type PressableProps, type ViewProps, } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, cancelAnimation } from "react-native-reanimated";
import { spring, timing, useHaptic, usePressAnimation, useReducedMotion, minTouchTarget, StaggeredItem, AnimatedPressable, } from "@lumioui/core";
import { Glass } from "../glass";
import { useSafeAreaInsets } from "react-native-safe-area-context";
interface Anchor {
x: number;
y: number;
width: number;
height: number;
}
interface PopoverCtx {
open: boolean;
setOpen: (open: boolean) => void;
anchor: Anchor | null;
setAnchor: (anchor: Anchor) => void;
}
const Ctx = createContext<PopoverCtx | null>(null);
function usePopover() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("Popover.* must be inside <Popover>");
return ctx;
}
export interface PopoverProps {
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
/**
* Popover — generic anchored floating content.
* Compose with Popover.Trigger and Popover.Content.
*/
function PopoverRoot({ open, defaultOpen = false, onOpenChange, children }: PopoverProps) {
const [internal, setInternal] = React.useState(defaultOpen);
const [anchor, setAnchor] = React.useState<Anchor | null>(null);
const isControlled = open !== undefined;
const value = isControlled ? open : internal;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return (
<Ctx.Provider value={{ open: value, setOpen, anchor, setAnchor }}>
{children}
</Ctx.Provider>
);
}
export interface PopoverTriggerProps extends Omit<PressableProps, "children"> {
children: React.ReactNode;
className?: string;
}
const PopoverTrigger = forwardRef<React.ElementRef<typeof Pressable>, PopoverTriggerProps>(
function PopoverTrigger({ children, className, onPress, onPressIn, onPressOut, ...props }, ref) {
const { open, setOpen, setAnchor } = usePopover();
const haptics = useHaptic();
const press = usePressAnimation();
const innerRef = React.useRef<View>(null);
// The trigger's visual size is whatever the child is — measure it and
// expand hitSlop so the effective target is always ≥ minTouchTarget.
const [triggerSize, setTriggerSize] = React.useState<{ width: number; height: number } | null>(null);
const hitSlop = React.useMemo(() => {
const v = Math.max(4, Math.ceil((minTouchTarget - (triggerSize?.height ?? 0)) / 2));
const h = Math.max(4, Math.ceil((minTouchTarget - (triggerSize?.width ?? 0)) / 2));
return { top: v, bottom: v, left: h, right: h };
}, [triggerSize]);
return (
<AnimatedPressable
ref={(node) => {
innerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
if (!triggerSize || triggerSize.width !== width || triggerSize.height !== height) {
setTriggerSize({ width, height });
}
}}
style={press.animatedStyle}
hitSlop={hitSlop}
onPressIn={(e) => {
press.onPressIn();
onPressIn?.(e);
}}
onPressOut={(e) => {
press.onPressOut();
onPressOut?.(e);
}}
onPress={(e) => {
// Open immediately for responsive feel; measure async for position.
const next = !open;
// Haptic feedback only when the popover opens.
if (next) haptics.light();
// Web fallback: use getBoundingClientRect if measureInWindow is slow/unavailable.
const node = innerRef.current as unknown as
| { getBoundingClientRect?: () => { left: number; top: number; width: number; height: number } }
| null;
if (node?.getBoundingClientRect) {
const r = node.getBoundingClientRect();
setAnchor({ x: r.left, y: r.top, width: r.width, height: r.height });
} else {
innerRef.current?.measureInWindow((x, y, width, height) => {
setAnchor({ x, y, width, height });
});
}
setOpen(next);
onPress?.(e);
}}
className={className}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
// accessibilityState isn't forwarded to the DOM on web — mirror it.
aria-expanded={open}
aria-haspopup
accessibilityHint="Opens a popover"
{...props}
>
{children}
</AnimatedPressable>
);
}
);
export interface PopoverContentProps extends ViewProps {
className?: string;
/** Horizontal alignment relative to the trigger. Default "center". */
align?: "start" | "center" | "end";
/** Which side of the trigger to render on. Default "bottom". */
side?: "top" | "bottom";
/** Gap between trigger and content. Default 6. */
sideOffset?: number;
/** Max content width — the panel hugs content up to this cap, then clamps
* inside the safe window. Default 224. */
contentWidth?: number;
}
const PopoverContent = forwardRef<React.ElementRef<typeof View>, PopoverContentProps>(
function PopoverContent(
{ className, align = "center", side = "bottom", sideOffset = 6, contentWidth = 224, children, onLayout, ...props },
ref
) {
const { open, setOpen, anchor } = usePopover();
const { width: winW, height: winH } = useWindowDimensions();
const insets = useSafeAreaInsets();
const haptics = useHaptic();
const reduceMotion = useReducedMotion();
// Measured panel size — the panel hugs content up to contentWidth, so
// alignment and clamping need the real laid-out size, not the cap.
const [panel, setPanel] = React.useState({ width: 0, height: 0 });
// Keep the Modal mounted through the exit fade so the recede is visible.
const [rendered, setRendered] = React.useState(false);
const scale = useSharedValue(0.92);
const opacity = useSharedValue(0);
// Small settle-drift toward the anchor — pairs with the origin-anchored
// scale so the panel reads as unfolding out of the trigger.
const drift = useSharedValue(0);
const driftX = useSharedValue(0);
// Drift start points are captured during render into a ref so panel
// measurement (which re-renders) doesn't replay the entrance.
const driftFrom = React.useRef({ x: 0, y: 0 });
const closeTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
if (open) {
setRendered(true);
if (closeTimeout.current) clearTimeout(closeTimeout.current);
// The trigger measures async, so on the first open `open` can flip
// before the anchor lands — wait for it so the entrance plays from
// the real origin instead of a settled panel blinking in.
if (!anchor) return;
// Reduced motion: skip the unfold scale + drift, keep the fade.
if (reduceMotion) {
scale.value = 1;
drift.value = 0;
driftX.value = 0;
} else {
scale.value = withSpring(1, spring.surface);
// Reset then settle so each open replays the drift.
drift.value = driftFrom.current.y;
drift.value = withSpring(0, spring.surface);
driftX.value = driftFrom.current.x;
driftX.value = withSpring(0, spring.surface);
}
opacity.value = withTiming(1, timing.fast);
return;
}
// timing.recede (~120ms) — faster and smaller than the open pop:
// exits fade out instead of collapsing.
opacity.value = withTiming(0, timing.recede);
// Reduced motion keeps the fade but drops the recede travel.
scale.value = reduceMotion ? 1 : withTiming(0.96, timing.recede);
drift.value = reduceMotion ? 0 : withTiming(driftFrom.current.y, timing.recede);
driftX.value = reduceMotion ? 0 : withTiming(driftFrom.current.x, timing.recede);
if (closeTimeout.current) clearTimeout(closeTimeout.current);
closeTimeout.current = setTimeout(() => setRendered(false), timing.recede.duration);
}, [open, anchor, scale, opacity, drift, driftX, reduceMotion, side]);
React.useEffect(
() => () => {
if (closeTimeout.current) clearTimeout(closeTimeout.current);
cancelAnimation(scale);
cancelAnimation(opacity);
cancelAnimation(drift);
cancelAnimation(driftX);
},
[scale, opacity, drift, driftX]
);
// Close haptic fires on dismiss paths (backdrop tap / hardware back),
// mirroring dropdown-menu — item presses keep their own haptics.
const close = () => {
haptics.selection();
setOpen(false);
};
// Panel hugs its content, capped by contentWidth and the safe window.
const maxW = Math.min(contentWidth, winW - 16 - insets.left - insets.right);
const panelW = panel.width > 0 ? Math.min(panel.width, maxW) : maxW;
const panelH = panel.height;
const anchorCX = anchor ? anchor.x + anchor.width / 2 : winW / 2;
let left = 8 + insets.left;
let top = insets.top + 8;
if (anchor) {
if (align === "start") left = anchor.x;
else if (align === "end") left = anchor.x + anchor.width - panelW;
else left = anchorCX - panelW / 2;
left = Math.max(8 + insets.left, Math.min(left, winW - panelW - 8 - insets.right));
top =
side === "bottom"
? // Clamp the panel's bottom edge (top + panelH) against the lower
// safe area — clamping top alone let tall content run offscreen.
Math.max(
insets.top + 8,
Math.min(anchor.y + anchor.height + sideOffset, winH - insets.bottom - 8 - panelH)
)
: Math.max(insets.top + 8, anchor.y - sideOffset - panelH);
}
// Scale origin — the anchor's position inside the panel, clamped so a
// clamped panel still grows from its nearest edge (context-menu pattern).
const originXFrac = anchor && panelW > 0 ? Math.max(0, Math.min(1, (anchorCX - left) / panelW)) : 0.5;
const originYFrac = side === "bottom" ? 0 : 1;
driftFrom.current = {
x: (originXFrac - 0.5) * 16,
y: side === "bottom" ? -6 : 6,
};
const contentStyle = useAnimatedStyle(() => {
if (Platform.OS === "web") {
return {
opacity: opacity.value,
// Grow from the anchor edge, not the center — reads as the panel
// unfolding out of the trigger.
transformOrigin: `${originXFrac * 100}% ${originYFrac * 100}%`,
transform: [{ scale: scale.value }, { translateX: driftX.value }, { translateY: drift.value }],
};
}
// transformOrigin is web-only — on native, emulate the same
// anchor-edge scale by translating the panel from its center toward
// the origin point by (origin - center) * (1 - scale), plus the
// settle-drift. The panel genuinely unfolds out of the trigger.
const s = scale.value;
return {
opacity: opacity.value,
transform: [
{ translateX: (originXFrac - 0.5) * panelW * (1 - s) + driftX.value },
{ translateY: (originYFrac - 0.5) * panelH * (1 - s) + drift.value },
{ scale: s },
],
};
});
if (!anchor) return null;
return (
<Modal
visible={rendered}
transparent
animationType="none"
onRequestClose={close}
statusBarTranslucent
>
<View className="flex-1" style={{ width: "100%", height: "100%" }}>
<Pressable
className="flex-1"
style={{ width: "100%", height: "100%" }}
onPress={close}
accessibilityLabel="Close popover" accessibilityRole="button"
/>
<Animated.View
ref={ref}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout;
if (width !== panel.width || height !== panel.height) {
setPanel({ width, height });
}
onLayout?.(e);
}}
// Claim taps that land on dead space inside the panel (padding,
// non-interactive content) so they can't fall through to the
// backdrop and dismiss. Children still claim first — this only
// catches touches nothing inside handled.
onStartShouldSetResponder={() => true}
style={[{ position: "absolute", top, left, maxWidth: maxW }, contentStyle]}
className={className}
accessibilityViewIsModal
// RN's accessibilityRole enum has no "dialog" — `role` carries
// the precise semantics (web + supported native mappings) and
// aria-modal marks the panel as the modal surface on web. Kept
// "menu" off: popover content is generic, not menu-only.
role="dialog"
aria-modal
{...props}
>
{/* Liquid-glass panel — real iOS 26 material when installed. */}
<Glass
effect="regular"
className="rounded-2xl border border-white/20 p-4 shadow-lg"
>
{React.Children.toArray(children).map((child, i) => (
<StaggeredItem key={i} index={i} active={open}>
{child}
</StaggeredItem>
))}
</Glass>
</Animated.View>
</View>
</Modal>
);
}
);
export const Popover = Object.assign(PopoverRoot, {
Trigger: PopoverTrigger,
Content: PopoverContent,
});
PopoverRoot.displayName = "Popover";
PopoverTrigger.displayName = "PopoverTrigger";
PopoverContent.displayName = "PopoverContent";