@react-overlay-manager/core
v0.4.1
Published
A lightweight React overlay management library with full TypeScript support
Downloads
1,181
Maintainers
Readme
React Overlay Manager
A 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,
useSyncExternalStoreunder the hood - 📏 Small – ~2.6 kB gzipped
Table of Contents
- Installation
- Quick Start
- API Reference
- React Hook:
useOverlayStore - Exit Behavior & Animations
- Stacking Behavior
- DevTools
- Examples
- TypeScript Guide
- Advanced Patterns
- Edge Cases & Error Handling
- SSR / Next.js
- Accessibility
- Troubleshooting
- Bundling & Versioning
- Quality & Coverage
- License
Installation
pnpm add @react-overlay-manager/core # or yarn / npmQuick Start
1. Define an overlay component
Use the defineOverlay helper for full type-safety. It injects props like visible (for animations) and close (to return a result).
// src/components/ConfirmDialog.tsx
import { defineOverlay } from '@react-overlay-manager/core';
import cx from 'clsx';
interface ConfirmDialogProps {
message: string;
}
export const ConfirmDialog = defineOverlay<ConfirmDialogProps, boolean>(
({ message, visible, close }) => {
const dialogClass = cx(
'... transition-opacity duration-300',
visible
? 'opacity-100 pointer-events-auto'
: 'opacity-0 pointer-events-none'
);
return (
<div className={dialogClass}>
<div className="...">
<p>{message}</p>
<button onClick={() => close(true)}>Confirm</button>
<button onClick={() => close(false)}>Cancel</button>
</div>
</div>
);
}
);2. Create a manager
A manager holds the registry of your overlays.
// src/services/overlayManager.ts
import { createOverlayManager } from '@react-overlay-manager/core';
import { ConfirmDialog } from '../components/ConfirmDialog';
export const overlayManager = createOverlayManager({
confirm: ConfirmDialog,
});3. Render the manager at your app's root
The <OverlayManager> component is responsible for rendering your overlays into the DOM. You must always include it in your app's root.
// src/App.tsx
import { OverlayManager } from '@react-overlay-manager/core';
import { overlayManager } from './services/overlayManager';
import { MyPage } from './MyPage';
export default function App() {
return (
<>
<MyPage />
<OverlayManager manager={overlayManager} />
</>
);
}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.
4. Open an overlay from anywhere
Call manager.open() from any component, hook, or service. It's fully async and type-safe.
// 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
}
}5. Open a component directly (no registry needed)
You can also open components on-the-fly without registering them first.
import { overlayManager } from '../services/overlayManager';
import { TempDialog } from '../components/TempDialog';
await overlayManager.open(TempDialog, { title: 'One-off dialog' });Alternative: Simplified Setup (No Registry)
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 <OverlayManager> component and pass this default instance to it.
// src/App.tsx
import { OverlayManager, overlays } from '@react-overlay-manager/core';
import { MyPage } from './MyPage';
export default function App() {
return (
<>
<MyPage />
{/* Render the manager, passing the default 'overlays' instance */}
<OverlayManager manager={overlays} />
</>
);
}
// 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
Manager Methods
| 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. |
Injected Overlay Props
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. |
<OverlayManager /> Props
| 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. |
open() Options (OpenOptions)
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 <OverlayManager portalTarget={...} />. |
| stackingBehavior | 'stack' | 'hide-previous' | Per-instance stacking mode. Overrides global stackingBehavior. |
React Hook: useOverlayStore
For 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.
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:
- CSS Event (Default): A
transitionendoranimationendevent fires on the overlay's root container. This is the "happy path" for CSS-based animations. - Timer: A timeout completes. This is a fallback or primary method if you don't use CSS animations.
- 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 afterclose()but will never be removed from the DOM. To avoid this, either add a CSS transition, set anexitDuration/defaultExitDuration, or callonExitComplete()manually.
The precedence for timers is:
options.exitDuration === null: Disables the timer completely. Relies solely on CSS events oronExitComplete.options.exitDuration(number): Uses the per-instance duration.<OverlayManager defaultExitDuration={...} />: Uses the global fallback duration.
Important: The manager listens for
transitionend/animationendon the container<div>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 callonExitComplete()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 > <OverlayManager /> 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:
// Globally set all overlays to stack
<OverlayManager manager={overlayManager} stackingBehavior="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.
pnpm add -D @react-overlay-manager/devtoolsRender the component next to your OverlayManager. It automatically does nothing in production.
import { OverlayManagerDevtools } from '@react-overlay-manager/devtools';
function App() {
return (
<>
<OverlayManager manager={overlayManager} />
{/* DevTools will only render in development builds */}
<OverlayManagerDevtools manager={overlayManager} />
</>
);
}Toggle with Ctrl/Cmd + Shift + O.
Warning: Ensure you pass the exact same
managerinstance to both<OverlayManager />and<OverlayManagerDevtools />. 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
CSS Transitions
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.
// 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 (
<div className={`backdrop ${visible ? 'enter' : 'exit'}`}>
<div className="spinner" />
</div>
);
});/* 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 ... */Framer Motion
For animation libraries, use AnimatePresence and call onExitComplete when the animation finishes. This gives you precise control and instant removal.
// 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 (
<AnimatePresence onExitComplete={onExitComplete}>
{visible && <motion.div /* ... */>{/* ... */}</motion.div>}
</AnimatePresence>
);
}
);To rely solely on onExitComplete, disable the fallback timer:
// 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)
<OverlayManager manager={overlayManager} defaultExitDuration={null} />;TypeScript Guide
Async/Await and PromiseWithId
The open() method returns a PromiseWithId<TResult>, 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.
// Get the ID synchronously, then await the result
const promise = overlayManager.open('confirm', { message: 'Proceed?' });
const id = promise.id; // `id` is typed as OverlayId
const 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?',
});Compile-time Errors for Wrong Props
The manager enforces props at compile time, preventing common bugs.
// 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
Central Registry with React.lazy
For better code-splitting, use React.lazy in your registry. Wrap your <OverlayManager> in a single <Suspense> at the app root to handle loading states without flicker.
// 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
<Suspense fallback={<GlobalSpinner />}>
<OverlayManager manager={overlays} />
</Suspense>;Using open() without await
Sometimes you want to fire-and-forget an overlay, like a loading spinner. You can grab the id to close it programmatically later.
// 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();
}Nested overlays with the injected manager (concise and type-safe)
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-safe
Define your manager in a separate module and export its type:
import { createOverlayManager } from '@react-overlay-manager/core';
import { ValidationModal } from '../components/ValidationModal';
// ...other overlays
export const overlayManager = createOverlayManager({
validationModal: ValidationModal,
// confirmModal: ConfirmModal, ...
});
export type AppOverlayManager = typeof overlayManager;import type { AppOverlayManager } from '../services/overlayManager';
export const ValidationModal = defineOverlay<ValidationModalProps, void>(
({ manager, ...props }) => {
manager
.as<AppOverlayManager['registry']>()
.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 callopen()with anidthat is already visible.- Solution: Omit the
idto let the manager auto-generate a unique one. Alternatively, callingopen()with the ID of a hidden overlay will show and update it instead of throwing an error.
- Solution: Omit the
OverlayNotFoundError: Thrown if you callhide(),show(), orupdate()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 atry/catchblock.
- Solution: In complex async flows, guard your calls:
SSR / Next.js
The library is SSR-safe. It avoids accessing window or document on the server.
portalTarget: On the server, overlays render inline asdefaultPortalTargetisnull. On the client, it defaults todocument.body. You can provide a stable portal element for better DOM structure.- Code-splitting: Use
next/dynamicto prevent overlay components from being included in the initial server bundle.
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* A dedicated portal target for overlays */}
<div id="overlay-portal" />
</body>
</html>
);
}
// app/Providers.tsx
('use client');
import { OverlayManager } from '@react-overlay-manager/core';
import { overlays } from './overlays';
export function Providers({ children }) {
return (
<>
{children}
<OverlayManager
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"orrole="alertdialog". - Labels: Provide an accessible name with
aria-labelledbyand/oraria-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
Escapekey.
Consider using the native HTML <dialog> element.
Troubleshooting
- Overlay doesn't disappear after animation:
- Ensure your CSS
transitionoranimationis on the root element of your overlay component. - If animations are nested or you don't use CSS animations, you must either set an
exitDuration/defaultExitDurationor callonExitComplete()manually.
- Ensure your CSS
OverlayAlreadyOpenError:- You are trying to
open()an overlay with anidthat is already visible. Let the manager generate IDs automatically by omitting theidoption.
- You are trying to
- Overlays appear behind other content:
- Check for parent elements with a
z-indexandpositionthat create a new stacking context. The<OverlayManager zIndexBase={...} />prop can help, but portals are the best solution. Render overlays into a dedicated portal element at the end of<body>.
- Check for parent elements with a
Bundling & Versioning
The library ships with CJS and ESM formats, with type definitions (
.d.ts).reactandreact-domare listed asexternaldependencies.The current version is available as an export:
import { version } from '@react-overlay-manager/core'.
Quality & Coverage
- Runtime tests: 66 tests across 22 files, with overall coverage: Statements 96.64%, Branches 85.25%, Functions 93.67%, Lines 96.64%.
- Type-level tests (tsd): 15 focused specs across core and devtools verifying generics and API contracts:
open()overloads and argument optionality (by key and by component)OverlayManagerPropsshape and constraints- Helper types:
ComponentProps<T>,OverlayResult<T> AnyOverlayInstancenarrowing and typed instances- Event typing (
ManagerEvent<T>) and default manager usage - Branded
OverlayIdinOpenOptions.id
These checks run in CI to prevent regressions and ensure the library remains safe to adopt.
