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().
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.
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 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.
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.
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.
When you ask an AI tool for a cart drawer, check for these:
A div role="dialog" with no focus trap. Use <dialog> and showModal().
<dialog open={open}>. That opens it as non-modal. Call showModal() from an effect.
An overlay div with a big z-index. Style ::backdrop instead.
Rendering the drawer only when open ({open && <Drawer />}). The close animation can't play because the element is already gone.
No Escape handling, or Escape handled with a keydown listener on window. The dialog's cancel event already does this.
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.
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.
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.
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.
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.
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.
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.