Skip to content
UI Kit
Accent color

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.

4 variantsOne of 4 components in Cart and checkout

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

MilestonesNew in 1.2

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 suggestionsNew in 1.2

On desktop the drawer widens and the suggestions stand as a column on its left, on tablet and phone they become a rail with pictures to swipe. Adding moves the product into the cart.

Added to cartNew in 1.2

The moment after "Add to cart". A check pops in, the new item stands large, the rest of the cart is folded away and three matching products wait below.

With extrasNew in 1.2

Every line can be saved for later and moved back. Gift wrapping counts into the total, the note counts down its characters and the delivery date sits above the button.

Details

What Mini cart drawer brings along, read from the registry and the source.

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

Props

The MiniCartDrawerProps interface from components/ui/mini-cart-drawer.tsx, with the comments from the code.

  • variantoptional
    MiniCartDrawerVariant

    progressrecommendationsaddedextras

  • itemsoptional
    LineItem[]
  • recommendationsoptional
    ShopProduct[]

    Suggestions with images, added straight into the cart

  • milestonesoptional
    MiniCartMilestone[]

    progress: stops on the bar, smallest first. Without it the bar shows free shipping only.

  • freeShippingFromoptional
    number

    Free shipping from this subtotal on

  • shippingCostoptional
    number
  • addedIdoptional
    string

    added: id of the line that was just added

  • giftWrapoptional
    { price: number }

    extras: gift wrapping price, 0 for free

  • deliveryoptional
    string

    extras: the expected delivery, e.g. "Thu, 30 Oct"

  • noteMaxoptional
    number

    extras: longest order note

  • headeroptional
    { name: React.ReactNode; links?: { label: string; href: string }[] }

    The store header the cart button sits in

  • defaultOpenoptional
    boolean

    Open on load, for previews

  • childrenoptional
    React.ReactNode

    The page behind the drawer

  • checkoutHrefoptional
    string
  • cartHrefoptional
    string
  • currencyoptional
    string

    ISO 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
titleEnglish Your cartGerman Dein Warenkorb
openEnglish Open cartGerman Warenkorb öffnen
closeEnglish Close cartGerman Warenkorb schließen
itemEnglish itemGerman Artikel
itemsEnglish itemsGerman Artikel
subtotalEnglish SubtotalGerman Zwischensumme
taxNoteEnglish Incl. VAT, shipping calculated at checkoutGerman inkl. MwSt., Versand wird an der Kasse berechnet
checkoutEnglish Check outGerman Zur Kasse
viewCartEnglish View cartGerman Warenkorb ansehen
continueEnglish Keep shoppingGerman Weiter einkaufen
freeShippingEnglish Free shippingGerman Gratisversand
leftForEnglish {goal} with {amount} moreGerman Noch {amount}, dann {goal}
allDoneEnglish Everything unlocked, enjoyGerman Alles freigeschaltet, viel Freude
reachedEnglish UnlockedGerman Erreicht
closesGapEnglish Closes the gapGerman Schließt die Lücke
addEnglish AddGerman Hinzufügen
addedEnglish AddedGerman Im Korb
addNamedEnglish Add {name}German {name} hinzufügen
youMayLikeEnglish Goes well withGerman Passt dazu
previousEnglish Previous suggestionsGerman Vorherige Vorschläge
nextEnglish Next suggestionsGerman Nächste Vorschläge
addedTitleEnglish Added to your cartGerman Im Warenkorb
alsoInCartEnglish Also in your cartGerman Außerdem im Warenkorb
removedEnglish removed.German entfernt.
undoEnglish UndoGerman Rückgängig
saveLaterEnglish Save for laterGerman Für später merken
savedEnglish Saved for laterGerman Für später gemerkt
moveToCartEnglish Move to cartGerman In den Warenkorb
giftWrapEnglish Wrap it as a giftGerman Als Geschenk verpacken
giftWrapNoteEnglish Recycled paper and a handwritten cardGerman Recyclingpapier und eine handgeschriebene Karte
freeEnglish FreeGerman Gratis
noteEnglish Note for the orderGerman Anmerkung zur Bestellung
notePlaceholderEnglish A message for us or the gift cardGerman Eine Nachricht an uns oder für die Geschenkkarte
noteLeftEnglish {n} characters leftGerman Noch {n} Zeichen
noteOverEnglish {n} characters too many, please shorten the noteGerman {n} Zeichen zu viel, bitte kürze die Anmerkung
deliveryByEnglish Arrives by {date}German Ankunft bis {date}
emptyTitleEnglish Your cart is emptyGerman Dein Warenkorb ist leer
emptyTextEnglish 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
components/ui/mini-cart-drawer.tsx
'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.

Often searched

Jump to

Copied to the clipboard