Fiber UI LogoFiberUI

usePortal

A React hook that simplifies the creation and management of React Portals. It handles the creation of the portal container, appends it to the DOM, and manages cleanups.

Installation

npx shadcn@latest add https://r.fiberui.com/r/hooks/use-portal.json

A robust hook for managing clean and efficient React Portals. It solves the "z-index war" by physically moving your component to the end of the <body> tag (or any other container), while keeping it logically inside your React component tree.

Source Code

View the full hook implementation in the Hook Source Code section below.

Features

  • Automatic Container Management - Creates and appends the portal container if it doesn't exist.
  • SSR Safe - Gracefully handles Server-Side Rendering by only rendering on the client.
  • Cleanup - Automatically removes the container from the DOM when it is empty and no longer used.
  • Singleton Pattern - Reuses existing containers with the same ID.
  • Component-Based API - Returns a <Portal> component for easy usage.

Basic Usage

The usePortal hook returns a Portal component that you can use to wrap your content.

Try it out!

Click the button to open a modal. The modal content is physically appended to the end of the document <body>.

Click the button below to open a modal rendered via a Portal.

"use client";

import { usePortal } from "@repo/hooks/dom/use-portal";
import { useState } from "react";

export function Example1() {
    const [isOpen, setIsOpen] = useState(false);

    // usePortal implementation
    // This automatically creates <div id="modal-root"></div> in your body
    // It returns a Component 'Portal' that we can use to render content into that div
    const { Portal } = usePortal({ id: "modal-root" });

    return (
        <div className="flex flex-col items-center gap-4">
            <div className="bg-muted/50 rounded-lg border p-4 text-center">
                <p className="text-muted-foreground mb-4 text-sm">
                    Click the button below to open a modal rendered via a
                    Portal.
                </p>
                <button
                    onClick={() => setIsOpen(true)}
                    className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
                >
                    Open Portal
                </button>
            </div>

            {/* 
        We use the Portal component returned by the hook.
        It conditionally renders children only when the container is ready.
      */}
            {isOpen && (
                <Portal>
                    <div
                        className="animate-in fade-in zoom-in-95 z-9999 fixed inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm duration-200"
                        onClick={() => setIsOpen(false)}
                    >
                        <div
                            className="bg-card text-card-foreground relative w-full max-w-md rounded-lg border p-6 shadow-lg"
                            onClick={(e) => e.stopPropagation()}
                        >
                            <h2 className="mb-2 text-lg font-semibold">
                                Portal Content
                            </h2>
                            <p className="text-muted-foreground mb-4">
                                This content is rendered in a portal
                                (id=&quot;modal-root&quot;), physically located
                                at the end of the body element.
                            </p>
                            <button
                                onClick={() => setIsOpen(false)}
                                className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
                            >
                                Close Portal
                            </button>
                        </div>
                    </div>
                </Portal>
            )}
        </div>
    );
}

Use Cases

Tooltips

Portals are excellent for tooltips because they avoid issues where overflow: hidden on parent elements clips the tooltip content.

"use client";

import { usePortal } from "@repo/hooks/dom/use-portal";
import { useState, useRef, useEffect } from "react";

export function Example2() {
    const [isOpen, setIsOpen] = useState(false);
    const triggerRef = useRef<HTMLButtonElement>(null);
    const [position, setPosition] = useState<{
        top: number;
        left: number;
    } | null>(null);

    // Use a shared container for tooltips if you like, or unique strings.
    // Here we let it create a default one or reuse "fiberui-portal".
    const { Portal } = usePortal({ id: "tooltip-root" });

    // Calculate position when opening
    useEffect(() => {
        if (isOpen && triggerRef.current) {
            const rect = triggerRef.current.getBoundingClientRect();
            setPosition({
                // Position above the button
                top: rect.top + window.scrollY - 10,
                // Center horizontally
                left: rect.left + window.scrollX + rect.width / 2,
            });
        } else {
            setPosition(null);
        }
    }, [isOpen]);

    return (
        <div className="flex justify-center p-8">
            <button
                ref={triggerRef}
                onMouseEnter={() => setIsOpen(true)}
                onMouseLeave={() => setIsOpen(false)}
                className="bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-md px-4 py-2 text-sm font-medium transition-colors"
            >
                Hover me
            </button>

            {isOpen && position && (
                <Portal>
                    <div
                        className="animate-in fade-in z-9999 pointer-events-none absolute -translate-x-1/2 -translate-y-full transform rounded bg-black px-3 py-1.5 text-xs text-white shadow-md duration-200"
                        style={{
                            top: position.top,
                            left: position.left,
                        }}
                    >
                        I&apos;m a portal tooltip!
                        {/* Tiny arrow */}
                        <div className="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-black" />
                    </div>
                </Portal>
            )}
        </div>
    );
}

