Fiber UI LogoFiberUI

Popover

Displays rich content in a portal, triggered by a button.

Basic Usage

import { Button } from "@repo/ui/components/button";
import { Input } from "@repo/ui/components/input";
import { Label } from "@repo/ui/components/label";
import {
    Popover,
    PopoverBody,
    PopoverContent,
    PopoverTrigger,
} from "@repo/ui/components/popover";

interface Example1Props {}

export const Example1: React.FC<Example1Props> = ({}) => {
    return (
        <Popover>
            <PopoverTrigger asChild>
                <Button variant="outline">Open popover</Button>
            </PopoverTrigger>
            <PopoverContent className="bg-fd-card w-80">
                <PopoverBody>
                    <div className="grid gap-4">
                        <div className="space-y-2">
                            <h4 className="font-medium leading-none">
                                Dimensions
                            </h4>
                            <p className="text-muted-foreground text-sm">
                                Set the dimensions for the layer.
                            </p>
                        </div>
                        <div className="grid gap-2">
                            <div className="grid grid-cols-3 items-center gap-4">
                                <Label htmlFor="width">Width</Label>
                                <Input
                                    id="width"
                                    defaultValue="100%"
                                    className="col-span-2 h-8"
                                />
                            </div>
                            <div className="grid grid-cols-3 items-center gap-4">
                                <Label htmlFor="maxWidth">Max. width</Label>
                                <Input
                                    id="maxWidth"
                                    defaultValue="300px"
                                    className="col-span-2 h-8"
                                />
                            </div>
                            <div className="grid grid-cols-3 items-center gap-4">
                                <Label htmlFor="height">Height</Label>
                                <Input
                                    id="height"
                                    defaultValue="25px"
                                    className="col-span-2 h-8"
                                />
                            </div>
                            <div className="grid grid-cols-3 items-center gap-4">
                                <Label htmlFor="maxHeight">Max. height</Label>
                                <Input
                                    id="maxHeight"
                                    defaultValue="none"
                                    className="col-span-2 h-8"
                                />
                            </div>
                        </div>
                    </div>
                </PopoverBody>
            </PopoverContent>
        </Popover>
    );
};

Directions of popover

"use client";

import {
    Popover,
    PopoverBody,
    PopoverContent,
    PopoverTrigger,
} from "@repo/ui/components/popover";

interface Example2Props {}

export const Example2: React.FC<Example2Props> = ({}) => {
    return (
        <>
            <div className="grid grid-cols-2 gap-5">
                <Popover>
                    <PopoverTrigger>Top</PopoverTrigger>
                    <PopoverContent placement="top">
                        <PopoverBody>This is TOP popover content</PopoverBody>
                    </PopoverContent>
                </Popover>

                {/* --------------------------------------------------- */}

                <Popover>
                    <PopoverTrigger>Right</PopoverTrigger>
                    <PopoverContent placement="right">
                        <PopoverBody>This is RIGHT popover content</PopoverBody>
                    </PopoverContent>
                </Popover>

                {/* --------------------------------------------------- */}

                <Popover>
                    <PopoverTrigger>Left</PopoverTrigger>
                    <PopoverContent placement="left">
                        <PopoverBody>This is LEFT popover content</PopoverBody>
                    </PopoverContent>
                </Popover>

                {/* --------------------------------------------------- */}

                <Popover>
                    <PopoverTrigger>Bottom</PopoverTrigger>
                    <PopoverContent placement="bottom">
                        <PopoverBody>
                            This is BOTTOM popover content
                        </PopoverBody>
                    </PopoverContent>
                </Popover>
            </div>
        </>
    );
};

All Components

import { Button } from "@repo/ui/components/button";
import { Checkbox } from "@repo/ui/components/checkbox";
import {
    Popover,
    PopoverTrigger,
    PopoverContent,
    PopoverHeader,
    PopoverBody,
    PopoverFooter,
} from "@repo/ui/components/popover";

interface Example3Props {}

