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 uxgen
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.

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 reported | What is actually wrong | The fix |
|---|---|---|
| The site froze after I closed the cart | The overlay is still hit-testing during its exit transition | pointer-events: none when closed |
| Tab goes behind the panel | No focus trap, or a node list captured at open time | Recompute focusable nodes on each Tab |
| The page jerks sideways when it opens | The scrollbar was removed without compensation | scrollbar-gutter: stable, or pad the body |
| It sends me back to the top of the page | position: fixed on the body without restoring the offset | window.scrollTo(0, y) on unlock |
| My cart was empty this morning | Whole product objects stored, then rejected on read | Store SKU and quantity, hydrate prices from the server |
| Nothing tells me it worked | No live region, or one mounted together with its content | One persistent role="status" outside the drawer |
| Escape does nothing | Listener bound to the panel, which never held focus | Bind 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.