A lightweight React overlay management library with full TypeScript support
npm install @react-overlay-manager/coreA lightweight, type-safe overlay system for React with a zero dependency and built-in DevTools.
- 📦 Headless – bring your own styles / animations
- 🔒 Fully typed – compile-time safety for props and results
- 🛠 DevTools – inspect the overlay stack in development
- ⚡️ Fast – minimal state, useSyncExternalStore under the hood
- 📏 Small – ~2.6 kB gzipped
---
- Installation
- Quick Start
- API Reference
- React Hook: useOverlayStore
- Exit Behavior & Animations
- Stacking Behavior
- DevTools
- Examples
- CSS Transitions
- Framer Motion
- TypeScript Guide
- Async/Await and PromiseWithId
- Compile-time Errors for Wrong Props
- Advanced Patterns
- Central Registry with React.lazy
- Using open() without await
- Edge Cases & Error Handling
- SSR / Next.js
- Accessibility
- Troubleshooting
- Bundling & Versioning
- Quality & Coverage
- License
---
``bash`
pnpm add @react-overlay-manager/core # or yarn / npm
---
Use the defineOverlay helper for full type-safety. It injects props like visible (for animations) and close (to return a result).
`tsx
// src/components/ConfirmDialog.tsx
import { defineOverlay } from '@react-overlay-manager/core';
import cx from 'clsx';
interface ConfirmDialogProps {
message: string;
}
export const ConfirmDialog = defineOverlay
({ message, visible, close }) => {
const dialogClass = cx(
'... transition-opacity duration-300',
visible
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
);
return (
{message}
$3
A manager holds the registry of your overlays.
`tsx
// src/services/overlayManager.ts
import { createOverlayManager } from '@react-overlay-manager/core';
import { ConfirmDialog } from '../components/ConfirmDialog';export const overlayManager = createOverlayManager({
confirm: ConfirmDialog,
});
`$3
The
component is responsible for rendering your overlays into the DOM. You must always include it in your app's root.`tsx
// src/App.tsx
import { OverlayManager } from '@react-overlay-manager/core';
import { overlayManager } from './services/overlayManager';
import { MyPage } from './MyPage';export default function App() {
return (
<>
>
);
}
`> Note: For overlays without CSS animations, you may want to add
defaultExitDuration={0} to ensure they are removed from the DOM after closing. See the Exit Behavior section for details.$3
Call
manager.open() from any component, hook, or service. It's fully async and type-safe.`tsx
// inside any component / service
import { overlayManager } from '../services/overlayManager';async function deleteItem() {
const confirmed = await overlayManager.open('confirm', {
message: 'Delete this item?',
}); //
confirmed is typed as boolean if (confirmed) {
// ...delete logic
}
}
`$3
You can also open components on-the-fly without registering them first.
`tsx
import { overlayManager } from '../services/overlayManager';
import { TempDialog } from '../components/TempDialog';await overlayManager.open(TempDialog, { title: 'One-off dialog' });
`$3
If you don't intend to use a component registry and only want to open components directly, you can import a pre-configured, shared manager instance.
You still must render the
component and pass this default instance to it.`tsx
// src/App.tsx
import { OverlayManager, overlays } from '@react-overlay-manager/core';
import { MyPage } from './MyPage';export default function App() {
return (
<>
{/ Render the manager, passing the default 'overlays' instance /}
>
);
}
// Then, from any other file:
import { overlays } from '@react-overlay-manager/core';
import { MyDialog } from './components/MyDialog';
function handleClick() {
overlays.open(MyDialog, { title: 'Hello' });
}
`---
API Reference
$3
| Method | Signature | Notes |
| :------------------ | :----------------------- | :---------------------------------------------------------------------------------------- |
|
open | open(keyOrComp, opts?) | Opens an overlay. Returns a PromiseWithId that resolves when close(result) is called. |
| hide | hide(id) | Hides an overlay by setting visible=false. The component remains mounted. |
| show | show(id) | Re-shows a hidden overlay. Throws OverlayNotFoundError if the ID is invalid. |
| update | update(id, props) | Merges new props into an existing overlay, triggering a re-render. |
| closeAll | closeAll() | Closes all open overlays, respecting their individual exit animations. |
| isOpen | isOpen(id) | Returns true if an overlay with the given ID exists in the manager. |
| getInstance | getInstance(id) | Returns the runtime instance ({ id, props, visible, ... }) or undefined. |
| getInstancesByKey | getInstancesByKey(key) | Returns an array of all instances opened from a specific registry key. |
| getOpenCount | getOpenCount() | Returns the number of overlays currently in the stack. |$3
These props are automatically passed to every overlay component.
| Prop | Type | Purpose |
| :----------------- | :------------------ | :------------------------------------------------------------------------------ |
|
id | OverlayId | A unique, stable identifier for the overlay instance. |
| visible | boolean | true when the overlay should be visible. Drives enter/exit animations. |
| hide() | () => void | Hides the overlay without unmounting it or resolving its promise. |
| close() | (result?) => void | Resolves the open() promise and begins the exit/removal process. |
| onExitComplete() | () => void | Manually tells the manager the exit animation is done, causing instant removal. |---
$3
| Prop | Type | Default | Purpose |
| :-------------------- | :----------------------------- | :-------------------------------------- | :------------------------------------------------------------------------------------------------------- |
|
manager | OverlayManagerCore | — | The manager instance created by createOverlayManager (or the shared overlays). |
| zIndexBase | number | 100 | Base z-index applied to the first overlay container; each subsequent overlay uses base + index. |
| defaultExitDuration | number \| null | undefined | Global fallback for exit removal. 0 = remove immediately. null = disable timer, use events/callback. |
| portalTarget | HTMLElement \| null | document.body (client) / null (SSR) | Default portal element for all overlays. Can be overridden per open() call. |
| stackingBehavior | 'stack' \| 'hide-previous' | 'hide-previous' | Global stacking mode; can be overridden per open() call. |---
$3
The second argument to
open() merges your component props with a few manager options:| Option | Type | Purpose |
| :----------------- | :----------------------------- | :---------------------------------------------------------------------------------------------- |
|
id | OverlayId | Optional explicit ID. If omitted, a unique one is generated. |
| exitDuration | number \| null | Per-instance exit timer. 0 = remove immediately. null = disable timer, use events/callback. |
| portalTarget | HTMLElement \| null | Per-instance portal target. Overrides . |
| stackingBehavior | 'stack' \| 'hide-previous' | Per-instance stacking mode. Overrides global stackingBehavior. |React Hook:
useOverlayStoreFor building custom UI that reacts to the overlay state,
useOverlayStore provides an efficient way to subscribe to changes. It's powered by useSyncExternalStore and only triggers re-renders when the selected state changes.`tsx
import { useOverlayStore } from '@react-overlay-manager/core';
import { overlayManager } from './services/overlayManager';function GlobalBlocker() {
const isAnyOverlayOpen = useOverlayStore(
overlayManager,
(state) => state.overlayStack.length > 0
);
// Block background scroll when any overlay is open
useEffect(() => {
document.body.style.overflow = isAnyOverlayOpen ? 'hidden' : 'auto';
}, [isAnyOverlayOpen]);
return null;
}
`---
Exit Behavior & Animations
When you call
close(), the manager sets visible = false and waits to unmount the component. By default, if you do not provide any exitDuration, the manager relies on CSS events to remove the overlay.Unmounting happens on the first of these events to occur:
1. CSS Event (Default): A
transitionend or animationend event fires on the overlay's root container. This is the "happy path" for CSS-based animations.
2. Timer: A timeout completes. This is a fallback or primary method if you don't use CSS animations.
3. Manual Callback: You explicitly call the injected onExitComplete() function. This is required for animation libraries like Framer Motion.> Warning: If your component has no CSS transitions/animations on its root element, and you don't set an
exitDuration/defaultExitDuration, the overlay will become invisible after close() but will never be removed from the DOM. To avoid this, either add a CSS transition, set an exitDuration/defaultExitDuration, or call onExitComplete() manually.The precedence for timers is:
1.
options.exitDuration === null: Disables the timer completely. Relies solely on CSS events or onExitComplete.
2. options.exitDuration (number): Uses the per-instance duration.
3. : Uses the global fallback duration.> Important: The manager listens for
transitionend/animationend on the container it renders, not on your component's nested elements. These events do bubble, so child animations will usually be detected. Some libraries or patterns may not emit native events; in that case, set a timer or call onExitComplete() manually. The library safely handles multiple redundant events, so you don't have to worry about race conditions.---
Stacking Behavior
You can control how overlays behave when new ones are opened on top of them.
The precedence is:
open() options > prop > default ('hide-previous').| Behavior | Description | Use Case |
| :------------------------------ | :----------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------- |
|
'hide-previous' (Default) | Opening a new overlay sets visible = false on the one below it. Closing the top one automatically re-shows the previous one. | Modal dialogs, where only one should be interactive at a time. |
| 'stack' | New overlays open on top, and previous ones remain visible. | Toasts, notifications, or non-modal pop-ups. |Example:
`tsx
// Globally set all overlays to stack
;// But override for a specific modal
overlayManager.open('confirm', {
message: 'Are you sure?',
stackingBehavior: 'hide-previous', // This one will hide overlays beneath it
});
`---
DevTools
Install the DevTools package to inspect your overlay stack in development.
`bash
pnpm add -D @react-overlay-manager/devtools
`Render the component next to your
OverlayManager. It automatically does nothing in production.`tsx
import { OverlayManagerDevtools } from '@react-overlay-manager/devtools';function App() {
return (
<>
{/ DevTools will only render in development builds /}
>
);
}
`Toggle with
Ctrl/Cmd + Shift + O.> Warning: Ensure you pass the exact same
manager instance to both and . Using different instances is a common cause for the DevTools appearing empty. Props shown in DevTools may contain sensitive data; its use is intended for development only.---
Examples
$3
A minimal example using pure CSS class toggle to drive animations. The manager's built-in
transitionend listener handles removal automatically because the transition property is on the root element.`tsx
// Spinner.tsx
import { defineOverlay } from '@react-overlay-manager/core';
import './spinner.css';export const Spinner = defineOverlay<{}, void>(({ visible }) => {
// Apply 'enter' or 'exit' class based on the
visible prop
return (
backdrop ${visible ? 'enter' : 'exit'}}>
);
});
``css
/ spinner.css /
/ The transition must be on the root element that OverlayItem renders /
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 200ms ease; / This transition is key /
}
.backdrop.enter {
opacity: 1;
}
.backdrop.exit {
opacity: 0;
}
/ ... spinner styles ... /
`$3
For animation libraries, use
AnimatePresence and call onExitComplete when the animation finishes. This gives you precise control and instant removal.`tsx
// MotionDialog.tsx
import { defineOverlay } from '@react-overlay-manager/core';
import { AnimatePresence, motion } from 'framer-motion';export const MotionDialog = defineOverlay<{ title: string }, void>(
({ title, visible, close, onExitComplete }) => {
return (
{visible && ... />{/ ... /} }
);
}
);
`To rely solely on
onExitComplete, disable the fallback timer:`tsx
// Option 1: Per-call
await overlayManager.open(MotionDialog, {
title: 'Welcome',
exitDuration: null, // Disables the timer for this instance
});// Option 2: Globally (recommended for animation libraries)
;
`---
TypeScript Guide
$3
The
open() method returns a PromiseWithId, which is a standard Promise with an added id property. This lets you access the overlay's ID immediately, even if you await the result later.`ts
// Get the ID synchronously, then await the result
const promise = overlayManager.open('confirm', { message: 'Proceed?' });
const id = promise.id; // id is typed as OverlayIdconst ok = await promise; //
ok is typed as boolean// Or with async/await, though you lose direct access to the promise object
const result: boolean = await overlayManager.open('confirm', {
message: 'Again?',
});
`$3
The manager enforces props at compile time, preventing common bugs.
`ts
// Assuming 'confirm' expects: { message: string }
overlayManager.open('confirm', {
message: 123, // ❌ Type 'number' is not assignable to type 'string'.
unknownProp: true, // ❌ Object literal may only specify known properties.
});
`---
Advanced Patterns
$3
For better code-splitting, use
React.lazy in your registry. Wrap your in a single at the app root to handle loading states without flicker.`tsx
// overlays.ts
import { createOverlayManager } from '@react-overlay-manager/core';
import React, { lazy } from 'react';export const overlays = createOverlayManager({
confirm: lazy(() => import('./dialogs/ConfirmDialog')),
settings: lazy(() => import('./dialogs/SettingsModal')),
});
// App.tsx
}>
$3
Sometimes you want to fire-and-forget an overlay, like a loading spinner. You can grab the
id to close it programmatically later.`tsx
// Show a spinner and don't wait for a result
const spinner = overlayManager.open('spinner');try {
await someAsyncTask();
} finally {
// Close the spinner by its ID when the task is done
overlayManager.getInstance(spinner.id)?.close();
}
`$3
Overlays receive a
manager prop. Use it to open other overlays (nested modals) and keep
key-based typing by narrowing with manager.as<...>(). This avoids circular imports when the
overlay is part of the same registry.- Why
- Avoids circular types/imports
- Keeps nested
open('key', ...) fully type-safeDefine your manager in a separate module and export its type:
`ts
import { createOverlayManager } from '@react-overlay-manager/core';
import { ValidationModal } from '../components/ValidationModal';
// ...other overlaysexport const overlayManager = createOverlayManager({
validationModal: ValidationModal,
// confirmModal: ConfirmModal, ...
});
export type AppOverlayManager = typeof overlayManager;
```ts
import type { AppOverlayManager } from '../services/overlayManager';export const ValidationModal = defineOverlay(
({ manager, ...props }) => {
manager
.as()
.open('confirmModal', { / ... / });
// or open by component directly (no key)
// manager.open(ConfirmModal, { / ... / });
return null;
}
);
You can also dynamically import the manager to avoid circular imports:
`ts
const { overlayManager } = await import('../services/overlayManager');
overlayManager.open('confirmModal', { / ... / });
``---
Edge Cases & Error Handling
-
OverlayAlreadyOpenError: Thrown if you call open() with an id that is already visible.
- Solution: Omit the id to let the manager auto-generate a unique one. Alternatively, calling open() with the ID of a _hidden_ overlay will show and update it instead of throwing an error.
- OverlayNotFoundError: Thrown if you call hide(), show(), or update() on an ID that has already been closed and removed.
- Solution: In complex async flows, guard your calls: if (manager.isOpen(id)) manager.hide(id); or wrap them in a try/catch block.---
SSR / Next.js
The library is SSR-safe. It avoids accessing
window or document on the server.-
portalTarget: On the server, overlays render inline as defaultPortalTarget is null. On the client, it defaults to document.body. You can provide a stable portal element for better DOM structure.
- Code-splitting: Use next/dynamic to prevent overlay components from being included in the initial server bundle.`tsx
// app/layout.tsx
export default function RootLayout({ children }) {
return (
{children}
{/ A dedicated portal target for overlays /}
);
}// app/Providers.tsx
('use client');
import { OverlayManager } from '@react-overlay-manager/core';
import { overlays } from './overlays';
export function Providers({ children }) {
return (
<>
{children}
manager={overlays}
portalTarget={document.getElementById('overlay-portal')}
/>
>
);
}
`---
Accessibility
This library is headless and unopinionated about your markup. It is your responsibility to make your overlay components accessible. Key considerations include:
- Roles: Use
role="dialog" or role="alertdialog".
- Labels: Provide an accessible name with aria-labelledby and/or aria-describedby.
- Modality: Use aria-modal="true" for modal dialogs.
- Focus Management: Trap focus within the overlay while it's open and return focus to the trigger element when it closes.
- Keyboard Navigation: Allow closing with the Escape key.Consider using the native HTML