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.jsonA 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="modal-root"), 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'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>
);
}
Dropdowns
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
options | UsePortalOptions | {} | Configuration object. |
Options Object (UsePortalOptions)
| Property | Type | Default | Description |
|---|---|---|---|
id | string | "fiberui-portal" | The ID of the container element to create/find. |
container | HTMLElement | React.RefObject<HTMLElement> | undefined | A custom container element or ref to render into. Takes precedence over id. |
Returns
Returns an object containing:
| Property | Type | Description |
|---|---|---|
Portal | React.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 };
}