Similar to tooltips, dropdowns benefit from being rendered outside the regular DOM hierarchy to ensure they appear on top of other elements.

"use client";

import { usePortal } from "@repo/hooks/dom/use-portal";
import { ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";

export function Example3() {
    const [isOpen, setIsOpen] = useState(false);
    const triggerRef = useRef<HTMLButtonElement>(null);
    const [position, setPosition] = useState<{
        top: number;
        left: number;
        width: number;
    } | null>(null);
    const dropdownRef = useRef<HTMLDivElement>(null);

    const { Portal } = usePortal({ id: "dropdown-root" });

    // Handle outside click to close
    useEffect(() => {
        const handleClickOutside = (event: MouseEvent) => {
            if (
                isOpen &&
                triggerRef.current &&
                !triggerRef.current.contains(event.target as Node) &&
                dropdownRef.current &&
                !dropdownRef.current.contains(event.target as Node)
            ) {
                setIsOpen(false);
            }
        };
        document.addEventListener("mousedown", handleClickOutside);
        return () =>
            document.removeEventListener("mousedown", handleClickOutside);
    }, [isOpen]);

    // Update position on open or scroll/resize
    useEffect(() => {
        const updatePosition = () => {
            if (triggerRef.current && isOpen) {
                const rect = triggerRef.current.getBoundingClientRect();
                setPosition({
                    top: rect.bottom + window.scrollY + 4,
                    left: rect.left + window.scrollX,
                    width: rect.width,
                });
            } else {
                setPosition(null);
            }
        };

        updatePosition();
        window.addEventListener("resize", updatePosition);
        window.addEventListener("scroll", updatePosition);
        return () => {
            window.removeEventListener("resize", updatePosition);
            window.removeEventListener("scroll", updatePosition);
        };
    }, [isOpen]);

    return (
        <div className="flex flex-col items-center gap-4 p-8">
            <button
                ref={triggerRef}
                onClick={() => setIsOpen(!isOpen)}
                className="bg-background hover:bg-muted/50 flex w-48 items-center justify-between rounded-md border px-4 py-2 text-sm shadow-sm transition-colors"
            >
                <span>Options</span>
                <span
                    className={`transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
                >
                    <ChevronDown />
                </span>
            </button>

            {isOpen && position && (
                <Portal>
                    <div
                        ref={dropdownRef}
                        className="bg-popover text-popover-foreground animate-in fade-in zoom-in-95 z-9999 absolute rounded-md border shadow-md duration-100"
                        style={{
                            top: position.top,
                            left: position.left,
                            width: position.width,
                        }}
                    >
                        <div className="p-1">
                            <div
                                className="hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-sm px-2 py-1.5 text-sm"
                                onClick={() => setIsOpen(false)}
                            >
                                Profile
                            </div>
                            <div
                                className="hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-sm px-2 py-1.5 text-sm"
                                onClick={() => setIsOpen(false)}
                            >
                                Settings
                            </div>
                            <div className="bg-border my-1 h-px" />
                            <div
                                className="hover:bg-destructive hover:text-destructive-foreground cursor-pointer rounded-sm px-2 py-1.5 text-sm"
                                onClick={() => setIsOpen(false)}
                            >
                                Logout
                            </div>
                        </div>
                    </div>
                </Portal>
            )}
        </div>
    );
}

Toasts & Notifications

You can create a dedicated portal container for notifications (e.g., toast-container) and render multiple toast components into it.

"use client";

import { usePortal } from "@repo/hooks/dom/use-portal";
import { useState } from "react";
import { X } from "lucide-react";

export function Example4() {
    const [toasts, setToasts] = useState<
        Array<{ id: number; message: string }>
    >([]);
    const { Portal } = usePortal({ id: "toast-container" });

    const addToast = () => {
        const id = Date.now();
        setToasts((prev) => [
            ...prev,
            { id, message: `Toast notification #${id.toString().slice(-4)}` },
        ]);

        // Auto remove after 5 seconds
        setTimeout(() => {
            setToasts((prev) => prev.filter((t) => t.id !== id));
        }, 5000);
    };

    const removeToast = (id: number) => {
        setToasts((prev) => prev.filter((t) => t.id !== id));
    };

    return (
        <div className="flex flex-col items-center gap-4 p-8">
            <button
                onClick={addToast}
                className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
            >
                Add Toast Notification
            </button>

            <Portal>
                <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
                    {toasts.map((toast) => (
                        <div
                            key={toast.id}
                            className="bg-background text-foreground animate-in slide-in-from-right-full flex min-w-[300px] items-center justify-between rounded-md border p-4 shadow-lg transition-all"
                        >
                            <span className="text-sm">{toast.message}</span>
                            <button
                                onClick={() => removeToast(toast.id)}
                                className="hover:bg-muted rounded-full p-1 transition-colors"
                            >
                                <X className="h-4 w-4" />
                            </button>
                        </div>
                    ))}
                </div>
            </Portal>
        </div>
    );
}

