Blog · · 15 min

A cart drawer component, in full

A cart drawer is a modal dialog, so it needs a focus trap, Escape to close, a scroll lock on the body that does not shift the page, and a live region that announces the line that just arrived. The contents survive a reload because you store SKUs and quantities, never prices. It opens by itself after an add from a product page, and it must not open after an add from a grid.

By

The drawer is thirty minutes of layout and a week of behaviour. The panel slides in on the first try. What takes the week is the six things underneath: focus that goes into the panel and cannot leave it, Escape that closes it, a body that stops scrolling without the page jumping sideways, a cart that is still there after a reload, an announcement when a line arrives, and an exit animation that does not eat the next click.

Every one of those is a bug that reaches production, because none of them is visible in a screenshot.

Is it a panel or a dialog?

It is a dialog. It covers the page, it takes the interaction, and nothing behind it should be reachable while it is open. That single classification decides most of the markup.

<aside
  ref={panelRef}
  role="dialog"
  aria-modal="true"
  aria-labelledby="cart-drawer-title"
  tabIndex={-1}
  data-state={open ? 'open' : 'closed'}
>
  <h2 id="cart-drawer-title">Your cart</h2>
  {/* lines, subtotal, checkout */}
</aside>

Three details in those six lines get dropped. aria-modal="true" is what tells assistive technology that the rest of the page is not available; without it a screen reader user keeps browsing the product page underneath and never learns that a cart appeared. aria-labelledby pointing at the heading is what gives the dialog a name when it is announced. And tabIndex={-1} on the panel is what lets you move focus onto the container itself when there is nothing focusable inside yet, which is exactly the case for an empty cart.

The native <dialog> element gives you the trap and the inert background for free, and it is a reasonable choice. It also gives you a ::backdrop you style separately, a showModal() you have to call imperatively from an effect, and a top layer that ignores your z-index stack. Either path works. The rest of this article assumes you built it yourself, because that is what an agent produces when asked for a cart drawer.

![A shallow drawer of dark oiled wood pulled halfway out of a black cabinet under hard raking light, three folded paper receipts standing upright inside it, a small brass plate screwed to the drawer front.](/blog/tiroir-a-glissiere.webp "Pulled out, it holds what was already decided. The work is in the runners, which is the part nobody photographs.")

How do you trap focus without pulling in a library?

Focus is the failure nobody reports and everybody suffers. The drawer opens, the buyer presses Tab, and the highlight walks off into the product page behind the overlay. On a keyboard it is confusing. With a screen reader it is a dead end.

A trap is about forty lines.

// useFocusTrap.ts
import { useEffect, type RefObject } from 'react'

const FOCUSABLE = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',')

export function useFocusTrap(
  ref: RefObject<HTMLElement | null>,
  active: boolean,
  onEscape: () => void,
) {
  useEffect(() => {
    const root = ref.current
    if (!active || !root) return

    const previous = document.activeElement as HTMLElement | null
    const visible = () =>
      Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
        (node) => node.offsetParent !== null,
      )

    const opener = visible()[0] ?? root
    opener.focus()

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        event.stopPropagation()
        onEscape()
        return
      }
      if (event.key !== 'Tab') return

      const nodes = visible()
      if (nodes.length === 0) {
        event.preventDefault()
        return
      }

      const first = nodes[0]
      const last = nodes[nodes.length - 1]
      const current = document.activeElement

      if (event.shiftKey && (current === first || !root.contains(current))) {
        event.preventDefault()
        last.focus()
      } else if (!event.shiftKey && current === last) {
        event.preventDefault()
        first.focus()
      }
    }

    document.addEventListener('keydown', onKeyDown, true)
    return () => {
      document.removeEventListener('keydown', onKeyDown, true)
      previous?.focus()
    }
  }, [ref, active, onEscape])
}

Two lines in there carry more weight than the rest.

visible() is recomputed on every Tab rather than captured once when the drawer opened. A cart changes while it is open: a line is removed, a quantity stepper disappears, a complement renders. A list of focusable nodes captured at open time will hand focus to a button that no longer exists.

previous?.focus() in the cleanup is the return trip. The buyer pressed a button to get here, and when the drawer closes the highlight goes back to that button. Skip it and focus falls back to the document body, which on a long product page means the next Tab starts from the top. On a grid, where the buyer adds several things in a row, that one omission makes the keyboard path unusable.

onEscape has to be stable. Wrap the close handler in useCallback or the effect tears down and rebuilds on every render, stealing focus back to the first element each time.

Why does the page jump when you lock the scroll?

Because you removed the scrollbar. On a desktop browser that paints a classic scrollbar, overflow: hidden on the root takes fifteen or so pixels of width away from the document, and everything centred on the page slides sideways at the exact moment the drawer appears.

The modern fix is one declaration on the root and it costs nothing:

html { scrollbar-gutter: stable; }

That reserves the gutter permanently, so hiding the scrollbar changes nothing. If you need older engines, or your design cannot afford a permanent gutter, compensate at lock time:

