Commerce

Cart

A controlled list of cart lines with steppers, line totals, a subtotal and remove with undo.

Built on
<ul>Container queriesARIA live regionsIntl.NumberFormat<data>
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.9 kB
Built from
QuantityPriceIconButtonEmpty state
  • Stoneware mug

    Sea glass, 350 ml

    Line total $18.00

  • Fig and cedar candle

    Line total $24.00

Code
"use client";

import { useState } from "react";
import { Cart, type CartItem } from "@/components/equip/cart";

function Mug() {
  return (
    <svg viewBox="0 0 40 40" aria-hidden="true" style={{ color: "var(--eq-chart-2)" }}>
      <path d="M11 13h15v13a4 4 0 0 1-4 4h-7a4 4 0 0 1-4-4z" fill="currentColor" />
      <path d="M26 16h2.5a3 3 0 0 1 0 6H26" fill="none" stroke="currentColor" strokeWidth="2" />
      <path d="M15 7c-1 1.5 1 2.5 0 4M20 7c-1 1.5 1 2.5 0 4" fill="none" stroke="var(--eq-ink-3)" strokeWidth="1.2" strokeLinecap="round" />
    </svg>
  );
}

function Candle() {
  return (
    <svg viewBox="0 0 40 40" aria-hidden="true" style={{ color: "var(--eq-chart-4)" }}>
      <rect x="12" y="16" width="16" height="16" rx="3" fill="currentColor" />
      <path d="M20 16v-3" stroke="var(--eq-ink)" strokeWidth="1.2" strokeLinecap="round" />
      <path d="M20 6c2.5 3 2.5 5.5 0 6.5-2.5-1-2.5-3.5 0-6.5z" fill="var(--eq-chart-1)" />
    </svg>
  );
}

const start: CartItem[] = [
  { id: "mug", title: "Stoneware mug", options: "Sea glass, 350 ml", unitPrice: 18, quantity: 1, media: <Mug /> },
  { id: "candle", title: "Fig and cedar candle", unitPrice: 24, quantity: 1, media: <Candle /> },
];

export default function Demo() {
  const [items, setItems] = useState(start);
  return (
    <Cart
      variant="compact"
      style={{ width: 224 }}
      items={items}
      onQuantityChange={(id, quantity) => setItems((all) => all.map((i) => (i.id === id ? { ...i, quantity } : i)))}
      onRemove={(id) => setItems((all) => all.filter((i) => i.id !== id))}
      onUndo={(id) => {
        const item = start.find((i) => i.id === id);
        const order = (i: CartItem) => start.findIndex((s) => s.id === i.id);
        if (item) setItems((all) => [...all, item].sort((a, b) => order(a) - order(b)));
      }}
    />
  );
}

Examples

Cart page

Remove a line and an undo row holds its place, with focus on Undo; undoing puts focus back on the line.

  • Linen shirt

    Colour: Oat, Size: M

    Was $85, now $68

    Line total $68.00

  • Court sneaker

    Clay, UK 8

    $112

    Only 2 left in this size

    Line total $112.00

  • Waxed canvas daypack

    Plum, 18 litres

    $94.50 each

    Ships in 2 to 3 weeks

    Line total $189.00

Code
"use client";

import { useState } from "react";
import { Cart, type CartItem } from "@/components/equip/cart";
import { Button } from "@/components/equip/button";

function Shirt() {
  return (
    <svg viewBox="0 0 80 80" aria-hidden="true" style={{ color: "var(--eq-chart-4)" }}>
      <path d="M28 18l-14 8 6 12 6-3v27h28V35l6 3 6-12-14-8c-2 4-6 6-12 6s-10-2-12-6z" fill="currentColor" />
      <path d="M40 24v38" stroke="var(--eq-surface)" strokeWidth="1.5" strokeDasharray="1 5" strokeLinecap="round" />
    </svg>
  );
}

