Slide-Out Cart Drawer in React With Native dialog

5 min read

TL;DR Open a native <dialog> with showModal() from a useEffect (never pass open as a prop). You get the top layer, an inert page, Escape, and focus that comes back to the cart button. Slide it in with @starting-style. To animate the close, set data-closing, wait for getAnimations() to finish, then call close(). Close on backdrop click when e.target is the dialog, and lock page scroll with html:has().

On this page
  1. What showModal() gives you for free
  2. The component
  3. The CSS: slide in, slide out, lock the page
  4. What about the CSS-only exit animation?
  5. Browser support
  6. Mistakes AI tools make with drawers
  7. A prompt for Cursor, v0 or Lovable
  8. A cart that does more
  9. FAQ
  10. How do I make an accessible cart drawer in React without a library?
  11. Why doesn't my dialog close animation play?
  12. How do I close a dialog when the backdrop is clicked?
  13. Does a modal dialog stop the page from scrolling?

A cart drawer is the piece AI tools get wrong most quietly. Ask Cursor, v0 or Lovable for one and you usually get a div with fixed inset-y-0 right-0, a dark overlay div behind it and an onClick to close it. It looks right. Then you press Tab and focus walks out of the drawer into the page behind it, Escape does nothing, and a screen reader has no idea a dialog opened.

The browser already ships the hard parts. A native <dialog> opened with showModal() sits above everything, makes the rest of the page inert, closes on Escape, moves focus inside and gives focus back to the cart button when it closes. This post builds a slide-over cart drawer on top of it with React and Tailwind, including a slide-out animation, with no library.

I make the Stack Cart Drawer on qodesign, so I spend a lot of time on drawers. That piece uses Framer Motion for its flying product discs. The code in this post is a smaller version written for this post, uses no animation library, and is not the component's source.

What showModal() gives you for free

Per MDN's dialog page and the HTML spec, a dialog opened with showModal():

  • goes into the top layer, so no z-index fight with your header
  • makes everything outside it inert, so Tab can't leave it and clicks don't reach the page
  • gets a ::backdrop you can style, so no overlay div
  • fires a cancel event and closes when the user presses Escape
  • moves focus to the first focusable element inside it
  • puts focus back on whatever had it before (your cart button) when it closes

That is the whole focus trap and Escape handling you would otherwise write by hand. It also avoids the leftover pointer-events: none on the body that Radix dialogs sometimes leave behind, because nothing touches the body.

Stack Cart Drawer running live. Products fly into the cart as discs; open it and they unfold into line items.

The component

The trick in React is to never pass open to the <dialog> as a prop. The open attribute shows the dialog as non-modal, without the inert page or the backdrop. Keep your own open state and call showModal() and close() from an effect.

tsx
// components/cart-drawer.tsx
"use client";

import { useEffect, useId, useRef, type ReactNode } from "react";

type CartDrawerProps = {
  open: boolean;
  onClose: () => void;
  title?: string;
  children: ReactNode;
};

export function CartDrawer({ open, onClose, title = "Your cart", children }: CartDrawerProps) {
  const ref = useRef<HTMLDialogElement>(null);
  const titleId = useId();

  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;

    if (open) {
      if (!dialog.open) dialog.showModal();
      delete dialog.dataset.closing; // also cancels a close that is still animating
      return;
    }
    if (!dialog.open) return;

    // Play the slide-out first, then really close.
    dialog.dataset.closing = "";
    const running = dialog.getAnimations({ subtree: true }).map((a) => a.finished);
    Promise.allSettled(running).then(() => {
      if (dialog.dataset.closing === undefined) return; // reopened meanwhile
      dialog.close();
      delete dialog.dataset.closing;
    });
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      className="cart-drawer bg-white text-zinc-900 shadow-2xl dark:bg-zinc-900 dark:text-zinc-100"
      onCancel={(e) => {
        e.preventDefault(); // Esc: animate out instead of vanishing
        onClose();
      }}
      onClose={onClose}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose(); // click on the backdrop
      }}
    >
      <div className="flex h-full flex-col">
        <header className="flex items-center justify-between border-b border-zinc-200 p-4 dark:border-zinc-800">
          <h2 id={titleId} className="text-lg font-semibold">
            {title}
          </h2>
          <button
            type="button"
            onClick={onClose}
            aria-label="Close cart"
            className="rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800"
          >
            <span aria-hidden="true">✕</span>
          </button>
        </header>
        <div className="flex-1 overflow-y-auto overscroll-contain p-4">{children}</div>
      </div>
    </dialog>
  );
}