Custom Container

You can render the portal into a specific container using the container option. This is useful when you want the portal content to be physically located within a specific part of your DOM (e.g., inside a layout container) while still escaping the parent's overflow.

Main Content Area

This area contains the button and normal flow content.

Target Container (ref)

"use client";

import { usePortal } from "@repo/hooks/dom/use-portal";
import { useState, useRef } from "react";

export function Example5() {
    const [isOpen, setIsOpen] = useState(false);
    const containerRef = useRef<HTMLDivElement>(null);

    // Pass the ref to usePortal to render content inside that element
    const { Portal } = usePortal({ container: containerRef });

    return (
        <div className="flex flex-col gap-4 p-8">
            <div className="flex items-center gap-4">
                <button
                    onClick={() => setIsOpen(!isOpen)}
                    className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
                >
                    {isOpen ? "Close Portal" : "Open Portal in Box Below"}
                </button>
            </div>

            <div className="grid grid-cols-2 gap-4">
                <div className="rounded-md border p-4">
                    <h3 className="mb-2 font-medium">Main Content Area</h3>
                    <p className="text-muted-foreground text-sm">
                        This area contains the button and normal flow content.
                    </p>
                </div>

                <div
                    ref={containerRef}
                    className="bg-muted/30 relative min-h-[100px] rounded-md border border-dashed p-4"
                >
                    <h3 className="text-muted-foreground mb-2 text-sm font-medium">
                        Target Container (ref)
                    </h3>
                    {/* The portal content will appear here */}
                </div>
            </div>

            {isOpen && (
                <Portal>
                    <div className="animate-in fade-in zoom-in-95 bg-accent text-accent-foreground rounded p-3 text-sm shadow-sm ring-1 ring-inset ring-black/10">
                        ✨ I am portaled into the container div!
                    </div>
                </Portal>
            )}
        </div>
    );
}

API Reference

type UsePortalOptions = {
    id?: string;
    container?: HTMLElement | React.RefObject<HTMLElement | null>;
};

function usePortal(options: UsePortalOptions = {}): {
    Portal: React.FC<{ children: React.ReactNode }>;
};

Parameters

The hook accepts an optional configuration object.

ParameterTypeDefaultDescription
optionsUsePortalOptions{}Configuration object.

Options Object (UsePortalOptions)

PropertyTypeDefaultDescription
idstring"fiberui-portal"The ID of the container element to create/find.
containerHTMLElement | React.RefObject<HTMLElement>undefinedA custom container element or ref to render into. Takes precedence over id.

Returns

Returns an object containing:

PropertyTypeDescription
PortalReact.FC<{ children: React.ReactNode }>A component that renders its children into the portal container.

Hook Source Code

import React, { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";

type UsePortalOptions = {
    /**
     * The ID of the container element.
     * @default "fiberui-portal"
     */
    id?: string;
    /**
     * A custom container element or ref to render the portal into.
     * If provided, `id` is ignored for creation purposes (though might be used if container is not found yet, but usually container takes precedence).
     */
    container?: HTMLElement | React.RefObject<HTMLElement | null>;
};

/**
 * A hook that manages a portal container in the DOM and returns a Portal component.
 *
 * @param options - Configuration options.
 * @returns An object containing the Portal component.
 */
export function usePortal(options: UsePortalOptions = {}) {
    const [container, setContainer] = useState<HTMLElement | null>(null);

    const { id = "fiberui-portal", container: customContainer } = options;

    useEffect(() => {
        // 1. If a custom container (element or ref) is provided, try to use it.
        if (customContainer) {
            const element =
                "current" in customContainer
                    ? customContainer.current
                    : customContainer;

            if (element) {
                setContainer(element);
                return;
            }
        }

        // 2. Otherwise, manage a DOM element with the given ID.
        // We only want to create/manage if we are NOT using a custom container
        // (or if custom container rendered null, but primarily for the ID case).
        if (!customContainer) {
            let element = document.getElementById(id);
            let created = false;

            if (!element) {
                created = true;
                element = document.createElement("div");
                element.setAttribute("id", id);
                document.body.appendChild(element);
            }

            setContainer(element);

            return () => {
                // Cleanup: only remove if we created it and it's empty
                if (created && element && element.parentNode) {
                    if (element.childNodes.length === 0) {
                        element.parentNode.removeChild(element);
                    }
                }
            };
        }
    }, [id, customContainer]);

    const Portal = React.useMemo(() => {
        const PortalComponent = ({
            children,
        }: {
            children: React.ReactNode;
        }) => {
            if (!container) return null;
            return createPortal(children, container);
        };
        return PortalComponent;
    }, [container]);

    return { Portal };
}