// lockScroll.ts — reference counted, no layout shift, and iOS stays put
let locks = 0
let restore: (() => void) | null = null

export function lockScroll(): void {
  if (locks++ > 0) return

  const doc = document.documentElement
  const gutter = window.innerWidth - doc.clientWidth // scrollbar width, 0 on touch
  const y = window.scrollY

  const previous = {
    overflow: doc.style.overflow,
    paddingRight: document.body.style.paddingRight,
    position: document.body.style.position,
    top: document.body.style.top,
    width: document.body.style.width,
  }

  doc.style.overflow = 'hidden'
  if (gutter > 0) document.body.style.paddingRight = gutter + 'px'

  // Safari on iOS ignores overflow:hidden on the root, so pin the body as well
  document.body.style.position = 'fixed'
  document.body.style.top = '-' + y + 'px'
  document.body.style.width = '100%'

  restore = () => {
    doc.style.overflow = previous.overflow
    document.body.style.paddingRight = previous.paddingRight
    document.body.style.position = previous.position
    document.body.style.top = previous.top
    document.body.style.width = previous.width
    window.scrollTo(0, y)
  }
}

export function unlockScroll(): void {
  if (--locks > 0) return
  restore?.()
  restore = null
}

The counter matters more than it looks. A cart drawer, a size guide and a consent banner can all be open in the same session, and each of them calls the lock. Without the count, the first one to close unlocks the page while the other two are still up, and the buyer scrolls the product page behind an overlay.

The position: fixed block exists for one browser and it has a price: on iOS the scroll position is destroyed, which is why window.scrollTo(0, y) sits in the restore. Test that path specifically. A drawer that returns the buyer to the top of a long product page every time they close it is worse than no drawer.

Where does the cart live between reloads?

In localStorage, holding SKUs and quantities. Not prices.

A stored price is a price that was correct at some point in the past. Store it and you have built a machine that shows yesterday's total on today's product, and the disagreement surfaces at the checkout, which is the most expensive place on the site for a number to change.

// cartStorage.ts
const KEY = 'cart.v1'
const MAX_AGE_MS = 1000 * 60 * 60 * 24 * 14

export type Line = { sku: string; qty: number }
type Stored = { v: 1; lines: Line[]; savedAt: number }

export function readCart(): Line[] {
  try {
    const raw = localStorage.getItem(KEY)
    if (!raw) return []
    const parsed = JSON.parse(raw) as Stored
    if (parsed.v !== 1) return []
    if (Date.now() - parsed.savedAt > MAX_AGE_MS) return []
    return parsed.lines.filter(
      (line) =>
        typeof line.sku === 'string' &&
        Number.isInteger(line.qty) &&
        line.qty > 0,
    )
  } catch {
    return [] // private mode, storage disabled, corrupted JSON
  }
}

export function writeCart(lines: Line[]): void {
  const payload: Stored = { v: 1, lines, savedAt: Date.now() }
  try {
    localStorage.setItem(KEY, JSON.stringify(payload))
  } catch {
    // quota or private mode: the cart still exists in memory for this session
  }
}

The v: 1 field is what lets you change the shape later without shipping a bug to every returning visitor. When the shape changes you bump it, and old carts are dropped instead of being read as garbage.

The expiry is a commercial decision rather than a technical one. A fourteen day old cart restored in full is a list of things the buyer decided against, presented as though they were still deciding.

Read it in an effect, never during render:

const [lines, setLines] = useState<Line[]>([])
useEffect(() => {
  setLines(readCart())
}, [])

Reading localStorage during render on a server-rendered page produces markup on the server that does not match the markup in the browser, and React throws a hydration error. The cost is one frame where the cart badge shows zero. Reserve the badge width so that frame does not move anything.

Prices come back from the server on hydration, keyed by SKU. That is also what makes the drawer safe to reopen after a price change, a currency switch, or a promotion that ended overnight.

What does a screen reader hear when a line arrives?

Nothing, unless you say it. The buyer pressed a button and a panel appeared somewhere. That is a visual event and only a visual event.

// Rendered once in the layout, outside the drawer, always in the DOM.
<p role="status" aria-live="polite" className="sr-only">
  {announcement}
</p>

Then set announcement to a whole sentence when something changes: Sencha, 100 g, added. Cart now 3 items, $48.00.

Two rules separate this working from this appearing to work.

The region has to exist before the text changes. A live region inserted into the DOM at the same moment as its content is unreliable across screen readers. Render the empty paragraph on first paint and only ever change its text.

It lives outside the drawer. A region inside a dialog that closes gets cut off mid-sentence. It also has to survive the case where the drawer never opens at all, which is the grid case at the end of this article.

For the same reason, keep the announcement out of any node you animate. aria-live on something that re-renders gets re-announced on every pass.

Why can't I click anything right after it opens?

Because the overlay is still in the tree, transparent, and still swallowing pointer events. This is the bug that gets reported as the site froze.