Three handlers do the closing:

  • onCancel catches Escape. preventDefault() stops the instant close so the slide-out can play, and onClose() flips your state.
  • onClick checks e.target === e.currentTarget. A click on the backdrop lands on the dialog element itself, while clicks inside land on its children. That is why the inner div fills the whole dialog with h-full and the dialog has no padding.
  • onClose (the dialog's own close event) keeps your state in sync if the dialog closes some other way.

Using it is one piece of state:

tsx
// components/cart-button.tsx
"use client";

import { useState } from "react";
import { CartDrawer } from "./cart-drawer";

type Line = { id: string; name: string; qty: number };

export function CartButton({ lines }: { lines: Line[] }) {
  const [open, setOpen] = useState(false);
  const count = lines.reduce((n, l) => n + l.qty, 0);

  return (
    <>
      <button type="button" aria-haspopup="dialog" onClick={() => setOpen(true)}>
        Cart ({count})
      </button>
      <CartDrawer open={open} onClose={() => setOpen(false)}>
        <ul className="space-y-3">
          {lines.map((l) => (
            <li key={l.id} className="flex justify-between">
              <span>{l.name}</span>
              <span className="tabular-nums">x{l.qty}</span>
            </li>
          ))}
        </ul>
      </CartDrawer>
    </>
  );
}

The CSS: slide in, slide out, lock the page

Put this in your globals.css. Plain CSS is easier to read here than a pile of arbitrary Tailwind variants, and Tailwind v4 is fine with it next to @import "tailwindcss".

css
.cart-drawer {
  /* pin to the right edge, full height */
  margin: 0 0 0 auto;
  width: min(24rem, 100vw);
  max-width: none;
  height: 100dvh;
  max-height: none;
  padding: 0;
  border: 0;
  translate: 0 0;
  transition: translate 300ms cubic-bezier(0.32, 0.72, 0, 1);
}
.cart-drawer::backdrop {
  background: rgb(0 0 0 / 0.4);
  opacity: 1;
  transition: opacity 300ms ease;
}

/* where it comes in from, each time it opens */
@starting-style {
  .cart-drawer[open] {
    translate: 100% 0;
  }
  .cart-drawer[open]::backdrop {
    opacity: 0;
  }
}

/* where it goes while the close animation plays */
.cart-drawer[data-closing] {
  translate: 100% 0;
}
.cart-drawer[data-closing]::backdrop {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .cart-drawer,
  .cart-drawer::backdrop {
    transition: none;
  }
}

/* stop the page behind from scrolling */
html:has(.cart-drawer[open]) {
  overflow: hidden;
}

How the two directions work:

  • Opening: a dialog goes from display: none to shown, and normally CSS transitions don't run on that first frame. @starting-style gives the browser a "before" value (translate: 100% 0, backdrop at opacity 0), so the drawer slides in every time it opens.
  • Closing: close() hides the dialog at once, so the effect sets data-closing first. That moves the drawer back off screen and fades the backdrop. Then it waits for those transitions with getAnimations() (subtree: true includes the backdrop) and only then calls close().

With reduced motion on, the transitions are none, getAnimations() returns an empty list and the drawer closes right away. If you want more on that, see how to respect prefers-reduced-motion in React.

The html:has(...) rule is there because a modal dialog makes the page inert, but in practice the page behind can still scroll with a wheel or a swipe. overscroll-contain on the item list stops the list from handing its leftover scroll to the page, which this post on overscroll-behavior covers in detail.

What about the CSS-only exit animation?

MDN describes another way to animate the close: add display and overlay to the transition with allow-discrete, so the browser keeps the dialog on screen until the transition ends. It's less code, but MDN's compatibility data lists overlay only for Chrome and Edge (117+). In Firefox and Safari the dialog would leave the top layer the moment it closes. The data-closing approach above works the same in all three.

Browser support

From MDN's compatibility data:

  • <dialog> and showModal(): Chrome 37, Firefox 98, Safari 15.4
  • @starting-style: Chrome 117, Firefox 129, Safari 17.5
  • :has(), used for the scroll lock: every current browser

In a browser without @starting-style the drawer simply appears without sliding in. Everything else keeps working.

Mistakes AI tools make with drawers

When you ask an AI tool for a cart drawer, check for these:

  1. A div role="dialog" with no focus trap. Use <dialog> and showModal().
  2. <dialog open={open}>. That opens it as non-modal. Call showModal() from an effect.
  3. An overlay div with a big z-index. Style ::backdrop instead.
  4. Rendering the drawer only when open ({open && <Drawer />}). The close animation can't play because the element is already gone.
  5. No Escape handling, or Escape handled with a keydown listener on window. The dialog's cancel event already does this.
  6. Body scroll lock done by setting document.body.style.overflow in an effect and never restoring it. The CSS :has() rule undoes itself when the dialog closes.

A prompt for Cursor, v0 or Lovable

text
Build a cart drawer with the native <dialog> element, React and Tailwind, no animation library.
- Keep React state "open". In a useEffect call dialog.showModal() when open becomes true. Never pass open as a prop to <dialog>.
- To close: set data-closing on the dialog, await Promise.allSettled(dialog.getAnimations({ subtree: true }).map(a => a.finished)), then call dialog.close() and remove data-closing. Skip the close if open became true again meanwhile.
- onCancel: preventDefault() and set open to false. onClose: set open to false. onClick: close when e.target === e.currentTarget (backdrop click).
- CSS: pin the dialog to the right, full height, no padding. Transition translate. Use @starting-style for translate: 100% 0 and backdrop opacity 0 on [open]; the same values on [data-closing]. transition: none under prefers-reduced-motion.
- Add html:has(.cart-drawer[open]) { overflow: hidden; } and overscroll-contain on the item list.
- Label the dialog with aria-labelledby pointing at its heading. Put the close button first.

A cart that does more

The drawer above is plain on purpose. The Stack Cart Drawer is the version with more going on: products fly into the cart as discs, the total rolls like an odometer (the number counter post shows how that roll works), a ring fills toward free shipping, and each disc unfolds into a line item when the drawer opens. It's a modal dialog with focus handling and live announcements. There's a Next.js guide for it too.

FAQ

How do I make an accessible cart drawer in React without a library?

Use the native <dialog> element and open it with showModal() from a useEffect. That gives you the top layer, an inert page behind it, Escape to close, and focus that moves in and comes back to the trigger button. Pin it to the right with CSS and slide it in with @starting-style.

Why doesn't my dialog close animation play?

dialog.close() hides the element immediately, so there's nothing left to animate. Set a data-closing attribute that moves the drawer off screen, wait for getAnimations() to finish, then call close(). Also make sure you aren't unmounting the drawer when it closes.

How do I close a dialog when the backdrop is clicked?

A click on the ::backdrop is reported on the dialog element itself. Remove the dialog's padding, fill it with one inner wrapper, and close when e.target === e.currentTarget in the dialog's onClick.

Does a modal dialog stop the page from scrolling?

It makes the page inert, but the page can usually still scroll behind it. Add html:has(dialog[open]) { overflow: hidden; } (or your drawer's class) and use overscroll-contain on any scrolling list inside the drawer.