All articles
architecture

Nested Modals and Toasts with Base UI

Share this article

Share on LinkedIn Share on X (formerly Twitter)

Managing overlays—such as modals, dialogs, drawers, and toasts—is one of the trickiest parts of frontend engineering. When overlays are nested within other overlays, browser layout engines often present structural challenges like z-index conflicts and parent clipping.

This article provides an in-depth breakdown of a clean, highly robust solution built using Next.js and Base UI. We will examine how Base UI automatically manages nested modal states and portals, and how it provides a built-in global toast manager to handle imperative notifications effortlessly.


1. System Architecture & Tech Stack

This application relies on a modular architecture designed to keep components decoupled, accessible, and visually consistent.

The Stack

  • Framework: Next.js (v14.0.1) using the Pages Router.
  • Primitives: Base UI (@base-ui/react).
  • Styling: Vanilla CSS focusing on transition keyframes and layout isolation.

2. The Portaling Strategy: Zero-Config Safe Portals

In vanilla HTML/CSS, if you render an overlay inside a parent component that has position: relative or overflow: hidden, the overlay may be cropped.

Historically, solving this in React, especially with Server-Side Rendering (SSR) in Next.js, required manually creating portal containers in _document.jsx and carefully waiting for client hydration to use ReactDOM.createPortal.

Base UI completely eliminates this overhead. Components like <Dialog.Portal> and <Toast.Portal> automatically handle hydration and append their contents directly to the <body> element by default. You don't need any custom portal wrappers or _document.jsx modifications. It just works.


3. Core Components & State Design

A. Context-Driven Global Modals

To open a modal from anywhere in the component hierarchy without passing props down multiple levels, we expose a React context:

// src/pages/ModalContext.jsx
import { Dialog } from '@base-ui/react/dialog';
import { createContext, useContext, useState } from "react";
 
const ModalContext = createContext({
  isOpen: false,
  setIsOpen: (_open) => {}
});
 
export const useModalContext = () => useContext(ModalContext);
 
export const ModalContextProvider = ({ children }) => {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <ModalContext.Provider value={{ isOpen, setIsOpen }}>
      {children}
      
      {/* Root-level Dialog listening to global state */}
      <Dialog.Root 
        open={isOpen} 
        onOpenChange={(open, details) => {
          if (!open && details?.event?.target instanceof Element && details.event.target.closest('.ToastRoot')) {
            details.cancel();
            return;
          }
          setIsOpen(open);
        }}
      >
        <Dialog.Portal>
          <Dialog.Backdrop className="DialogOverlay" />
          <Dialog.Popup className="DialogContent">
            <Dialog.Title className="DialogTitle">Global Modal</Dialog.Title>
            <Dialog.Description className="DialogDescription">
              This modal is controlled programmatically via context.
            </Dialog.Description>
            
            <Dialog.Close className="IconButton" aria-label="Close">
              Close
            </Dialog.Close>
          </Dialog.Popup>
        </Dialog.Portal>
      </Dialog.Root>
    </ModalContext.Provider>
  );
};

B. Global Toast Manager

Toasts are typically triggered as side-effects of async actions. Instead of wrangling local state and useImperativeHandle refs, Base UI provides a global toastManager out of the box.

We create a single instance of the manager and export it:

// src/components/toastManager.js
import { Toast } from '@base-ui/react/toast';
 
export const toastManager = Toast.createToastManager();

We then wrap our application root in <Toast.Provider> and render the viewport:

// src/pages/_app.jsx
import { Toast } from '@base-ui/react/toast';
import { toastManager } from '@/components/toastManager';
import { ModalContextProvider } from './ModalContext';
 
export default function App({ Component, pageProps }) {
  return (
    <Toast.Provider toastManager={toastManager}>
      <ModalContextProvider>
        <Component {...pageProps} />
      </ModalContextProvider>
      <GlobalToasts />
    </Toast.Provider>
  );
}
 