export const Example3: React.FC<Example3Props> = ({}) => {
    return (
        <div className="flex min-h-[400px] items-center justify-center">
            <Popover>
                <PopoverTrigger asChild>
                    <Button variant="outline">Open Popover</Button>
                </PopoverTrigger>
                <PopoverContent className="w-80">
                    <PopoverHeader>
                        <h3 className="text-lg font-semibold">
                            Account Settings
                        </h3>
                    </PopoverHeader>
                    <PopoverBody>
                        <p className="mb-4 text-sm text-gray-600">
                            Manage your account preferences and settings here.
                            Changes will be saved automatically.
                        </p>
                        <div className="space-y-2">
                            <div className="flex items-center justify-between">
                                <span className="text-sm">
                                    Email notifications
                                </span>
                                <Checkbox defaultSelected />
                            </div>
                            <div className="flex items-center justify-between">
                                <span className="text-sm">
                                    Marketing emails
                                </span>
                                <Checkbox />
                            </div>
                        </div>
                    </PopoverBody>
                    <PopoverFooter>
                        <div className="flex justify-end gap-2">
                            <Button variant="ghost" size="sm">
                                Cancel
                            </Button>
                            <Button size="sm">Save Changes</Button>
                        </div>
                    </PopoverFooter>
                </PopoverContent>
            </Popover>
        </div>
    );
};

Component Code

"use client";

import { useOverlayTriggerState, OverlayTriggerState } from "react-stately";

import {
    useOverlayTrigger,
    usePopover,
    Overlay,
    DismissButton,
    AriaButtonProps,
    AriaPopoverProps,
    useButton,
    mergeProps,
    useOverlay,
} from "react-aria";

import {
    createContext,
    useContext,
    useRef,
    ReactNode,
    forwardRef,
    RefObject,
} from "react";

import { cn } from "@repo/ui/lib/utils";
import { Slot } from "@repo/ui/components/slot";
import { Button } from "@repo/ui/components/button";

interface PopoverProviderT {
    defaultOpen?: boolean;
    isOpen?: boolean;
    onOpenChange?: (val: boolean) => void;
    modal?: boolean;
}
// ============================================================================
// Popover Context
// ============================================================================

interface PopoverContextT extends PopoverProviderT {
    state: OverlayTriggerState;
    triggerRef: RefObject<HTMLButtonElement | null>;
}
const PopoverContext = createContext<PopoverContextT | null>(null);

const Popover = ({
    children,
    ...props
}: PopoverProviderT & { children: ReactNode }) => {
    const state = useOverlayTriggerState(props);
    const triggerRef = useRef<HTMLButtonElement>(null);

    return (
        <PopoverContext.Provider value={{ state, triggerRef, ...props }}>
            {children}
        </PopoverContext.Provider>
    );
};

// ============================================================================
// Popover Trigger
// ============================================================================

interface PopoverTriggerProps extends AriaButtonProps {
    asChild?: boolean;
    className?: string;
    children: ReactNode;
}

const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
    ({ asChild, className, children, ...props }, ref) => {
        const ctx = useContext(PopoverContext);
        if (!ctx) throw new Error("PopoverTrigger must be inside <Popover>");
        const { state, triggerRef } = ctx;

        const { triggerProps } = useOverlayTrigger(
            { type: "dialog" },
            state,
            triggerRef,
        );

        const internalRef = useRef<HTMLButtonElement>(null);

        const { buttonProps } = useButton(props, internalRef);

        const finalProps = mergeProps(triggerProps, buttonProps);

        const mergedRef = (node: HTMLButtonElement | null) => {
            triggerRef.current = node;
            internalRef.current = node;
            if (typeof ref === "function") {
                ref(node);
            } else if (ref) {
                /* eslint-disable */
                (ref as any).current = node;
            }
        };

        if (asChild) {
            return (
                <Slot {...finalProps} ref={mergedRef} className={className}>
                    {children}
                </Slot>
            );
        }

        return (
            <Button
                {...finalProps}
                ref={mergedRef}
                className={cn("outline-none", className)}
            >
                {children}
            </Button>
        );
    },
);
PopoverTrigger.displayName = "PopoverTrigger";

// ============================================================================
// Popover Content
// ============================================================================

interface PopoverContentProps
    extends Omit<AriaPopoverProps, "popoverRef" | "triggerRef"> {
    className?: string;
    children: ReactNode;
    asChild?: boolean;
}

