Gooey Popover
Skia gooey popover that morphs its trigger into the content panel.
Popover content goes here.
Web preview — components render natively on iOS & Android
import { GooeyPopover, GooeyPopoverTrigger, GooeyPopoverContent } from "@/components/ui/gooey-popover";
import { Text } from "react-native";
<GooeyPopover side="bottom" align="center">
<GooeyPopoverTrigger>Options</GooeyPopoverTrigger>
<GooeyPopoverContent className="min-w-[210px]">
<Text className="text-sm text-foreground">Popover content goes here.</Text>
</GooeyPopoverContent>
</GooeyPopover>Installation#
npx @aniui/cli add gooey-popoverUsage#
import { GooeyPopover, GooeyPopoverTrigger, GooeyPopoverContent } from "@/components/ui/gooey-popover";
import { Text } from "react-native";
<GooeyPopover side="bottom" align="center">
<GooeyPopoverTrigger>Options</GooeyPopoverTrigger>
<GooeyPopoverContent className="min-w-[210px]">
<Text className="text-sm text-foreground">Popover content goes here.</Text>
</GooeyPopoverContent>
</GooeyPopover>Source#
components/ui/gooey-popover.tsx
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Dimensions, Pressable, Text, View, useWindowDimensions, type LayoutChangeEvent, type View as RNView } from "react-native";
import { Blur, Canvas, ColorMatrix, Group, Paint, RoundedRect } from "@shopify/react-native-skia";
import Animated, {
interpolate,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withSpring,
type SharedValue,
type WithSpringConfig,
} from "react-native-reanimated";
import { cn } from "@/lib/utils";
import { useThemeColors } from "@/components/ui/theme-provider";
type GooeyPopoverSide = "top" | "bottom";
type GooeyPopoverAlign = "start" | "center" | "end";
type GooeyPopoverRect = { x: number; y: number; w: number; h: number; r: number };
type GooeyPopoverGeo = {
layerW: number;
layerH: number;
left: number;
top: number;
trigger: GooeyPopoverRect;
panel: GooeyPopoverRect;
};
const OPEN_SPRING: WithSpringConfig = { stiffness: 115, damping: 18, mass: 0.4 };
const CLOSE_SPRING: WithSpringConfig = { stiffness: 115, damping: 18, mass: 0.4 };
const PRESS_IN_SPRING: WithSpringConfig = { stiffness: 400, damping: 26, mass: 0.5 };
const PRESS_OUT_SPRING: WithSpringConfig = { stiffness: 260, damping: 16, mass: 0.5 };
// Same alpha-threshold trick used elsewhere in this family: blur two shapes, then
// sharpen alpha so they fuse into a single blob while they overlap.
const GOO_MATRIX = [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 22, -11];
type GooeyPopoverClamp = { screenW: number; triggerWinX: number; margin: number };
function buildGeo(
triggerW: number,
triggerH: number,
panelW: number,
panelH: number,
side: GooeyPopoverSide,
align: GooeyPopoverAlign,
gap: number,
panelRadius: number,
clamp: GooeyPopoverClamp | null,
): GooeyPopoverGeo {
const py = side === "bottom" ? triggerH + gap : -(gap + panelH);
const idealPx = align === "start" ? 0 : align === "end" ? triggerW - panelW : (triggerW - panelW) / 2;
// A wide (e.g. two-line) panel centered under a narrow trigger can overhang
// past the screen edge. Once we know the trigger's actual on-screen X (via
// measureInWindow, set once GooeyPopoverTrigger has laid out), slide the
// panel back on-screen — same clamp shape as unfold-menu's clampToScreen.
let px = idealPx;
if (clamp) {
const { screenW, triggerWinX, margin } = clamp;
const minPx = margin - triggerWinX;
const maxPx = screenW - margin - panelW - triggerWinX;
px = Math.min(Math.max(idealPx, minPx), Math.max(maxPx, minPx));
}
const left = Math.min(0, px);
const top = Math.min(0, py);
const layerW = Math.max(triggerW, px + panelW) - left;
const layerH = Math.max(triggerH, py + panelH) - top;
const triggerRadius = Math.min(triggerH / 2, panelRadius);
return {
layerW,
layerH,
left,
top,
trigger: { x: -left, y: -top, w: triggerW, h: triggerH, r: triggerRadius },
panel: { x: px - left, y: py - top, w: panelW, h: panelH, r: panelRadius },
};
}
function rectAt(geo: GooeyPopoverGeo, p: number): GooeyPopoverRect {
"worklet";
const t = geo.trigger;
const panel = geo.panel;
const lerp = (a: number, b: number) => a + (b - a) * p;
return { x: lerp(t.x, panel.x), y: lerp(t.y, panel.y), w: lerp(t.w, panel.w), h: lerp(t.h, panel.h), r: lerp(t.r, panel.r) };
}
type GooeyPopoverContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
progress: SharedValue<number>;
side: GooeyPopoverSide;
align: GooeyPopoverAlign;
gap: number;
panelRadius: number;
gooStrength: number;
color: string;
dismissOnOutsidePress: boolean;
triggerSize: { w: number; h: number };
setTriggerSize: React.Dispatch<React.SetStateAction<{ w: number; h: number }>>;
triggerScale: SharedValue<number>;
triggerWindowPos: { x: number; y: number } | null;
setTriggerWindowPos: React.Dispatch<React.SetStateAction<{ x: number; y: number } | null>>;
};
const GooeyPopoverContext = createContext<GooeyPopoverContextValue | null>(null);
function useGooeyPopoverContext(component: string) {
const ctx = useContext(GooeyPopoverContext);
if (!ctx) throw new Error(`${component} must be rendered inside <GooeyPopover>.`);
return ctx;
}
export interface GooeyPopoverProps {
children: React.ReactNode;
className?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
side?: GooeyPopoverSide;
align?: GooeyPopoverAlign;
sideOffset?: number;
panelRadius?: number;
gooStrength?: number;
/** Goo layer fill — the Skia canvas can't read className tokens, so this
* defaults to the theme's resolved card color (via useThemeColors(), light
* `#ffffff` / dark `#09090b`) and only needs overriding for a non-card surface. */
color?: string;
dismissOnOutsidePress?: boolean;
}
/** Root: holds open/progress state and the trigger's measured size so
* GooeyPopoverContent can compute where the panel should blob out from. */
export function GooeyPopover({
children,
className,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
side = "bottom",
align = "center",
sideOffset = 14,
panelRadius = 16,
gooStrength = 8,
color,
dismissOnOutsidePress = true,
}: GooeyPopoverProps) {
const colors = useThemeColors();
const resolvedColor = color ?? colors.card;
const isControlled = controlledOpen !== undefined;
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
const open = isControlled ? controlledOpen : uncontrolled;
const progress = useSharedValue(defaultOpen ? 1 : 0);
const triggerScale = useSharedValue(1);
const [triggerSize, setTriggerSize] = useState({ w: 0, h: 0 });
const [triggerWindowPos, setTriggerWindowPos] = useState<{ x: number; y: number } | null>(null);
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolled(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [open, setOpen]);
useEffect(() => {
progress.value = withSpring(open ? 1 : 0, open ? OPEN_SPRING : CLOSE_SPRING);
}, [open, progress]);
const ctx = useMemo<GooeyPopoverContextValue>(
() => ({ open, setOpen, toggle, progress, side, align, gap: sideOffset, panelRadius, gooStrength, color: resolvedColor, dismissOnOutsidePress, triggerSize, setTriggerSize, triggerScale, triggerWindowPos, setTriggerWindowPos }),
[open, setOpen, toggle, progress, side, align, sideOffset, panelRadius, gooStrength, resolvedColor, dismissOnOutsidePress, triggerSize, triggerScale, triggerWindowPos],
);
const { width: screenW, height: screenH } = Dimensions.get("window");
return (
<GooeyPopoverContext.Provider value={ctx}>
<View className={cn("relative", className)}>
{open && dismissOnOutsidePress ? (
<Pressable
accessible={false}
onPress={() => setOpen(false)}
style={{ position: "absolute", left: -screenW, top: -screenH, width: screenW * 2, height: screenH * 2, zIndex: 5 }}
/>
) : null}
{children}
</View>
</GooeyPopoverContext.Provider>
);
}
export interface GooeyPopoverTriggerProps extends React.ComponentPropsWithoutRef<typeof Pressable> {
className?: string;
pressScale?: number;
}
export function GooeyPopoverTrigger({ children, className, pressScale = 0.94, ...props }: GooeyPopoverTriggerProps) {
const { toggle, open, setTriggerSize, triggerScale, setTriggerWindowPos } = useGooeyPopoverContext("GooeyPopoverTrigger");
const triggerRef = useRef<RNView>(null);
const onLayout = useCallback(
(e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setTriggerSize((prev: { w: number; h: number }) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
// Needed to clamp a wide (e.g. two-line) panel back on-screen — the local
// layout above has no idea where this trigger actually sits on the device.
triggerRef.current?.measureInWindow((x, y) => {
setTriggerWindowPos((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
});
},
[setTriggerSize, setTriggerWindowPos],
);
const pressStyle = useAnimatedStyle(() => ({ transform: [{ scale: triggerScale.value }] }));
// GooeyPopoverContent's backdrop Canvas sits at zIndex 0 and is a later sibling
// of this trigger — a tied zIndex falls back to sibling order, which would let
// the canvas's goo blob paint over (and fully hide) this real button. zIndex 1
// keeps the trigger above the canvas while staying below the open panel's
// content overlay (zIndex 10).
return (
<Animated.View style={[{ zIndex: 1 }, pressStyle]}>
<Pressable
ref={triggerRef}
onLayout={onLayout}
onPress={toggle}
onPressIn={() => {
triggerScale.value = withSpring(pressScale, PRESS_IN_SPRING);
}}
onPressOut={() => {
triggerScale.value = withSpring(1, PRESS_OUT_SPRING);
}}
accessible={true}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
className={cn("min-h-12 min-w-12 items-center justify-center rounded-full bg-primary px-4", className)}
{...props}
>
{typeof children === "string" ? <Text className="text-sm font-medium text-primary-foreground">{children}</Text> : children}
</Pressable>
</Animated.View>
);
}
export interface GooeyPopoverContentProps {
children: React.ReactNode;
className?: string;
}
/** Content: measures its own natural size off-screen, then animates a clip
* window (borderRadius + bounds) from the trigger's rect to the panel's rect
* while a Skia goo layer blends trigger + panel shapes underneath. */
export function GooeyPopoverContent({ children, className }: GooeyPopoverContentProps) {
const { progress, side, align, gap, panelRadius, gooStrength, color, open, triggerSize, triggerScale, triggerWindowPos } =
useGooeyPopoverContext("GooeyPopoverContent");
const { width: screenW } = useWindowDimensions();
const [contentSize, setContentSize] = useState({ w: 0, h: 0 });
const onMeasure = useCallback((e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setContentSize((prev) => (prev.w === width && prev.h === height ? prev : { w: width, h: height }));
}, []);
const clamp = useMemo<GooeyPopoverClamp | null>(
() => (triggerWindowPos ? { screenW, triggerWinX: triggerWindowPos.x, margin: 12 } : null),
[screenW, triggerWindowPos],
);
const geo = useMemo(
() => buildGeo(triggerSize.w, triggerSize.h, contentSize.w, contentSize.h, side, align, gap, panelRadius, clamp),
[triggerSize, contentSize, side, align, gap, panelRadius, clamp],
);
const ready = geo.layerW > 0 && contentSize.w > 0;
const rect = useDerivedValue(() => rectAt(geo, progress.value), [geo]);
const rx = useDerivedValue(() => rect.value.x);
const ry = useDerivedValue(() => rect.value.y);
const rw = useDerivedValue(() => rect.value.w);
const rh = useDerivedValue(() => rect.value.h);
const rr = useDerivedValue(() => rect.value.r);
const cx = geo.trigger.x + geo.trigger.w / 2;
const cy = geo.trigger.y + geo.trigger.h / 2;
const tx = useDerivedValue(() => cx - (geo.trigger.w * triggerScale.value) / 2, [geo]);
const ty = useDerivedValue(() => cy - (geo.trigger.h * triggerScale.value) / 2, [geo]);
const tw = useDerivedValue(() => geo.trigger.w * triggerScale.value, [geo]);
const th = useDerivedValue(() => geo.trigger.h * triggerScale.value, [geo]);
const tr = useDerivedValue(() => geo.trigger.r * triggerScale.value, [geo]);
const clipStyle = useAnimatedStyle(() => {
const m = rectAt(geo, progress.value);
// Border fades in with the content below — at progress 0 this exactly
// matches the trigger's own rounded-pill shape, and without fading it out
// it would sit drawn on top of the trigger even while fully closed.
return { left: m.x, top: m.y, width: m.w, height: m.h, borderRadius: m.r, opacity: interpolate(progress.value, [0, 0.45, 1], [0, 0.2, 1]) };
}, [geo]);
const contentStyle = useAnimatedStyle(() => {
const m = rectAt(geo, progress.value);
return { left: geo.panel.x - m.x, top: geo.panel.y - m.y };
}, [geo]);
return (
<>
<View pointerEvents="none" style={{ position: "absolute", left: 0, top: 0, opacity: 0, zIndex: -2 }}>
<View className={cn("max-w-[320px] p-4", className)} onLayout={onMeasure}>
{children}
</View>
</View>
{ready ? (
<Canvas
pointerEvents="none"
style={{ position: "absolute", zIndex: 0, left: geo.left, top: geo.top, width: geo.layerW, height: geo.layerH }}
>
<Group
layer={
<Paint>
<Blur blur={gooStrength} />
<ColorMatrix matrix={GOO_MATRIX} />
</Paint>
}
>
<RoundedRect x={tx} y={ty} width={tw} height={th} r={tr} color={color} />
<RoundedRect x={rx} y={ry} width={rw} height={rh} r={rr} color={color} />
</Group>
</Canvas>
) : null}
<View
pointerEvents={open ? "box-none" : "none"}
style={{ position: "absolute", zIndex: 10, left: geo.left, top: geo.top, width: geo.layerW, height: geo.layerH }}
>
<Animated.View className="border border-border" style={[{ position: "absolute", overflow: "hidden" }, clipStyle]}>
<Animated.View className={cn("absolute max-w-[320px] p-4", className)} style={contentStyle}>
{children}
</Animated.View>
</Animated.View>
</View>
</>
);
}

