shadcn.io is not affiliated with official shadcn/ui
Mini Cart Checkout Block for Radix UI
A tea shop's cart popover shown open and run from the keyboard: arrows move between lines, plus and minus change the quantity, Delete removes, and the subtotal is worked out in cents.
Installation
Overview
Let people tidy a cart without leaving the page or reaching for the mouse, with this mini cart for React with Radix UI. It hangs from the Tea page of Pennyroyal Tea, a loose-leaf tea shop: the header holds an outline Cart Button with a count Badge, and the shelf, six drawn tins, pouches and pots, sits in a hairline frame with a plus mark on each corner. The cart is a small non-modal Popover with four lines, each with a drawn thumbnail, its name and size, a quantity stepper and the line total. One line is current: Up and Down move to the next or the previous line, Home and End to the ends, + and - change its quantity from 1 to 9, and Delete takes it out, with a legend of Kbd keys above the list and a button for every key for a pointer. Each change is said in a polite line, the subtotal is worked out in whole cents, and removing the last line leaves an Empty cart at the same height. Checkout is the only Shine Button. For preview the cart is drawn open in place when the page loads; the header button opens the real Popover, with the same lines and the same state. For shops, menus, ticket sellers and any header cart that should work without a mouse.
FAQ
Why is the cart already open, and what do I keep?
So you can see it without a click. On load the cart is a copy drawn in place at the end of the shelf: a native non-modal dialog element with no scrim, so nothing is trapped or focused, and the page around the preview keeps working. The Cart button opens the real cart as a non-modal Popover that hangs from the button (Escape closes it and focus goes back to the button), and the copy stays closed until a reload. Both show the same Cart component and share the block's state, so a quantity changed in the copy is changed in the real one. In your app keep the Popover and drop the copy: the "in-place" state, the dialog and the Escape effect.
How do the keys work, and why are the small buttons not in the tab order?
The lines are one list with one tab stop, the current line's name: Tab enters it and leaves it, and the keys act on the current line. Up and Down move, Home and End go to the ends, + (or =) and - change the quantity and Delete or Backspace removes the line. Pressing a line's name makes it current. The Fewer, More and Remove buttons do the same for a pointer or a screen reader's virtual cursor and are left out of the tab order so the list stays one stop; a quantity at 1 or 9 answers in the status line instead of clamping silently. If you would rather have every button tabbable, drop the tabIndex={-1} on them.
What happens when the last line is removed?
The list is replaced, in the same height, by an empty state: an icon, "Your cart is empty", one sentence and an outline button that puts the sample cart back. An unseen copy of the first cart sits in the same grid cell and holds the height, so Checkout and the popover don't move. Focus lands on the first line of the restored cart, the status line says the cart is empty, and Checkout stays in place but says to add an item first.
How is the subtotal worked out?
Every price is an integer number of cents, a line is its unit price times its quantity, the subtotal is the sum, and usd is the only place cents become text. Shipping and tax are not shown: they depend on an address, and Checkout says they come at the next step.
Is it accessible from the keyboard?
Yes. The popover is named Your cart, focus moves to the current line when it opens, and Escape closes it and returns focus to the Cart button. The trigger is the button wrapped in PopoverTrigger with asChild, and the popover is controlled with open and onOpenChange and is not modal. The legend is read to the current line as its description, and one polite status message reports what each key did. The copy shown on load moves no focus; Escape inside it closes it and puts focus on the Cart button.
What stays local in the demo, and how do I wire it up?
Everything. The cart lives in the block's state, the keys and buttons change only that state, and Checkout only says that checkout opens at the next step. In your app keep the lines in your cart store, send each quantity change or removal to your API (and show a failure in the same status line), and make Checkout a link to your next step: wrap an a element in the Shine Button with asChild.