Documentation
Introduction
A headless popup system designed for building accessible, composable overlays such as modals, drawers and sidebars.
It provides a unified API for managing multiple overlays while maintaining full control over styling and layout.
Why This Exists
Most modal implementations rely on isolated boolean state and tightly coupled UI, which limits composability and makes it difficult to support more advanced overlay patterns.
As application grow, these limitations lead to fragmented logic, inconsistent behavior, and poor accessibility support.
This system introduce a headless, stack-driven architecture that centralizes overlay state and enables scalable composition of modals, drawers, and sidebars through a unified API.
Architecture
The system is structured around two core layers: a global stack manager and isolated popup instances.
A top-level provider maintains a centralized stack that tracks active popups, enabling proper layering and control of multiple overlays.
Each popups instance establishes its own context boundary, allowing triggers and content components to communicate without relying on prop drilling or shared global state.
PopupProvider (global stack)
↓
Popup (instance boundary)
↓
Trigger / Content / CloseThis separation ensures predictable behavior, scalability, and composability across different overlay types.
Design Decision
- Stack-based state over boolean flags: Enables multiple popups to coexist and ensures only the topmost overlay is interactive at any time
- Compound component pattern: Provides a declarative API where related components share state implicitly through context.
- Headless architecture: Separates behavior from presentation, allowing full control over styling and layout without constraints.
- Context isolation per instance: Ensures each popup operates independently, preventing unintended interactions between multiple instances.
- Portal-based rendering: Avoids stacking and layout conflicts by rendering overlays outside the normal DOM hierarchy.
Stack System
Popups are managed using a Last-In-First-Out (LIFO) stack model.
type PopupStackItem = {
id: string;
};
- Opening a popup pushes it onto the stack.
- Closing removes it from the stack.
- The topmost popup is always the active one.
Variants
Variants control layout and animation behavior while sharing the same underlying logic.
'modal' | 'drawer-left' | 'drawer-right'
Each variant applies different positioning and transitions using state-driven styles.
Overlay Behavior
The system includes built-in overlay handling to ensure a consistent and accessible user experience across all popup variants.
Scroll Lock
When a popup is open, background scrolling is disabled to prevent interaction with underlying content.
Overlay Click to Close
Clicking outside the popup content (on the overlay) will close the active popup.
This behavior is scoped to the topmost popup in the stack, preventing unintended closures when multiple overlays are present.
useOverlay(containerRef, {
isOpen,
onClose,
});
The overlay logic is encapsulated in a reusable hook, which handles outside clicks and escape key interactions while respecting the popup stack.
Escape Key Handling
Pressing the escape key closes the topmost popup, maintaining predictable and accessible interaction patterns.
API
<Popup>
<Popup.Trigger asChild>
<button type='button'>Open</button>
</Popup.Trigger>
<Popup.Content variant='modal'>
<Popup.Header>
<Popup.Title>Title</Popup.Title>
<Popup.Close>X</Popup.Close>
</Popup.Header>
<Popup.Body>Content</Popup.Body>
</Popup.Content>
</Popup>
Keyboard Interactions
The system provides built-in keyboard support to ensure accessibility and predictable interaction across all popup variants.
Focus Trap
When a popup is active, focus is contained within the overlay, preventing interaction with elements outside of it.
This ensures users navigating via keyboard can cycle through interactive elements without leaving the popup context.
useFocusTrap(containerRef, isOpen);
const focusable = container.querySelectorAll(
'a, button, input, textarea, select, [tabindex]:not([tabindex='-1'])'
);
The focus trap is implemented using a custom hook that manages keyboard navigation and ensures focus loops between the first and last focusable elements.
Escape Key
Pressing the Escape key closes the topmost popup in the stack, ensuring consistent and expected behavior.
Tab Navigation
- Esc - closes the topmost popup
- Tab - moves focus forward
- Shift + Tab - moves focus backward