# Slide-Out Cart Drawer in React With Native dialog

> 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().

- URL: https://qodesign.dev/blog/cart-drawer-react-native-dialog-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog) and the [HTML spec](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element), 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](/blog/radix-dialog-pointer-events-none-body-fix), because nothing touches the body.

## 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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@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()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/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](/blog/prefers-reduced-motion-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](/blog/scroll-container-stealing-page-scroll-react) 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](/components/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](/blog/animated-number-counter-react-no-library) 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](/react/animated-cart-drawer) 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.