It has two halves and both need fixing.

<div
  className="cart-overlay"
  data-state={open ? 'open' : 'closed'}
  onClick={close}
  aria-hidden="true"
  style={{ pointerEvents: open ? 'auto' : 'none' }}
/>

The pointerEvents line is the closing half. While the overlay fades out over 200 ms it must not accept a click, or the buyer's first tap after closing lands on nothing.

The opening half is the drawer's own contents. If you keep the panel mounted when closed so it can animate, everything inside it is still focusable and still hit-testable off-screen. Mark it inert:

<aside inert={!open}>{/* React 19 passes this through */}</aside>

On React 18 the attribute is not in the type definitions and is not forwarded reliably, so spread it:

<aside {...(open ? {} : { inert: '' })}>{/* React 18 */}</aside>

inert removes a subtree from the tab order, from hit testing and from the accessibility tree in one attribute. Before it existed this took a loop over every focusable child setting tabindex="-1", and that loop is still what most hand-rolled drawers contain.

What breaks, and what the symptom looks like

Symptom reportedWhat is actually wrongThe fix
The site froze after I closed the cartThe overlay is still hit-testing during its exit transitionpointer-events: none when closed
Tab goes behind the panelNo focus trap, or a node list captured at open timeRecompute focusable nodes on each Tab
The page jerks sideways when it opensThe scrollbar was removed without compensationscrollbar-gutter: stable, or pad the body
It sends me back to the top of the pageposition: fixed on the body without restoring the offsetwindow.scrollTo(0, y) on unlock
My cart was empty this morningWhole product objects stored, then rejected on readStore SKU and quantity, hydrate prices from the server
Nothing tells me it workedNo live region, or one mounted together with its contentOne persistent role="status" outside the drawer
Escape does nothingListener bound to the panel, which never held focusBind on document in the capture phase

Should the drawer open by itself?

This is the only question in the component that is commercial rather than technical, and it is the one an agent gets wrong by default, because the default is always.

Open it automatically when the add was the buyer's final action. On a product page they chose a variant, set a quantity, pressed the button. The drawer is a receipt, and it puts checkout one tap away. It is also where the remaining amount to free shipping does its work, and that is a component of its own.

Do not open it automatically when the add was one of several. On a collection grid, a search results page, or a quick-add tile, the buyer is in a loop: scan, add, scan, add. A drawer that takes the screen on each pass forces a close before the loop can continue, and it destroys the scroll position they were holding in their head. Update the count in the header, announce the line, leave the grid alone.

The rule fits on one line. Open on a terminal add, stay shut on a repeated one. Same component, one prop:

addToCart(line, { revealCart: context === 'product-page' })

Three cases where it must never open on its own: on page load, on hover of the cart icon, and after a quantity change made from inside the drawer itself. The third one is the subtle one, because the code path is shared with the add, and the result is a panel that closes and reopens under the buyer's finger.

There is a third position worth knowing, which is not opening a drawer at all and navigating to a full cart page. That is the right answer when the cart carries real decisions: a shipping method, a gift message, or a bump with a consent requirement attached. A drawer is a confirmation surface. A cart page is a decision surface. Most stores need both and use the drawer for the first add and the page for the last look.

The button that starts all of this has its own set of rules, written up in add to cart button.

We build uxgen, an MCP server that hands Claude 168 selling components and writes them into the store as HTML you own. The behaviours above are free either way, and several of the basket pieces are in the commerce kit under MIT.

FAQ

What is a cart drawer component?

A cart drawer is a modal dialog that slides in from the side of the page and shows the current basket without leaving the product. Technically it is a dialog rather than a panel: focus moves into it and is trapped there, Escape closes it, the page behind it stops scrolling, and it carries role="dialog" with aria-modal="true" and a name supplied by its heading.

Should a cart drawer open automatically when something is added?

Only when the add was the buyer's final action, which in practice means an add from a product page. From a collection grid or a quick-add tile the buyer is adding several items in a row, and a drawer that takes the screen each time forces a close before they can continue and loses the scroll position they were holding. Update the header count and announce the new line instead.

How do I stop the page scrolling behind a cart drawer?

Set overflow: hidden on the root element and reserve the scrollbar width with scrollbar-gutter: stable so nothing shifts sideways. Safari on iOS ignores overflow: hidden on the root, so also pin the body with position: fixed and a negative top equal to the current scroll offset, then restore that offset with window.scrollTo when you unlock. Reference count the lock so a second modal cannot release it early.

Where should a cart drawer store its contents?

In localStorage, holding only SKUs and quantities with a version field and a timestamp, and read inside an effect rather than during render so server-rendered markup still matches. Never store prices: a stored price is a price that was correct in the past, and it will eventually disagree with the checkout, which is the most expensive place for a number to change.

uxgen is a service of UXGen AI, LLC — 131 Continental Dr, Suite 305, Newark, DE 19713, United States.

© 2026 UXGen AI, LLC. All rights reserved.