const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
    ({ asChild, className, children, ...props }, ref) => {
        const ctx = useContext(PopoverContext);

        if (!ctx) throw new Error("PopoverContent must be inside <Popover>");
        const { state, triggerRef } = ctx;

        const popoverRef = useRef<HTMLDivElement>(null);
        const mergedRef = (node: HTMLDivElement | null) => {
            popoverRef.current = node;
            if (typeof ref === "function") {
                ref(node);
            } else if (ref) {
                /* eslint-disable */
                (ref as any).current = node;
            }
        };

        // 🔹 Add overlayProps for dismissal behavior
        const { overlayProps } = useOverlay(
            {
                onClose: state.close,
                isOpen: state.isOpen,
                isDismissable: true,
                shouldCloseOnBlur: true,
            },
            popoverRef,
        );

        const { popoverProps, underlayProps } = usePopover(
            {
                ...props,
                popoverRef,
                triggerRef,
                offset: props.offset ?? 8,
                placement: props.placement ?? "bottom",
                isNonModal: !ctx.modal,
            },
            state,
        );

        const dataState = state.isOpen ? "open" : "closed";
        const side = props.placement?.split(" ")[0] ?? "bottom";

        const animationClasses =
            "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95" +
            " data-[side=bottom]:slide-in-from-top-2" +
            " data-[side=top]:slide-in-from-bottom-2" +
            " data-[side=left]:slide-in-from-right-2" +
            " data-[side=right]:slide-in-from-left-2";

        const content = asChild ? (
            <Slot
                {...mergeProps(popoverProps, overlayProps)}
                ref={mergedRef}
                data-state={dataState}
                data-side={side}
                className={cn(animationClasses, className)}
            >
                {children}
            </Slot>
        ) : (
            <div
                id="testing"
                {...mergeProps(popoverProps, overlayProps)}
                ref={mergedRef}
                data-state={dataState}
                data-side={side}
                className={cn(
                    animationClasses,
                    "bg-popover text-popover-foreground absolute z-50 w-72 rounded-md border p-4 shadow-md outline-none",
                    className,
                )}
            >
                {children}
                <DismissButton onDismiss={state.close} />
            </div>
        );

        if (!state.isOpen) return null;

        return (
            <Overlay>
                {!ctx.modal && (
                    <div {...underlayProps} className="fixed inset-0 z-40" />
                )}
                {content}
            </Overlay>
        );
    },
);
PopoverContent.displayName = "PopoverContent";

// ============================================================================
// Popover Header (Optional)
// ============================================================================

interface PopoverHeaderProps {
    children: React.ReactNode;
    className?: string;
}

const PopoverHeader: React.FC<PopoverHeaderProps> = ({
    children,
    className,
}) => {
    return (
        <div className={cn("px-4 pb-2 pt-4", className)}>
            <h3 className="text-sm font-semibold leading-none">{children}</h3>
        </div>
    );
};

// ============================================================================
// Popover Body (Optional)
// ============================================================================

interface PopoverBodyProps {
    children: React.ReactNode;
    className?: string;
}

const PopoverBody: React.FC<PopoverBodyProps> = ({ children, className }) => {
    return <div className={cn("px-4 py-3", className)}>{children}</div>;
};

// ============================================================================
// Popover Footer (Optional)
// ============================================================================

interface PopoverFooterProps {
    children: React.ReactNode;
    className?: string;
}

const PopoverFooter: React.FC<PopoverFooterProps> = ({
    children,
    className,
}) => {
    return (
        <div
            className={cn(
                "flex items-center gap-2 border-t px-4 pb-4 pt-2",
                className,
            )}
        >
            {children}
        </div>
    );
};

export {
    Popover,
    PopoverTrigger,
    PopoverContent,
    PopoverHeader,
    PopoverBody,
    PopoverFooter,
};

// <Popover>
//     <PopoverTrigger></PopoverTrigger>
//     <PopoverContent>
//         <PopoverHeader></PopoverHeader>
//         <PopoverBody></PopoverBody>
//         <PopoverFooter></PopoverFooter>
//     </PopoverContent>
// </Popover>