function GlobalToasts() {
  const { toasts } = Toast.useToastManager();
  return (
    <Toast.Portal>
      <Toast.Viewport className="ToastViewport">
        {toasts.map((toast) => (
          <Toast.Root key={toast.id} toast={toast} className="ToastRoot">
            <Toast.Content className="ToastContent">
              <Toast.Description>{toast.description}</Toast.Description>
              <Toast.Close>Dismiss</Toast.Close>
            </Toast.Content>
          </Toast.Root>
        ))}
      </Toast.Viewport>
    </Toast.Portal>
  );
}

Now, any component in the app can trigger a toast simply by calling: toastManager.add({ description: 'Saved successfully!' })


C. Integrating Nested Overlays

The implementation below brings these patterns together. It demonstrates how a modal can safely spawn another nested modal, and how it can trigger a global toast without getting blocked or accidentally closed.

// src/pages/Modal1.jsx
import { Dialog } from '@base-ui/react/dialog';
import { useModalContext } from './ModalContext';
import { toastManager } from '@/components/toastManager';
 
export function Modal1() {
  const { setIsOpen } = useModalContext();
 
  return (
    <Dialog.Root
      onOpenChange={(open, details) => {
        if (!open && details?.event?.target instanceof Element && details.event.target.closest('.ToastRoot')) {
          details.cancel();
        }
      }}
    >
      <Dialog.Trigger>Open primary modal</Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Backdrop className="DialogOverlay" />
        <Dialog.Popup className="DialogContent">
          <Dialog.Title>Primary Modal</Dialog.Title>
          <Dialog.Description>
            This is the first modal.
          </Dialog.Description>
          
          <div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
            {/* 1. Imperative Toast Spawning */}
            <button onClick={() => toastManager.add({ description: 'Action successful!' })}>
              Fire Toast
            </button>
 
            {/* 2. Programmatic Global Modal */}
            <button onClick={() => setIsOpen(true)}>
              Open global modal
            </button>
 
            {/* 3. Nested Modal */}
            <NestedModal />
          </div>
 
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Popup>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
 
function NestedModal() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Open nested modal</Dialog.Trigger>
      
      <Dialog.Portal>
        <Dialog.Backdrop className="DialogOverlay" />
        <Dialog.Popup className="DialogContent">
          <Dialog.Title>Nested Modal</Dialog.Title>
          <Dialog.Description>
            This sits on top of the primary modal flawlessly.
          </Dialog.Description>
          
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Popup>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

4. CSS Transitions and Layout

Base UI components are unstyled, giving you complete control. We can define our portal layers and animations cleanly in our global CSS.

/* src/styles/globals.css */
.DialogOverlay {
  background-color: rgba(0, 0, 0, 0.3);
  position: fixed;
  inset: 0;
  animation: overlayShow 150ms ease-out;
}
 
.DialogContent {
  background-color: white;
  border-radius: 6px;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 90vw;
  max-width: 450px;
  padding: 25px;
  animation: contentShow 150ms ease-out;
}
 
@keyframes overlayShow {
  from { opacity: 0; }
  to { opacity: 1; }
}
 
@keyframes contentShow {
  from {
    opacity: 0;
    transform: translate(-50%, -48%) scale(0.96);
  }
  to {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
  }
}
 
/* Toast stacking positioning */
.ToastViewport {
  position: fixed;
  z-index: 9999;
  bottom: 2rem;
  right: 2rem;
  width: 22.5rem;
}

Summary

Migrating from older primitives to Base UI simplifies the architecture massively:

  1. Zero-Config Portals: Base UI handles React Portals under the hood, bypassing hydration headaches in Next.js.
  2. Built-in Global Toasts: The native createToastManager provides imperative notifications without writing custom hooks or useImperativeHandle boilerplate.
  3. Flawless Stacking: Nested dialogs and independent global toasts stack perfectly without conflicting z-index hacks or layout shifts.

Comments