function Sneaker() {
  return (
    <svg viewBox="0 0 80 80" aria-hidden="true" style={{ color: "var(--eq-chart-1)" }}>
      <path d="M12 50V34c6 0 10-4 12-8l10 6c4 3 10 4 16 5l14 3c4 1 6 4 6 8v2z" fill="currentColor" />
      <path d="M10 50h62v4a3 3 0 0 1-3 3H13a3 3 0 0 1-3-3z" fill="var(--eq-ink-2)" />
      <path d="M30 31l4 4M35 28l4 4" stroke="var(--eq-surface)" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function Backpack() {
  return (
    <svg viewBox="0 0 80 80" aria-hidden="true" style={{ color: "var(--eq-chart-3)" }}>
      <path d="M32 18a8 8 0 0 1 16 0" fill="none" stroke="var(--eq-ink-2)" strokeWidth="3" />
      <rect x="20" y="18" width="40" height="46" rx="10" fill="currentColor" />
      <rect x="27" y="42" width="26" height="16" rx="4" fill="var(--eq-surface)" opacity="0.35" />
      <path d="M27 48h26" stroke="var(--eq-surface)" strokeWidth="1.5" opacity="0.6" />
    </svg>
  );
}

const start: CartItem[] = [
  {
    id: "shirt",
    title: "Linen shirt",
    href: "#linen-shirt",
    options: [
      { label: "Colour", value: "Oat" },
      { label: "Size", value: "M" },
    ],
    unitPrice: 68,
    compareAt: 85,
    quantity: 1,
    media: <Shirt />,
  },
  {
    id: "sneaker",
    title: "Court sneaker",
    href: "#court-sneaker",
    options: "Clay, UK 8",
    unitPrice: 112,
    quantity: 1,
    max: 2,
    media: <Sneaker />,
    note: "Only 2 left in this size",
  },
  {
    id: "pack",
    title: "Waxed canvas daypack",
    href: "#daypack",
    options: "Plum, 18 litres",
    unitPrice: 94.5,
    quantity: 2,
    media: <Backpack />,
    note: "Ships in 2 to 3 weeks",
  },
];

export default function Example() {
  const [items, setItems] = useState(start);
  /* The page keeps what it removed, so Undo can put the line back exactly as it was. */
  const [removed, setRemoved] = useState<Record<string, { item: CartItem; index: number }>>({});

  return (
    <Cart
      items={items}
      onQuantityChange={(id, quantity) => setItems((all) => all.map((i) => (i.id === id ? { ...i, quantity } : i)))}
      onRemove={(id) => {
        const index = items.findIndex((i) => i.id === id);
        if (index >= 0) setRemoved((r) => ({ ...r, [id]: { item: items[index], index } }));
        setItems((all) => all.filter((i) => i.id !== id));
      }}
      onUndo={(id) => {
        const entry = removed[id];
        if (!entry) return;
        setItems((all) => [...all.slice(0, entry.index), entry.item, ...all.slice(entry.index)]);
      }}
      subtotalHint="Shipping and taxes are worked out at checkout."
      footer={<Button variant="accent" size="l">Check out</Button>}
    />
  );
}

Drawer

The compact variant keeps steppers small and puts a close button on each line, for a side panel.

Your cart

  • Brass desk lamp

    Warm white bulb

    Line total $129.00

  • Merino crew socks

    Rust, size L

    $16 each

    Line total $48.00

  • Gooseneck kettle

    Matte teal

    Was $89, now $74

    Line total $74.00

Code
"use client";

import { useState } from "react";
import { Cart, type CartItem } from "@/components/equip/cart";
import { Button } from "@/components/equip/button";

function Lamp() {
  return (
    <svg viewBox="0 0 40 40" aria-hidden="true" style={{ color: "var(--eq-chart-1)" }}>
      <path d="M13 8h14l5 12H8z" fill="currentColor" />
      <path d="M20 20v12M14 33h12" stroke="var(--eq-ink-2)" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

function Socks() {
  return (
    <svg viewBox="0 0 40 40" aria-hidden="true" style={{ color: "var(--eq-chart-5)" }}>
      <path d="M14 6h10v16l6 5a4 4 0 0 1-5 6l-9-7a5 5 0 0 1-2-4z" fill="currentColor" />
      <path d="M14 10h10" stroke="var(--eq-surface)" strokeWidth="2" opacity="0.6" />
    </svg>
  );
}

function Kettle() {
  return (
    <svg viewBox="0 0 40 40" aria-hidden="true" style={{ color: "var(--eq-chart-2)" }}>
      <path d="M12 16h16l2 14a3 3 0 0 1-3 3H13a3 3 0 0 1-3-3z" fill="currentColor" />
      <path d="M28 18l5-4M15 16a5 5 0 0 1 10 0" fill="none" stroke="var(--eq-ink-2)" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

const start: CartItem[] = [
  { id: "lamp", title: "Brass desk lamp", options: "Warm white bulb", unitPrice: 129, quantity: 1, media: <Lamp /> },
  { id: "socks", title: "Merino crew socks", options: "Rust, size L", unitPrice: 16, quantity: 3, max: 6, media: <Socks /> },
  { id: "kettle", title: "Gooseneck kettle", options: "Matte teal", unitPrice: 74, compareAt: 89, quantity: 1, media: <Kettle /> },
];

export default function Example() {
  const [items, setItems] = useState(start);
  const [removed, setRemoved] = useState<Record<string, { item: CartItem; index: number }>>({});

  return (
    <div
      style={{
        width: "100%",
        maxWidth: 360,
        boxSizing: "border-box",
        padding: 16,
        border: "1px solid var(--eq-line-strong)",
        borderRadius: "var(--eq-radius-l)",
        background: "var(--eq-raised)",
      }}
    >
      <h3 style={{ margin: "0 0 12px", font: "var(--eq-weight-strong) var(--eq-text-l) / 1.3 var(--eq-font)" }}>Your cart</h3>
      <Cart
        variant="compact"
        items={items}
        onQuantityChange={(id, quantity) => setItems((all) => all.map((i) => (i.id === id ? { ...i, quantity } : i)))}
        onRemove={(id) => {
          const index = items.findIndex((i) => i.id === id);
          if (index >= 0) setRemoved((r) => ({ ...r, [id]: { item: items[index], index } }));
          setItems((all) => all.filter((i) => i.id !== id));
        }}
        onUndo={(id) => {
          const entry = removed[id];
          if (entry) setItems((all) => [...all.slice(0, entry.index), entry.item, ...all.slice(entry.index)]);
        }}
        subtotalHint="Free shipping on orders over $150."
        footer={
          <>
            <Button variant="accent">Check out</Button>
            <Button variant="outline">View cart</Button>
          </>
        }
      />
    </div>
  );
}

Empty cart

With no lines the footer goes and a short empty state takes the list's place; pass empty to replace it.

Your cart is empty

Things you add will wait here until you check out.

Code
"use client";

import { Cart } from "@/components/equip/cart";
import { Button } from "@/components/equip/button";

export default function Example() {
  return (
    <Cart
      items={[]}
      onQuantityChange={() => {}}
      onRemove={() => {}}
      emptyAction={<Button variant="outline">Continue shopping</Button>}
      style={{ maxWidth: 420 }}
    />
  );
}

Pounds, en-GB

Line totals and the subtotal use the currency's own fraction digits, so the column lines up.

  • Cast iron teapot

    Moss, 0.8 litres

    £42

    Line total £42.00

  • Breakfast tea, loose leaf

    250 g tin

    £8.95 each

    Line total £26.85

Code
"use client";

import { useState } from "react";
import { Cart, type CartItem } from "@/components/equip/cart";
import { Button } from "@/components/equip/button";

function Teapot() {
  return (
    <svg viewBox="0 0 80 80" aria-hidden="true" style={{ color: "var(--eq-chart-2)" }}>
      <ellipse cx="38" cy="46" rx="20" ry="16" fill="currentColor" />
      <path d="M57 42c6-2 9-8 10-12M19 40c-6 0-8 6-4 10" fill="none" stroke="currentColor" strokeWidth="4" strokeLinecap="round" />
      <rect x="32" y="26" width="12" height="5" rx="2" fill="var(--eq-ink-2)" />
    </svg>
  );
}

function Tea() {
  return (
    <svg viewBox="0 0 80 80" aria-hidden="true" style={{ color: "var(--eq-chart-4)" }}>
      <rect x="24" y="22" width="32" height="40" rx="4" fill="currentColor" />
      <rect x="24" y="34" width="32" height="12" fill="var(--eq-surface)" opacity="0.5" />
      <path d="M40 22v-6" stroke="var(--eq-ink-2)" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

const start: CartItem[] = [
  { id: "teapot", title: "Cast iron teapot", options: "Moss, 0.8 litres", unitPrice: 42, quantity: 1, media: <Teapot /> },
  { id: "tea", title: "Breakfast tea, loose leaf", options: "250 g tin", unitPrice: 8.95, quantity: 3, media: <Tea /> },
];

export default function Example() {
  const [items, setItems] = useState(start);
  return (
    <Cart
      currency="GBP"
      locale="en-GB"
      items={items}
      onQuantityChange={(id, quantity) => setItems((all) => all.map((i) => (i.id === id ? { ...i, quantity } : i)))}
      onRemove={(id) => setItems((all) => all.filter((i) => i.id !== id))}
      subtotalHint="Delivery is free across the UK."
      footer={<Button variant="accent" size="l">Go to checkout</Button>}
    />
  );
}

Without the subtotal

Set showSubtotal to false when an order summary beside the cart repeats the subtotal with delivery and tax.

  • Bluetooth barcode scanner

    Pairs with phones and tablets

    Line total £89.00

  • Shelf-edge labels

    Roll of 500

    £18 each

    Line total £72.00

Code
"use client";

import { useState } from "react";
import { Cart, type CartItem } from "@/components/equip/cart";

const start: CartItem[] = [
  { id: "scanner", title: "Bluetooth barcode scanner", options: "Pairs with phones and tablets", unitPrice: 89, quantity: 1, max: 6 },
  { id: "labels", title: "Shelf-edge labels", options: "Roll of 500", unitPrice: 18, quantity: 4, max: 20 },
];

export default function Example() {
  const [items, setItems] = useState(start);
  return (
    <Cart
      variant="compact"
      currency="GBP"
      locale="en-GB"
      showSubtotal={false}
      items={items}
      onQuantityChange={(id, quantity) => setItems((all) => all.map((i) => (i.id === id ? { ...i, quantity } : i)))}
      onRemove={(id) => setItems((all) => all.filter((i) => i.id !== id))}
    />
  );
}

Props

PropTypeDefaultWhat it does
items{ id; title; href?; options?; unitPrice; compareAt?; quantity; max?; media?; note? }[]noneThe lines. options is a string such as "Oat, size M" or { label, value } pairs; media is a thumbnail node.
onQuantityChange(id: string, quantity: number) => voidnoneCalled with each new clamped quantity.
onRemove(id: string) => voidnoneCalled from the remove button or the stepper's bin at one. Take the line out of items.
onUndo(id: string) => voidnonePut the line back into items. When given, a removed line leaves an undo row in its place.
currencystring"USD"ISO 4217 currency code.
localestring"en-US"Sets the symbol position, separators and grouping.
variant"full" | "compact""full"compact is for a drawer: 40px thumbnails, small steppers and a close button per line.
subtotalnumbernoneReplaces the subtotal worked out from the lines.
subtotalLabelstring"Subtotal"Label of the subtotal row.
subtotalHintReactNodenoneA line under the subtotal, such as where shipping is worked out.
showSubtotalbooleantrueSet false to leave out the subtotal row and its hint, such as beside an order summary that repeats them. footer still shows.
footerReactNodenoneContent under the subtotal, usually a checkout Button; buttons stretch to full width.
emptyReactNodenoneReplaces the default empty state.
emptyActionReactNodenoneAn action inside the default empty state, such as Continue shopping.
...restOmit<ComponentProps<"div">, "children">noneEvery native div attribute, applied to the root.

Anatomy

root.eq-cartThe size container; variant is on data-variant.
list.eq-cart-listA <ul> of lines.
item.eq-cart-itemOne line, laid out on a grid that restacks below 30rem.
media.eq-cart-mediaThe thumbnail frame, hidden from assistive tech.
info.eq-cart-infoTitle, options, unit price and note.
title.eq-cart-titleA link when href is given.
options.eq-cart-options
unit price.eq-cart-unitA small Price with any struck earlier price.
each.eq-cart-eachFollows the unit price when the quantity is above one.
note.eq-cart-note
quantity.eq-cart-quantityThe Quantity stepper, whose minus turns into a bin at one.
line total.eq-cart-total
remove.eq-cart-removeA text button in full, a close button in compact.
removed row.eq-cart-removedHolds a removed line's place with Undo.
removed text.eq-cart-removed-text
removed title.eq-cart-removed-title
dismiss.eq-cart-dismissClears the undo row.
empty.eq-cart-emptyThe default EmptyState.
footer.eq-cart-footer
subtotal.eq-cart-subtotal
subtotal label.eq-cart-subtotal-label
count.eq-cart-countThe number of units in the cart.
subtotal amount.eq-cart-subtotal-amount
hint.eq-cart-hint
actions.eq-cart-actionsHolds footer.
hidden text.eq-cart-hiddenWords for screen readers only.
status.eq-cart-statusA polite live region for removed and restored lines.

Keyboard

TabMoves through each line's title, stepper and remove button.
ArrowUpArrowDownIn a stepper, changes the quantity by one.
EnterSpaceOn remove, removes the line and moves focus to Undo; on Undo, restores it and focuses its title.

Notes

  • The cart is controlled: it calls onQuantityChange, onRemove and onUndo and draws whatever items you pass back.
  • After a remove, focus moves to the undo row's Undo button; without onUndo it moves to the next line, or the cart itself when none are left.
  • The undo row stays until Undo, its dismiss button or the next removal; it has no timer, so nobody has to race it.
  • Keep the removed line yourself (with its quantity and position) so onUndo can put it back exactly as it was.
  • Removing and restoring are announced in a polite live region; quantity changes are left to the stepper so the subtotal is not read on every step.
  • Line totals and the subtotal use the currency's own fraction digits, so a column of totals lines up; the unit price is a Price, which drops zero cents.
  • Below a 30rem container the line restacks: thumbnail and details on top, stepper and total underneath, with no sideways scroll at 280px.
  • Thumbnails are hidden from assistive tech and are not links, so each line has one link and one name.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/cart.tsx289 lines
"use client";

import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { Quantity } from "./quantity";
import { Price } from "./price";
import { Icon } from "./icon";
import { Button } from "./button";
import { EmptyState, EmptyStateActions } from "./empty-state";
import "./cart.css";

export type CartItem = {
  id: string;
  title: string;
  /** Links the title to the product page. */
  href?: string;
  /** The chosen variant, as text ("Oat, size M") or labelled pairs. */
  options?: string | { label: string; value: string }[];
  /** Price of one, in major units. */
  unitPrice: number;
  /** The earlier price of one, shown struck through. */
  compareAt?: number;
  quantity: number;
  /** Highest quantity, usually the stock left. */
  max?: number;
  /** A thumbnail: an image or drawn placeholder. Decorative; the title names the line. */
  media?: ReactNode;
  /** A short line under the options, such as "Ships in 2 to 3 weeks". */
  note?: string;
};

export type CartProps = Omit<ComponentProps<"div">, "children"> & {
  items: CartItem[];
  onQuantityChange: (id: string, quantity: number) => void;
  /** Called when a line is removed. Remove it from items; the cart keeps an undo row in its place. */
  onRemove: (id: string) => void;
  /** Put the line back into items. Without it, no undo row is shown. */
  onUndo?: (id: string) => void;
  /** ISO 4217 code. */
  currency?: string;
  locale?: string;
  /** compact is for a drawer or a narrow panel: smaller thumbnails and steppers. */
  variant?: "full" | "compact";
  /** Replaces the subtotal worked out from the lines, such as one from your server. */
  subtotal?: number;
  subtotalLabel?: string;
  /** A line under the subtotal, such as "Shipping and taxes are worked out at checkout". */
  subtotalHint?: ReactNode;
  /** Set false to leave out the subtotal row and its hint, such as when an order summary beside the cart repeats it. */
  showSubtotal?: boolean;
  /** Content under the subtotal, usually a checkout Button. */
  footer?: ReactNode;
  /** Replaces the whole empty state. */
  empty?: ReactNode;
  /** An action for the default empty state, such as a link back to the shop. */
  emptyAction?: ReactNode;
};

type Removed = { item: CartItem; index: number; restoring: boolean };

function optionsText(options: CartItem["options"]) {
  if (!options) return null;
  if (typeof options === "string") return options;
  return options.map((o) => `${o.label}: ${o.value}`).join(", ");
}

export function Cart({
  items,
  onQuantityChange,
  onRemove,
  onUndo,
  currency = "USD",
  locale = "en-US",
  variant = "full",
  subtotal,
  subtotalLabel = "Subtotal",
  subtotalHint,
  showSubtotal = true,
  footer,
  empty,
  emptyAction,
  className,
  ...rest
}: CartProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const undoRef = useRef<HTMLButtonElement>(null);
  const rows = useRef(new Map<string, HTMLLIElement>());
  /** Where focus should go after the next render: the undo button, a restored line, or a line by index. */
  const pendingFocus = useRef<{ to: "undo" } | { to: "line"; id: string } | { to: "index"; index: number } | null>(null);
  const [removed, setRemoved] = useState<Removed | null>(null);
  const [status, setStatus] = useState("");

  const compact = variant === "compact";
  /* Every money column uses the currency's own fraction digits, so $58.00 and $116.50 line up. */
  const money = new Intl.NumberFormat(locale, { style: "currency", currency });
  const total = subtotal ?? items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
  const count = items.reduce((sum, item) => sum + item.quantity, 0);

  const placeholderShown = removed !== null && !items.some((item) => item.id === removed.item.id);

  useEffect(() => {
    /* The parent put the line back, so the undo row has done its job. */
    if (removed && !placeholderShown) {
      if (removed.restoring) pendingFocus.current = { to: "line", id: removed.item.id };
      setRemoved(null);
    }
  }, [removed, placeholderShown]);

  useEffect(() => {
    const want = pendingFocus.current;
    if (!want) return;
    if (want.to === "undo") {
      if (!undoRef.current) return;
      undoRef.current.focus();
    } else {
      const row =
        want.to === "line"
          ? rows.current.get(want.id)
          : (rootRef.current?.querySelectorAll<HTMLLIElement>(".eq-cart-item")[want.index] ??
            rootRef.current?.querySelectorAll<HTMLLIElement>(".eq-cart-item")[want.index - 1]);
      if (want.to === "line" && !row) return;
      const target = row?.querySelector<HTMLElement>(".eq-quantity-input, .eq-cart-title") ?? rootRef.current;
      target?.focus();
    }
    pendingFocus.current = null;
  });

  const remove = (item: CartItem, index: number) => {
    setStatus(`${item.title} removed`);
    if (onUndo) {
      setRemoved({ item, index, restoring: false });
      pendingFocus.current = { to: "undo" };
    } else {
      pendingFocus.current = { to: "index", index };
    }
    onRemove(item.id);
  };

  const undo = () => {
    if (!removed || !onUndo) return;
    setRemoved({ ...removed, restoring: true });
    setStatus(`${removed.item.title} restored`);
    onUndo(removed.item.id);
  };

  const dismiss = () => {
    if (!removed) return;
    pendingFocus.current = { to: "index", index: removed.index };
    setRemoved(null);
  };

  const rowsOut: ReactNode[] = items.map((item, index) => {
    const options = optionsText(item.options);
    const label = `Quantity, ${item.title}`;
    const showUnit = !compact || item.quantity > 1 || (item.compareAt ?? 0) > item.unitPrice;
    const lineTotal = item.unitPrice * item.quantity;
    return (
      <li
        key={item.id}
        className="eq-cart-item"
        data-id={item.id}
        ref={(node) => {
          if (node) rows.current.set(item.id, node);
          else rows.current.delete(item.id);
        }}
      >
        {item.media != null && (
          <div className="eq-cart-media" aria-hidden="true">
            {item.media}
          </div>
        )}
        <div className="eq-cart-info">
          {item.href ? (
            <a className="eq-cart-title" href={item.href}>
              {item.title}
            </a>
          ) : (
            <span className="eq-cart-title" tabIndex={-1}>
              {item.title}
            </span>
          )}
          {options && <p className="eq-cart-options">{options}</p>}
          {showUnit && (
            <p className="eq-cart-unit">
              <Price amount={item.unitPrice} compareAt={item.compareAt} currency={currency} locale={locale} size="s" />
              {item.quantity > 1 && <span className="eq-cart-each"> each</span>}
            </p>
          )}
          {item.note && <p className="eq-cart-note">{item.note}</p>}
        </div>
        <Quantity
          className="eq-cart-quantity"
          label={label}
          value={item.quantity}
          max={item.max}
          size={compact ? "s" : "m"}
          onValueChange={(next) => onQuantityChange(item.id, next)}
          onRemove={() => remove(item, index)}
        />
        <p className="eq-cart-total">
          <span className="eq-cart-hidden">Line total </span>
          <data value={lineTotal}>{money.format(lineTotal)}</data>
        </p>
        <Button
          className="eq-cart-remove"
          variant="ghost"
          size="s"
          aria-label={`Remove ${item.title}`}
          lead={<Icon name={compact ? "close" : "trash"} size="s" />}
          onClick={() => remove(item, index)}
        >
          {compact ? null : "Remove"}
        </Button>
      </li>
    );
  });

  if (placeholderShown && removed) {
    rowsOut.splice(
      Math.min(removed.index, rowsOut.length),
      0,
      <li key={`removed-${removed.item.id}`} className="eq-cart-removed">
        <p className="eq-cart-removed-text">
          <span className="eq-cart-removed-title">{removed.item.title}</span> removed.
        </p>
        <Button ref={undoRef} variant="outline" size="s" onClick={undo} disabled={removed.restoring}>
          Undo
        </Button>
        <Button
          className="eq-cart-dismiss"
          variant="ghost"
          size="s"
          aria-label="Dismiss"
          lead={<Icon name="close" size="s" />}
          onClick={dismiss}
        />
      </li>,
    );
  }

  const isEmpty = rowsOut.length === 0;

  return (
    <div
      {...rest}
      ref={rootRef}
      tabIndex={-1}
      className={["eq-cart", className].filter(Boolean).join(" ")}
      data-variant={variant}
    >
      {isEmpty ? (
        (empty ?? (
          <EmptyState
            className="eq-cart-empty"
            size="s"
            title="Your cart is empty"
            icon={<Icon name="cart" />}
          >
            <p>Things you add will wait here until you check out.</p>
            {emptyAction != null && <EmptyStateActions>{emptyAction}</EmptyStateActions>}
          </EmptyState>
        ))
      ) : (
        <ul className="eq-cart-list">{rowsOut}</ul>
      )}
      {items.length > 0 && (showSubtotal || footer != null) && (
        <div className="eq-cart-footer">
          {showSubtotal && (
            <p className="eq-cart-subtotal">
              <span className="eq-cart-subtotal-label">
                {subtotalLabel}{" "}
                <span className="eq-cart-count">
                  ({count} {count === 1 ? "item" : "items"})
                </span>
              </span>
              <data className="eq-cart-subtotal-amount" value={total}>
                {money.format(total)}
              </data>
            </p>
          )}
          {showSubtotal && subtotalHint != null && <p className="eq-cart-hint">{subtotalHint}</p>}
          {footer != null && <div className="eq-cart-actions">{footer}</div>}
        </div>
      )}
      <p className="eq-cart-status" role="status">
        {status}
      </p>
    </div>
  );
}
components/equip/cart.css338 lines
@layer equip {
  .eq-cart {
    --_thumb: 80px;
    --_gap: var(--eq-space-4);
    --_pad: var(--eq-space-4);

    container-type: inline-size;
    box-sizing: border-box;
    width: 100%;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
  }

  .eq-cart[data-variant="compact"] {
    --_thumb: 40px;
    --_gap: var(--eq-space-2);
    --_pad: var(--eq-space-2);
    font-size: var(--eq-text-s);
  }

  .eq-cart:focus {
    outline: none;
  }

  .eq-cart-list {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-cart-item,
  .eq-cart-removed {
    padding-block: var(--_pad);
    border-bottom: 1px solid var(--eq-line);
  }

  .eq-cart-item:first-child,
  .eq-cart-removed:first-child {
    padding-top: 0;
  }

  /* Wide: thumbnail, details, stepper and total in one row, totals in a column at the end. */
  .eq-cart-item {
    display: grid;
    grid-template-columns: var(--_thumb) minmax(0, 1fr) auto minmax(9ch, auto);
    grid-template-areas: "media info quantity total";
    align-items: start;
    column-gap: var(--_gap);
    row-gap: var(--eq-space-2);
  }

  .eq-cart-item:not(:has(.eq-cart-media)) {
    grid-template-columns: minmax(0, 1fr) auto minmax(9ch, auto);
    grid-template-areas: "info quantity total";
  }

  .eq-cart-media {
    grid-area: media;
    display: grid;
    place-items: center;
    width: var(--_thumb);
    aspect-ratio: 1;
    overflow: hidden;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint);
    color: var(--eq-ink-2);
  }

  .eq-cart[data-variant="compact"] .eq-cart-media {
    border-radius: var(--eq-radius-s);
  }

  .eq-cart-media > :is(svg, img) {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .eq-cart-info {
    grid-area: info;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    min-width: 0;
  }

  .eq-cart-title {
    max-width: 100%;
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
    line-height: 1.3;
    text-decoration: none;
    overflow-wrap: anywhere;
    border-radius: var(--eq-radius-s);
  }

  a.eq-cart-title {
    text-decoration-line: underline;
    text-decoration-color: transparent;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.2em;
    transition: text-decoration-color var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    a.eq-cart-title:hover {
      text-decoration-color: currentColor;
    }
  }

  .eq-cart-title:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: 2px;
  }

  .eq-cart-title:focus:not(:focus-visible) {
    outline: none;
  }

  .eq-cart-options,
  .eq-cart-note,
  .eq-cart-unit {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
    line-height: 1.35;
  }

  .eq-cart[data-variant="compact"] :is(.eq-cart-options, .eq-cart-note, .eq-cart-unit) {
    font-size: var(--eq-text-xs);
  }

  .eq-cart-unit {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
  }

  .eq-cart-each {
    white-space: pre;
  }

  .eq-cart-note {
    color: var(--eq-ink-3);
  }

  .eq-cart-quantity {
    grid-area: quantity;
  }

  .eq-cart-total {
    grid-area: total;
    margin: 0;
    font-weight: var(--eq-weight-strong);
    font-variant-numeric: tabular-nums;
    line-height: var(--eq-h-m);
    text-align: end;
    white-space: nowrap;
  }

  .eq-cart[data-variant="compact"] .eq-cart-total {
    line-height: var(--eq-h-s);
  }

  .eq-cart-remove {
    grid-area: info;
    align-self: end;
    justify-self: start;
    margin-inline-start: calc(-1 * var(--eq-space-2));
    color: var(--eq-ink-2);
  }

  /* The details sit above the remove button in the same cell. */
  .eq-cart[data-variant="full"] .eq-cart-info {
    padding-bottom: calc(var(--eq-h-s) + var(--eq-space-1));
  }

  /* Compact: the thumbnail beside both rows, a small close button at the top end. */
  .eq-cart[data-variant="compact"] .eq-cart-item {
    grid-template-columns: var(--_thumb) minmax(0, 1fr) auto;
    grid-template-areas:
      "media info remove"
      "media quantity total";
    row-gap: var(--eq-space-1);
  }

  .eq-cart[data-variant="compact"] .eq-cart-item:not(:has(.eq-cart-media)) {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas:
      "info remove"
      "quantity total";
  }

  .eq-cart[data-variant="compact"] .eq-cart-remove {
    grid-area: remove;
    align-self: start;
    justify-self: end;
    width: 24px;
    height: 24px;
    margin: -2px 0 0;
    padding: 0;
  }

  .eq-cart[data-variant="compact"] .eq-cart-quantity {
    justify-self: start;
  }

  /* Narrow full cart: thumbnail and details on top, stepper and total underneath. */
  @container (width < 30rem) {
    .eq-cart[data-variant="full"] {
      --_thumb: 64px;
      --_gap: var(--eq-space-3);
    }

    .eq-cart[data-variant="full"] .eq-cart-item {
      grid-template-columns: var(--_thumb) minmax(0, 1fr) auto;
      grid-template-areas:
        "media info info"
        "quantity quantity total";
    }

    .eq-cart[data-variant="full"] .eq-cart-item:not(:has(.eq-cart-media)) {
      grid-template-columns: minmax(0, 1fr) auto;
      grid-template-areas:
        "info info"
        "quantity total";
    }
  }

  /* The undo row holds the removed line's place until it is undone, dismissed or replaced. */
  .eq-cart-removed {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--eq-space-2);
    color: var(--eq-ink-2);
    transition:
      opacity var(--eq-dur-move) var(--eq-spring),
      translate var(--eq-dur-move) var(--eq-spring);
  }

  @starting-style {
    .eq-cart-removed {
      opacity: 0;
      translate: 0 4px;
    }
  }

  .eq-cart-removed-text {
    flex: 1 1 10ch;
    min-width: 0;
    margin: 0;
    font-size: var(--eq-text-s);
  }

  .eq-cart-removed-title {
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  .eq-cart-dismiss {
    width: var(--eq-h-s);
    padding: 0;
    color: var(--eq-ink-2);
  }

  .eq-cart-empty {
    padding-block: var(--eq-space-5);
  }

  .eq-cart-footer {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-2);
    padding-top: var(--_pad);
  }

  .eq-cart-subtotal {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    align-items: baseline;
    column-gap: var(--eq-space-3);
    margin: 0;
    font-weight: var(--eq-weight-strong);
  }

  .eq-cart[data-variant="full"] .eq-cart-subtotal {
    font-size: var(--eq-text-l);
  }

  .eq-cart-count {
    color: var(--eq-ink-2);
    font-weight: var(--eq-weight);
    font-size: var(--eq-text-s);
  }

  .eq-cart[data-variant="compact"] .eq-cart-count {
    font-size: var(--eq-text-xs);
  }

  .eq-cart-subtotal-amount {
    margin-inline-start: auto;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
  }

  .eq-cart-hint {
    margin: 0;
    color: var(--eq-ink-2);
    font-size: var(--eq-text-s);
  }

  .eq-cart-actions {
    display: flex;
    flex-direction: column;
    /* Stretched, so each action, a Button or a link, runs the full width of the cart. */
    align-items: stretch;
    gap: var(--eq-space-2);
    padding-top: var(--eq-space-1);
  }

  .eq-cart-hidden,
  .eq-cart-status {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index