Mini cart drawer
The cart as a drawer from the store header. A sheet on phones, a panel on larger screens, with focus kept inside and Escape to close.
The cart, one click from every page
A mini cart drawer opens from the store header without leaving the page, so shopping goes on right after adding. It is a sheet on phones and a panel on larger screens, focus stays inside and Escape closes it.
Milestones sets free shipping and a free gift as stops on one bar, each stop pops when reached, the line says what is missing and the cheapest suggestion that closes the gap is marked. With suggestions widens the drawer on desktop with a column of suggestions, on tablet and phone they become a rail to swipe. Added to cart is the moment after adding, a check pops in, the new item stands large and three matching products wait below. With extras lets every line be saved for later, counts gift wrap into the total, counts down the note and shows the delivery date above the button.
Show the free shipping threshold in the drawer, that is where it lifts the basket. Keep suggestions to three that fit the cart. Name the delivery date before checkout, it answers the last question before paying.
Variants
Details
What Mini cart drawer brings along, read from the registry and the source.
- Category
- Cart and checkout
- Area
- E-Commerce
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/mini-cart-drawer.tsx
- Brings along
- utilskitbuttoniconproduct-imagecart
- npm packages
- None
- Languages
- English and German, 38 texts of its own
- Studio
- Adjust in code
- In collections
- Online shopShop checkout
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MiniCartDrawerProps interface from components/ui/mini-cart-drawer.tsx, with the comments from the code.
variantoptionalMiniCartDrawerVariantprogressrecommendationsaddedextras
itemsoptionalLineItem[]recommendationsoptionalShopProduct[]Suggestions with images, added straight into the cart
milestonesoptionalMiniCartMilestone[]progress: stops on the bar, smallest first. Without it the bar shows free shipping only.
freeShippingFromoptionalnumberFree shipping from this subtotal on
shippingCostoptionalnumberaddedIdoptionalstringadded: id of the line that was just added
giftWrapoptional{ price: number }extras: gift wrapping price, 0 for free
deliveryoptionalstringextras: the expected delivery, e.g. "Thu, 30 Oct"
noteMaxoptionalnumberextras: longest order note
headeroptional{ name: React.ReactNode; links?: { label: string; href: string }[] }The store header the cart button sits in
defaultOpenoptionalbooleanOpen on load, for previews
childrenoptionalReact.ReactNodeThe page behind the drawer
checkoutHrefoptionalstringcartHrefoptionalstringcurrencyoptionalstringISO code, default USD in English and EUR in German
onCheckoutoptional(items: LineItem[]) => void
Built-in texts
Mini cart drawer writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 38 texts
| Key | English | German |
|---|---|---|
| title | English Your cart | German Dein Warenkorb |
| open | English Open cart | German Warenkorb öffnen |
| close | English Close cart | German Warenkorb schließen |
| item | English item | German Artikel |
| items | English items | German Artikel |
| subtotal | English Subtotal | German Zwischensumme |
| taxNote | English Incl. VAT, shipping calculated at checkout | German inkl. MwSt., Versand wird an der Kasse berechnet |
| checkout | English Check out | German Zur Kasse |
| viewCart | English View cart | German Warenkorb ansehen |
| continue | English Keep shopping | German Weiter einkaufen |
| freeShipping | English Free shipping | German Gratisversand |
| leftFor | English {goal} with {amount} more | German Noch {amount}, dann {goal} |
| allDone | English Everything unlocked, enjoy | German Alles freigeschaltet, viel Freude |
| reached | English Unlocked | German Erreicht |
| closesGap | English Closes the gap | German Schließt die Lücke |
| add | English Add | German Hinzufügen |
| added | English Added | German Im Korb |
| addNamed | English Add {name} | German {name} hinzufügen |
| youMayLike | English Goes well with | German Passt dazu |
| previous | English Previous suggestions | German Vorherige Vorschläge |
| next | English Next suggestions | German Nächste Vorschläge |
| addedTitle | English Added to your cart | German Im Warenkorb |
| alsoInCart | English Also in your cart | German Außerdem im Warenkorb |
| removed | English removed. | German entfernt. |
| undo | English Undo | German Rückgängig |
| saveLater | English Save for later | German Für später merken |
| saved | English Saved for later | German Für später gemerkt |
| moveToCart | English Move to cart | German In den Warenkorb |
| giftWrap | English Wrap it as a gift | German Als Geschenk verpacken |
| giftWrapNote | English Recycled paper and a handwritten card | German Recyclingpapier und eine handgeschriebene Karte |
| free | English Free | German Gratis |
| note | English Note for the order | German Anmerkung zur Bestellung |
| notePlaceholder | English A message for us or the gift card | German Eine Nachricht an uns oder für die Geschenkkarte |
| noteLeft | English {n} characters left | German Noch {n} Zeichen |
| noteOver | English {n} characters too many, please shorten the note | German {n} Zeichen zu viel, bitte kürze die Anmerkung |
| deliveryBy | English Arrives by {date} | German Ankunft bis {date} |
| emptyTitle | English Your cart is empty | German Dein Warenkorb ist leer |
| emptyText | English Have a look around, the new season is in. | German Schau dich um, die neue Saison ist da. |
Accessibility
Read from the source of the component.
- Mini cart drawer handles the keys Escape and Tab itself.
- Sets aria-describedby, aria-expanded, aria-haspopup, aria-invalid, aria-label, aria-labelledby, aria-live, aria-modal, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
- Uses the roles dialog, progressbar and status.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 907 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts, type Locale } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { CART_TEXTS, CartLine, computeTotals } from '@/components/ui/cart';
import { FlyUp, ProductMedia, ShopMotion, Swap, TweenPrice, formatPrice, type LineItem, type ShopProduct } from '@/components/ui/product-image';
/*
* The cart as a drawer that opens from the cart button in the store header.
* On phones it is a sheet from the bottom with a handle, from 640 px a panel
* from the right. Focus stays inside while it is open, Escape and the scrim
* close it, focus goes back to the cart button.
*
* progress milestones on one bar (free shipping, a gift), the product that closes the gap is marked
* recommendations a rail of suggestions with pictures, a column inside the wider drawer on desktop
* added the confirmation after "Add to cart", the new item large, the rest folded
* extras save for later, gift wrapping, a note with a counter and the delivery date
*/
export type MiniCartDrawerVariant = 'progress' | 'recommendations' | 'added' | 'extras';
export interface MiniCartMilestone {
/** Subtotal from which the milestone is reached */
at: number;
label: string;
icon?: IconName;
}
export interface MiniCartDrawerProps {
variant?: MiniCartDrawerVariant;
items?: LineItem[];
/** Suggestions with images, added straight into the cart */
recommendations?: ShopProduct[];
/** progress: stops on the bar, smallest first. Without it the bar shows free shipping only. */
milestones?: MiniCartMilestone[];
/** Free shipping from this subtotal on */
freeShippingFrom?: number;
shippingCost?: number;
/** added: id of the line that was just added */
addedId?: string;
/** extras: gift wrapping price, 0 for free */
giftWrap?: { price: number };
/** extras: the expected delivery, e.g. "Thu, 30 Oct" */
delivery?: string;
/** extras: longest order note */
noteMax?: number;
/** The store header the cart button sits in */
header?: { name: React.ReactNode; links?: { label: string; href: string }[] };
/** Open on load, for previews */
defaultOpen?: boolean;
/** The page behind the drawer */
children?: React.ReactNode;
checkoutHref?: string;
cartHref?: string;
/** ISO code, default USD in English and EUR in German */
currency?: string;
onCheckout?: (items: LineItem[]) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
title: 'Your cart',
open: 'Open cart',
close: 'Close cart',
item: 'item',
items: 'items',
subtotal: 'Subtotal',
taxNote: 'Incl. VAT, shipping calculated at checkout',
checkout: 'Check out',
viewCart: 'View cart',
continue: 'Keep shopping',
freeShipping: 'Free shipping',
leftFor: '{goal} with {amount} more',
allDone: 'Everything unlocked, enjoy',
reached: 'Unlocked',
closesGap: 'Closes the gap',
add: 'Add',
added: 'Added',
addNamed: 'Add {name}',
youMayLike: 'Goes well with',
previous: 'Previous suggestions',
next: 'Next suggestions',
addedTitle: 'Added to your cart',
alsoInCart: 'Also in your cart',
removed: 'removed.',
undo: 'Undo',
saveLater: 'Save for later',
saved: 'Saved for later',
moveToCart: 'Move to cart',
giftWrap: 'Wrap it as a gift',
giftWrapNote: 'Recycled paper and a handwritten card',
free: 'Free',
note: 'Note for the order',
notePlaceholder: 'A message for us or the gift card',
noteLeft: '{n} characters left',
noteOver: '{n} characters too many, please shorten the note',
deliveryBy: 'Arrives by {date}',
emptyTitle: 'Your cart is empty',
emptyText: 'Have a look around, the new season is in.',
},
de: {
title: 'Dein Warenkorb',
open: 'Warenkorb öffnen',
close: 'Warenkorb schließen',
item: 'Artikel',
items: 'Artikel',
subtotal: 'Zwischensumme',
taxNote: 'inkl. MwSt., Versand wird an der Kasse berechnet',
checkout: 'Zur Kasse',
viewCart: 'Warenkorb ansehen',
continue: 'Weiter einkaufen',
freeShipping: 'Gratisversand',
leftFor: 'Noch {amount}, dann {goal}',
allDone: 'Alles freigeschaltet, viel Freude',
reached: 'Erreicht',
closesGap: 'Schließt die Lücke',
add: 'Hinzufügen',
added: 'Im Korb',
addNamed: '{name} hinzufügen',
youMayLike: 'Passt dazu',
previous: 'Vorherige Vorschläge',
next: 'Nächste Vorschläge',
addedTitle: 'Im Warenkorb',
alsoInCart: 'Außerdem im Warenkorb',
removed: 'entfernt.',
undo: 'Rückgängig',
saveLater: 'Für später merken',
saved: 'Für später gemerkt',
moveToCart: 'In den Warenkorb',
giftWrap: 'Als Geschenk verpacken',
giftWrapNote: 'Recyclingpapier und eine handgeschriebene Karte',
free: 'Gratis',
note: 'Anmerkung zur Bestellung',
notePlaceholder: 'Eine Nachricht an uns oder für die Geschenkkarte',
noteLeft: 'Noch {n} Zeichen',
noteOver: '{n} Zeichen zu viel, bitte kürze die Anmerkung',
deliveryBy: 'Ankunft bis {date}',
emptyTitle: 'Dein Warenkorb ist leer',
emptyText: 'Schau dich um, die neue Saison ist da.',
},
};
type Texts = (typeof TEXTS)['en'];
type T = Texts & (typeof CART_TEXTS)['en'];
const fill = (s: string, vars: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
const currencyOf = (locale: Locale, currency?: string) => currency ?? (locale === 'de' ? 'EUR' : 'USD');
const toLine = (p: ShopProduct): LineItem => ({ id: p.id, name: p.name, href: p.href, variant: p.subtitle, price: p.price, quantity: 1, image: p.image });
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
function useLines(initial: LineItem[]) {
const [items, setItems] = useState(initial);
const [removed, setRemoved] = useState<{ item: LineItem; index: number } | null>(null);
const first = useRef(new Set(initial.map((i) => i.id)));
return {
items,
removed,
isNew: (id: string) => !first.current.has(id),
setQty: (id: string, q: number) => setItems((xs) => xs.map((x) => (x.id === id ? { ...x, quantity: q } : x))),
remove: (id: string) =>
setItems((xs) => {
const index = xs.findIndex((x) => x.id === id);
if (index >= 0) setRemoved({ item: xs[index], index });
return xs.filter((x) => x.id !== id);
}),
undo: () => {
if (!removed) return;
setItems((xs) => [...xs.slice(0, removed.index), removed.item, ...xs.slice(removed.index)]);
setRemoved(null);
},
add: (line: LineItem) => {
setRemoved(null);
setItems((xs) => (xs.some((x) => x.id === line.id) ? xs.map((x) => (x.id === line.id ? { ...x, quantity: x.quantity + 1 } : x)) : [...xs, line]));
},
take: (id: string) => setItems((xs) => xs.filter((x) => x.id !== id)),
};
}
/** Open state, Escape, a focus trap and focus back to the trigger on close */
function useDrawer(defaultOpen?: boolean) {
const [open, setOpen] = useState(!!defaultOpen);
const panel = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const was = useRef(open);
useEffect(() => {
// The panel turns visible with its slide, focus moves in a moment later
if (open && !was.current) setTimeout(() => panel.current?.querySelector<HTMLElement>('[data-autofocus]')?.focus(), 60);
if (!open && was.current) trigger.current?.focus();
was.current = open;
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
return;
}
if (e.key !== 'Tab' || !panel.current) return;
const list = [...panel.current.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled]), textarea, select, [tabindex]:not([tabindex="-1"])')].filter((el) => el.offsetParent !== null);
if (!list.length) return;
const firstEl = list[0];
const lastEl = list[list.length - 1];
if (!panel.current.contains(document.activeElement)) {
e.preventDefault();
firstEl.focus();
} else if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
return { open, setOpen, panel, trigger };
}
/* ------------------------------------------------------------------ */
/* Frame: header, scrim and the drawer itself */
/* ------------------------------------------------------------------ */
function Header({ header, count, onOpen, open, trigger, t }: { header?: MiniCartDrawerProps['header']; count: number; onOpen: () => void; open: boolean; trigger: React.RefObject<HTMLButtonElement | null>; t: T }) {
const firstCount = useRef(count);
return (
<header className="sticky top-0 z-30 border-b border-border bg-background/85 backdrop-blur-xl">
<div className="kit-container flex h-16 items-center gap-8">
<span className="text-[17px] font-semibold tracking-[-0.02em] text-foreground">{header?.name}</span>
<nav className="flex gap-6 text-[14px] text-muted-foreground max-md:hidden">
{header?.links?.map((l) => (
<a key={l.label} href={l.href} className="hover:text-foreground">
{l.label}
</a>
))}
</nav>
<button
ref={trigger}
type="button"
onClick={onOpen}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={`${t.open}, ${count} ${count === 1 ? t.item : t.items}`}
className="relative ml-auto grid size-11 place-items-center rounded-lg text-foreground transition-colors hover:bg-accent"
>
<Icon name="bag" size={20} />
{count > 0 && (
<span
key={count}
className={cn(
'absolute -right-0.5 -top-0.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-primary px-1 text-[10px] font-semibold tabular-nums text-primary-foreground ring-2 ring-background',
count !== firstCount.current && 'animate-[kitshop-pop_0.45s_var(--ease-kit)]',
)}
>
{count}
</span>
)}
</button>
</div>
</header>
);
}
function Drawer({
open,
onClose,
panel,
title,
titleExtra,
wide,
children,
footer,
t,
}: {
open: boolean;
onClose: () => void;
panel: React.RefObject<HTMLDivElement | null>;
title: React.ReactNode;
titleExtra?: React.ReactNode;
wide?: boolean;
children: React.ReactNode;
footer: React.ReactNode;
t: T;
}) {
const titleId = useId();
return (
<>
<div onClick={onClose} className={cn('fixed inset-0 z-40 bg-scrim backdrop-blur-[2px] transition-opacity duration-300 motion-reduce:transition-none', open ? 'opacity-100' : 'pointer-events-none opacity-0')} aria-hidden />
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
inert={!open}
className={cn(
'fixed z-50 flex flex-col bg-background shadow-[var(--shadow-lg)] transition-[transform,visibility] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
// Phone: a sheet from the bottom. From 640 px: a panel from the right.
'inset-x-0 bottom-0 max-h-[90dvh] rounded-t-2xl border-t border-border',
'sm:inset-x-auto sm:inset-y-0 sm:right-0 sm:max-h-none sm:w-[27rem] sm:rounded-none sm:border-l sm:border-t-0',
wide && 'lg:w-[46rem]',
open ? 'visible translate-y-0 sm:translate-x-0' : 'invisible translate-y-full sm:translate-x-full sm:translate-y-0',
)}
>
<div className="mx-auto mt-2.5 h-1 w-10 shrink-0 rounded-full bg-border-strong sm:hidden" aria-hidden />
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-2.5 sm:px-6 sm:py-3">
<h2 id={titleId} className="flex min-w-0 items-center gap-2 text-[17px] font-semibold text-foreground">
{title}
{titleExtra}
</h2>
<button type="button" data-autofocus onClick={onClose} aria-label={t.close} className="-mr-2 grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
<Icon name="x" size={19} />
</button>
</div>
{children}
<div className="shrink-0 border-t border-border bg-background px-5 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:px-6 sm:pb-6 sm:pt-5">{footer}</div>
</div>
</>
);
}
function Count({ n, t }: { n: number; t: T }) {
return (
<span className="font-normal tabular-nums text-muted-foreground">
({n}
<span className="sr-only"> {n === 1 ? t.item : t.items}</span>)
</span>
);
}
function Footer({ subtotal, t, currency, checkoutHref, cartHref, onCheckout, onClose, children, compact, label }: { label?: string; subtotal: number; t: T; currency: string; checkoutHref?: string; cartHref?: string; onCheckout?: () => void; onClose: () => void; children?: React.ReactNode; compact?: boolean }) {
return (
<>
{children}
<div className="flex items-baseline justify-between gap-4">
<span className="text-[15px] font-semibold text-foreground">{label ?? t.subtotal}</span>
<TweenPrice value={subtotal} currency={currency} className="text-[19px] font-semibold tracking-[-0.02em] text-foreground" />
</div>
<p className="mt-0.5 text-[12px] text-muted-foreground">{t.taxNote}</p>
<div className={cn('mt-4 grid gap-2', compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-[auto_minmax(0,1fr)]')}>
{!compact && (
<Button href={cartHref ?? '#'} variant="outline" size="lg" className="max-sm:hidden">
{t.viewCart}
</Button>
)}
<Button href={checkoutHref ?? '#'} size="lg" icon="arrow-right" onClick={onCheckout} className="w-full">
{t.checkout}
</Button>
</div>
<button type="button" onClick={onClose} className="mt-1 flex h-11 w-full items-center justify-center text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground">
{t.continue}
</button>
</>
);
}
function Removed({ name, onUndo, t }: { name: string; onUndo: () => void; t: T }) {
return (
<div role="status" className="mt-4 flex animate-[kitshop-in_0.4s_var(--ease-kit)] items-center justify-between gap-3 rounded-lg bg-surface-2 px-4 py-2.5 text-[13px] text-muted-foreground">
<span className="min-w-0 truncate">
<span className="font-medium text-foreground">{name}</span> {t.removed}
</span>
<button type="button" onClick={onUndo} className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-foreground hover:bg-accent">
<Icon name="undo" size={14} />
{t.undo}
</button>
</div>
);
}
function Empty({ t }: { t: T }) {
return (
<div className="grid flex-1 place-items-center px-6 py-14 text-center">
<div>
<span className="mx-auto grid size-14 place-items-center rounded-2xl border border-border bg-card text-muted-foreground shadow-[var(--highlight),var(--shadow-sm)]">
<Icon name="bag" size={24} />
</span>
<p className="mt-4 text-[15px] font-semibold text-foreground">{t.emptyTitle}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t.emptyText}</p>
</div>
</div>
);
}
/** Lines of the cart, the ones that arrive later slide in */
function Lines({ lines, t, currency, open, extra }: { lines: ReturnType<typeof useLines>; t: T; currency: string; open: boolean; extra?: (item: LineItem) => React.ReactNode }) {
return (
<>
{lines.removed && <Removed name={lines.removed.item.name} onUndo={lines.undo} t={t} />}
<ul className="divide-y divide-border">
{lines.items.map((i, n) => (
<li
key={i.id}
className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'translate-x-0 opacity-100' : 'translate-x-6 opacity-0', lines.isNew(i.id) && 'animate-[kitshop-in_0.5s_var(--ease-kit)_both]')}
style={{ transitionDelay: open ? `${120 + Math.min(n, 5) * 60}ms` : '0ms' }}
>
<CartLine item={i} size="sm" currency={currency} t={t} onQuantity={(q) => lines.setQty(i.id, q)} onRemove={() => lines.remove(i.id)} />
{extra?.(i)}
</li>
))}
</ul>
</>
);
}
/** A small product with an add button, for rails and lists */
function RecCard({ p, onAdd, inCart, t, currency, badge, layout = 'card' }: { p: ShopProduct; onAdd: () => void; inCart: boolean; t: T; currency: string; badge?: string; layout?: 'card' | 'row' | 'icon' }) {
const locale = useLocale();
const [fly, setFly] = useState(0);
const button = (
<span className="relative">
<Button
size="sm"
variant={inCart ? 'secondary' : 'outline'}
disabled={inCart}
onClick={() => { onAdd(); setFly((f) => f + 1); }}
aria-label={inCart ? `${t.added}, ${p.name}` : fill(t.addNamed, { name: p.name })}
className={cn('overflow-hidden', layout === 'card' && 'h-10 w-full max-sm:h-11', layout === 'icon' && 'size-10 rounded-full px-0')}
>
<Swap value={inCart} className="gap-1.5">
<Icon name={inCart ? 'check' : 'plus'} size={15} />
{layout !== 'icon' && (inCart ? t.added : t.add)}
</Swap>
</Button>
<FlyUp count={fly} className="-top-5" />
</span>
);
if (layout === 'row' || layout === 'icon')
return (
<div className="flex items-center gap-3">
<ProductMedia className="size-14 shrink-0 rounded-lg">{p.image}</ProductMedia>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">{p.name}</p>
<p className="text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, p.price, currency)}</p>
{badge && <p className="mt-0.5 truncate text-[11px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{badge}</p>}
</div>
{button}
</div>
);
return (
<div className="group/rec flex h-full flex-col rounded-xl border border-border bg-card p-2 shadow-[var(--shadow-sm)]">
<ProductMedia className="aspect-square rounded-lg [&>*]:transition-transform [&>*]:duration-700 [&>*]:ease-[var(--ease-kit)] group-hover/rec:[&>*]:scale-[1.05] motion-reduce:[&>*]:transition-none">{p.image}</ProductMedia>
<p className="mt-2 line-clamp-1 px-1 text-[13px] font-medium text-foreground">{p.name}</p>
<p className="mb-2 px-1 text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, p.price, currency)}</p>
<div className="mt-auto">{button}</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* progress */
/* ------------------------------------------------------------------ */
function Milestones({ subtotal, stops, currency, t, open }: { subtotal: number; stops: MiniCartMilestone[]; currency: string; t: T; open: boolean }) {
const locale = useLocale();
const max = stops[stops.length - 1].at;
const pct = Math.min(100, round((subtotal / max) * 100, 1));
const next = stops.find((s) => subtotal < s.at);
// The bar grows when the drawer opens, then glides with every change
const shown = open ? pct : 0;
return (
<div>
<p className="flex min-h-5 items-center gap-2 text-[13px] text-foreground" aria-live="polite">
<Icon name={next ? (next.icon ?? 'truck') : 'check-circle'} size={16} className={next ? 'shrink-0 text-muted-foreground' : 'shrink-0 text-success'} />
<span>{next ? fill(t.leftFor, { amount: formatPrice(locale, round(next.at - subtotal), currency), goal: next.label }) : t.allDone}</span>
</p>
<div className="relative mt-3 pb-9">
<div className="h-1.5 overflow-hidden rounded-full bg-track" role="progressbar" aria-label={t.subtotal} aria-valuemin={0} aria-valuemax={max} aria-valuenow={Math.min(subtotal, max)} aria-valuetext={formatPrice(locale, subtotal, currency)}>
<div className="h-full origin-left rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `scaleX(${shown / 100})` }} />
</div>
{stops.map((s, i) => {
const at = round((s.at / max) * 100, 1);
const done = subtotal >= s.at;
const last = i === stops.length - 1;
return (
<div key={s.label} className="absolute top-[3px] flex flex-col items-center" style={last ? { right: 0, transform: 'translateY(-50%)' } : { left: `${at}%`, transform: 'translate(-50%,-50%)' }}>
<span
key={String(done)}
className={cn(
'grid size-6 place-items-center rounded-full border-2 transition-colors duration-500',
done ? 'animate-[kitshop-pop_0.45s_var(--ease-kit)] border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background text-muted-foreground',
)}
>
<Icon name={done ? 'check' : (s.icon ?? 'gift')} size={12} strokeWidth={done ? 2.4 : 1.8} />
</span>
<span className={cn('absolute top-7 whitespace-nowrap text-[11px] font-medium', last ? 'right-0' : 'left-1/2 -translate-x-1/2', done ? 'text-foreground' : 'text-muted-foreground')}>
{s.label}
<span className="sr-only">{done ? `, ${t.reached}` : ''}</span>
</span>
</div>
);
})}
</div>
</div>
);
}
function Progress(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, { freeShippingFrom: props.freeShippingFrom, shippingCost: props.shippingCost });
const stops = props.milestones?.length ? props.milestones : props.freeShippingFrom ? [{ at: props.freeShippingFrom, label: t.freeShipping, icon: 'truck' as IconName }] : [];
const next = stops.find((s) => totals.subtotal < s.at);
const gap = next ? next.at - totals.subtotal : 0;
const recs = (props.recommendations ?? []).filter((r) => !lines.items.some((i) => i.id === r.id));
// The cheapest suggestion that reaches the next milestone gets a mark
const closer = next ? recs.filter((r) => r.price >= gap).sort((a, b) => a.price - b.price)[0] : undefined;
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={<Footer subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
{stops.length > 0 && (
<div className="shrink-0 border-b border-border bg-surface px-5 pt-4 sm:px-6">
<Milestones subtotal={totals.subtotal} stops={stops} currency={currency} t={t} open={d.open} />
</div>
)}
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 sm:px-6">
{lines.items.length ? <Lines lines={lines} t={t} currency={currency} open={d.open} /> : <Empty t={t} />}
{recs.length > 0 && (
<section className="mb-5 mt-1 rounded-xl bg-surface-2 p-4" aria-label={t.youMayLike}>
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<ul className="mt-3 space-y-3">
{recs.slice(0, 3).map((r) => (
<li key={r.id}>
<RecCard p={r} layout="row" inCart={false} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} badge={closer?.id === r.id ? t.closesGap : undefined} />
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* recommendations */
/* ------------------------------------------------------------------ */
function Rail({ recs, isIn, onAdd, t, currency }: { recs: ShopProduct[]; isIn: (id: string) => boolean; onAdd: (p: ShopProduct) => void; t: T; currency: string }) {
const row = useRef<HTMLUListElement>(null);
const [edge, setEdge] = useState<'start' | 'mid' | 'end' | 'none'>('start');
const update = () => {
const el = row.current;
if (!el) return;
if (el.scrollWidth <= el.clientWidth + 2) return setEdge('none');
setEdge(el.scrollLeft < 4 ? 'start' : el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 ? 'end' : 'mid');
};
useEffect(update, [recs.length]);
const step = (dir: 1 | -1) => row.current?.scrollBy({ left: dir * row.current.clientWidth * 0.8, behavior: 'smooth' });
return (
<section aria-label={t.youMayLike} className="lg:hidden">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<div className="flex gap-1 max-sm:hidden">
<button type="button" onClick={() => step(-1)} disabled={edge === 'start' || edge === 'none'} aria-label={t.previous} className="grid size-9 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35">
<Icon name="chevron-left" size={16} />
</button>
<button type="button" onClick={() => step(1)} disabled={edge === 'end' || edge === 'none'} aria-label={t.next} className="grid size-9 place-items-center rounded-full border border-border text-foreground transition-colors hover:bg-accent disabled:opacity-35">
<Icon name="chevron-right" size={16} />
</button>
</div>
</div>
<ul
ref={row}
onScroll={update}
tabIndex={0}
aria-label={t.youMayLike}
className={cn(
'kit-no-scrollbar -mx-5 flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-px-5 px-5 pb-1 sm:-mx-6 sm:scroll-px-6 sm:px-6',
edge === 'start' && 'kit-fade-end',
edge === 'mid' && 'kit-fade-x',
edge === 'end' && 'kit-fade-start',
)}
>
{recs.map((r) => (
<li key={r.id} className="w-[9.5rem] shrink-0 snap-start">
<RecCard p={r} inCart={isIn(r.id)} onAdd={() => onAdd(r)} t={t} currency={currency} />
</li>
))}
</ul>
</section>
);
}
function Recommendations(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, {});
const recs = props.recommendations ?? [];
const isIn = (id: string) => lines.items.some((i) => i.id === id);
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
wide
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={<Footer subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
<div className="flex min-h-0 flex-1">
{/* Desktop: suggestions as a column on the left of the wide drawer */}
<aside aria-label={t.youMayLike} className="kit-scrollbar relative w-[16rem] shrink-0 overflow-y-auto border-r border-border bg-surface p-5 max-lg:hidden">
<h3 className="text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
<ul className="mt-4 grid grid-cols-1 gap-3">
{recs.map((r, n) => (
<li key={r.id} className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', d.open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0')} style={{ transitionDelay: d.open ? `${260 + n * 70}ms` : '0ms' }}>
<RecCard p={r} layout="icon" inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
</aside>
<div className="kit-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain px-5 sm:px-6">
{lines.items.length ? <Lines lines={lines} t={t} currency={currency} open={d.open} /> : <Empty t={t} />}
{recs.length > 0 && (
<div className="mb-5 mt-2">
<Rail recs={recs} isIn={isIn} onAdd={(p) => lines.add(toLine(p))} t={t} currency={currency} />
</div>
)}
</div>
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* added */
/* ------------------------------------------------------------------ */
function Added(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const totals = computeTotals(lines.items, { freeShippingFrom: props.freeShippingFrom, shippingCost: props.shippingCost });
const fresh = lines.items.find((i) => i.id === props.addedId) ?? lines.items[lines.items.length - 1];
const rest = lines.items.filter((i) => i.id !== fresh?.id);
const recs = (props.recommendations ?? []).slice(0, 3);
const isIn = (id: string) => lines.items.some((i) => i.id === id);
const left = props.freeShippingFrom !== undefined ? round(Math.max(0, props.freeShippingFrom - totals.subtotal)) : 0;
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={
<>
<span key={String(d.open)} className="grid size-6 animate-[kitshop-pop_0.5s_var(--ease-kit)_0.15s_both] place-items-center rounded-full bg-success text-[var(--background)]">
<Icon name="check" size={14} strokeWidth={2.6} />
</span>
{t.addedTitle}
</>
}
t={t}
footer={<Footer compact subtotal={totals.subtotal} t={t} currency={currency} checkoutHref={props.checkoutHref} cartHref={props.cartHref} onCheckout={() => props.onCheckout?.(lines.items)} onClose={() => d.setOpen(false)} />}
>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5 sm:px-6">
{fresh && (
<div className="mt-5 flex gap-4 rounded-2xl border border-border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)]">
<ProductMedia className="size-24 shrink-0 rounded-xl sm:size-28">{fresh.image}</ProductMedia>
<div className="flex min-w-0 flex-1 flex-col py-1">
<p className="text-[15px] font-semibold text-foreground">{fresh.name}</p>
{fresh.variant && <p className="mt-0.5 text-[13px] text-muted-foreground">{fresh.variant}</p>}
<p className="mt-auto pt-2 text-[15px] font-semibold tabular-nums text-foreground">{formatPrice(locale, fresh.price * fresh.quantity, currency)}</p>
</div>
</div>
)}
{props.freeShippingFrom !== undefined && (
<p className="mt-4 flex items-center gap-2 rounded-lg bg-surface-2 px-3.5 py-2.5 text-[13px] text-foreground">
<Icon name={left ? 'truck' : 'check-circle'} size={16} className={left ? 'shrink-0 text-muted-foreground' : 'shrink-0 text-success'} />
{left ? fill(t.leftFor, { amount: formatPrice(locale, left, currency), goal: t.freeShipping }) : t.allDone}
</p>
)}
{rest.length > 0 && (
<details className="group/rest mt-4 rounded-xl border border-border">
<summary className="flex min-h-12 cursor-pointer list-none items-center gap-3 px-4 text-[13px] font-medium text-foreground [&::-webkit-details-marker]:hidden">
<span className="flex -space-x-2">
{rest.slice(0, 3).map((i) => (
<ProductMedia key={i.id} className="size-7 rounded-full ring-2 ring-background">
{i.image}
</ProductMedia>
))}
</span>
{t.alsoInCart} ({rest.reduce((a, i) => a + i.quantity, 0)})
<Icon name="chevron-down" size={15} className="ml-auto text-muted-foreground transition-transform group-open/rest:rotate-180 motion-reduce:transition-none" />
</summary>
<ul className="divide-y divide-border border-t border-border px-4">
{rest.map((i) => (
<li key={i.id}>
<CartLine item={i} size="sm" currency={currency} t={t} onQuantity={(q) => lines.setQty(i.id, q)} onRemove={() => lines.remove(i.id)} />
</li>
))}
</ul>
</details>
)}
{lines.removed && <Removed name={lines.removed.item.name} onUndo={lines.undo} t={t} />}
{recs.length > 0 && (
<section className="mt-6" aria-label={t.youMayLike}>
<h3 className="mb-3 text-[13px] font-medium text-foreground">{t.youMayLike}</h3>
{/* Phone: rows, from 640 px: three cards */}
<ul className="space-y-3 sm:hidden">
{recs.map((r) => (
<li key={r.id}>
<RecCard p={r} layout="row" inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
<ul className="grid grid-cols-3 gap-3 max-sm:hidden">
{recs.map((r, n) => (
<li key={r.id} className={cn('transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', d.open ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0')} style={{ transitionDelay: d.open ? `${300 + n * 80}ms` : '0ms' }}>
<RecCard p={r} inCart={isIn(r.id)} onAdd={() => lines.add(toLine(r))} t={t} currency={currency} />
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/* ------------------------------------------------------------------ */
/* extras */
/* ------------------------------------------------------------------ */
function Extras(props: MiniCartDrawerProps & { t: T; currency: string }) {
const { t, currency } = props;
const locale = useLocale();
const d = useDrawer(props.defaultOpen);
const lines = useLines(props.items ?? []);
const [saved, setSaved] = useState<LineItem[]>([]);
const [wrap, setWrap] = useState(false);
const [note, setNote] = useState('');
const max = props.noteMax ?? 140;
const noteId = useId();
const wrapPrice = props.giftWrap?.price ?? 0;
const totals = computeTotals(lines.items, { extras: wrap ? wrapPrice : 0 });
const over = note.length - max;
const save = (item: LineItem) => {
lines.take(item.id);
setSaved((s) => [item, ...s.filter((x) => x.id !== item.id)]);
};
const back = (item: LineItem) => {
setSaved((s) => s.filter((x) => x.id !== item.id));
lines.add(item);
};
return (
<div className={cn('relative min-h-[100svh]', props.className)}>
<ShopMotion />
<Header header={props.header} count={totals.count} onOpen={() => d.setOpen(true)} open={d.open} trigger={d.trigger} t={t} />
<div aria-hidden={d.open || undefined}>{props.children}</div>
<Drawer
open={d.open}
onClose={() => d.setOpen(false)}
panel={d.panel}
title={t.title}
titleExtra={<Count n={totals.count} t={t} />}
t={t}
footer={
<Footer
label={t.total}
subtotal={totals.total}
t={t}
currency={currency}
checkoutHref={props.checkoutHref}
cartHref={props.cartHref}
onCheckout={() => props.onCheckout?.(lines.items)}
onClose={() => d.setOpen(false)}
>
{props.delivery && (
<p className="mb-3 flex items-center gap-2 text-[13px] text-foreground">
<Icon name="calendar-check" size={16} className="shrink-0 text-success" />
{fill(t.deliveryBy, { date: props.delivery })}
</p>
)}
</Footer>
}
>
<div className="kit-scrollbar relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-5 sm:px-6">
{lines.items.length ? (
<Lines
lines={lines}
t={t}
currency={currency}
open={d.open}
extra={(i) => (
<button type="button" onClick={() => save(i)} className="-mt-3.5 mb-2 ml-20 inline-flex h-9 items-center max-sm:h-11 gap-1.5 rounded-md px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
<Icon name="bookmark" size={14} />
{t.saveLater}
</button>
)}
/>
) : (
<Empty t={t} />
)}
{props.giftWrap && (
<label className={cn('mt-2 flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--ring)] has-[:focus-visible]:outline-solid', wrap ? 'border-foreground bg-surface' : 'border-border-strong hover:border-foreground/40')}>
<input type="checkbox" checked={wrap} onChange={(e) => setWrap(e.target.checked)} className="sr-only" />
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-foreground">
<Icon name="gift" size={18} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className="text-[14px] font-medium text-foreground">{t.giftWrap}</span>
<span className="shrink-0 text-[13px] tabular-nums text-muted-foreground">{wrapPrice ? `+ ${formatPrice(locale, wrapPrice, currency)}` : t.free}</span>
</span>
<span className="mt-0.5 block text-[12px] text-muted-foreground">{t.giftWrapNote}</span>
</span>
<span aria-hidden className={cn('mt-0.5 grid size-5 shrink-0 place-items-center rounded-[6px] border transition-colors', wrap ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong')}>
{wrap && <Icon name="check" size={13} strokeWidth={2.4} className="animate-[kitshop-pop_0.35s_var(--ease-kit)]" />}
</span>
</label>
)}
<div className="mt-4">
<label htmlFor={noteId} className="mb-1.5 block text-[13px] font-medium text-foreground">
{t.note}
</label>
<textarea
id={noteId}
rows={2}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t.notePlaceholder}
aria-invalid={over > 0}
aria-describedby={`${noteId}-count`}
className="block w-full resize-none rounded-xl border border-input bg-background px-3.5 py-2.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none placeholder:text-subtle-foreground focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-[color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive sm:text-[14px]"
/>
<p id={`${noteId}-count`} aria-live="polite" className={cn('mt-1.5 text-right text-[12px] tabular-nums', over > 0 ? 'text-destructive' : 'text-muted-foreground')}>
{over > 0 ? fill(t.noteOver, { n: over }) : fill(t.noteLeft, { n: max - note.length })}
</p>
</div>
{saved.length > 0 && (
<section className="mt-5 border-t border-border pt-4" aria-label={t.saved}>
<h3 className="text-[13px] font-medium text-foreground">
{t.saved} <span className="font-normal text-muted-foreground">({saved.length})</span>
</h3>
<ul className="mt-3 space-y-3">
{saved.map((i) => (
<li key={i.id} className="flex animate-[kitshop-in_0.45s_var(--ease-kit)] items-center gap-3">
<ProductMedia className="size-12 shrink-0 rounded-lg opacity-80">{i.image}</ProductMedia>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">{i.name}</p>
<p className="text-[12px] tabular-nums text-muted-foreground">{formatPrice(locale, i.price, currency)}</p>
</div>
<Button size="sm" variant="outline" onClick={() => back(i)}>
{t.moveToCart}
</Button>
</li>
))}
</ul>
</section>
)}
</div>
</Drawer>
</div>
);
}
/**
* A cart drawer from the store header: a sheet from the bottom on phones,
* a panel from the right from 640 px. Four layouts for the moments a shop
* needs it, with free shipping milestones, suggestions, a confirmation
* after adding and extras like gift wrapping.
*/
export function MiniCartDrawer(props: MiniCartDrawerProps) {
const locale = useLocale();
const t = { ...useTexts(CART_TEXTS), ...useTexts(TEXTS, props.labels) } as T;
const currency = currencyOf(locale, props.currency);
const { variant = 'progress' } = props;
if (variant === 'recommendations') return <Recommendations {...props} t={t} currency={currency} />;
if (variant === 'added') return <Added {...props} t={t} currency={currency} />;
if (variant === 'extras') return <Extras {...props} t={t} currency={currency} />;
return <Progress {...props} t={t} currency={currency} />;
}Frequent questions
How do I install Mini cart drawer?
With aiw add @ai-website/mini-cart-drawer. The command puts components/ui/mini-cart-drawer.tsx into your project, the code is yours from then on. It brings along button, icon, product-image and cart. It needs no further npm packages.
Which variants does Mini cart drawer have?
4 variants, Milestones, With suggestions, Added to cart and With extras. You pick one with the variant prop, for example variant="recommendations".
Can I translate the texts of Mini cart drawer?
Yes. Mini cart drawer carries 38 texts of its own in English and German, such as “Your cart”, “Open cart” and “Close cart”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Mini cart drawer?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Mini cart drawer with aiw_components and install it with aiw_add @ai-website/mini-cart-drawer. Name the variant in your prompt, for example variant="progress", and the assistant writes your content straight into the props.