# Cart

> A cart whose receipt does the maths in front of you: codes are stamped on, a rail of light fills toward free shipping and every total rolls.

Most carts are a table and a total that jumps when you change something, with a promo field that turns red and a free-shipping bar nobody trusts. Here the receipt is the lit surface. Change a quantity and the line and every total roll the way they moved; the change goes to your server once you stop pressing, and if it says no ("Only 2 left") the row shakes back and says so. Remove folds the row into "Removed · Undo" over a hairline that drains for four seconds before anything is deleted. Apply a code and it is stamped onto the cart, landing big and tilted and thrown down flat, while the discount line rolls into the receipt. Along the top of the receipt a rail of light fills tick by tick toward free shipping and says what is left to add, then "Free shipping unlocked". The store is yours: items in, one async function per change.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/cart.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `input`, `number-roll`, `status-button`, `text-morph`, `stepper-input`
- Docs and live preview: https://hairlineui.com/ui/cart
- Registry item (source): https://hairlineui.com/r/cart.json

## Usage

```tsx
import { Cart } from "@/components/cart";

<Cart
  items={items}
  shipping={8}
  freeShippingAt={250}
  onQuantityChange={(id, quantity) => api.updateLine(id, quantity)}
  onRemove={(id) => api.removeLine(id)}
  onApplyCode={(code) => api.applyCode(code)}
  onCheckout={async () => { window.location.href = await api.checkoutUrl(); }}
/>
```

## With your own API

Keep the cart on your server and call it from each function. Return the code’s value from onApplyCode; throw with the server’s message anywhere and the block shows it in place.

```tsx
"use client";
import { useState } from "react";
import { Cart, type CartItem } from "@/components/cart";

async function call<T>(path: string, body: unknown): Promise<T> {
  const res = await fetch(path, { method: "POST", body: JSON.stringify(body) });
  const json = await res.json();
  if (!res.ok) throw new Error(json.message);
  return json;
}

export function CartPanel({ initial }: { initial: CartItem[] }) {
  const [items, setItems] = useState(initial);

  return (
    <Cart
      items={items}
      shipping={8}
      freeShippingAt={250}
      onQuantityChange={async (id, quantity) => setItems(await call("/api/cart/line", { id, quantity }))}
      onRemove={async (id) => setItems(await call("/api/cart/remove", { id }))}
      onApplyCode={(code) => call("/api/cart/code", { code })} // → { code, percent } or { code, amount }
      onCheckout={async (summary) => {
        const { url } = await call<{ url: string }>("/api/checkout", summary);
        window.location.href = url;
      }}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `CartItem[]` | – | { id, name, variant?, price, quantity, max?, image?, icon? }. price is per unit; max caps the stepper (stock). |
| `onQuantityChange` | `(id, quantity) => Promise<unknown>` | – | Called once the presses stop; throw an Error to put the quantity back and show why. |
| `onRemove` | `(id) => Promise<unknown>` | – | Called when Undo runs out (4s); throw to bring the row back with your message. |
| `onApplyCode` | `(code) => Promise<CartDiscount>` | – | Return { code, percent } or { code, amount }; throw an Error to show why it didn’t apply. |
| `onRemoveCode` | `() => Promise<unknown>` | – | Called when the chip’s × is pressed. |
| `onCheckout` | `(summary: CartSummary) => Promise<unknown>` | – | Gets { items, code, subtotal, discount, shipping, total }; start checkout and redirect. |
| `discount` | `CartDiscount \| null` | – | The applied code if you keep it yourself; otherwise the cart remembers what onApplyCode returned. |
| `shipping / freeShippingAt` | `number` | – | Flat shipping, and the order value (after discount) that ships free; the second draws the rail. |
| `title / currency / locale` | `string` | `"Your cart" / "USD" / browser` | The heading, and how every amount is formatted. |

## States

- **cart**: Rows with a tinted tile (or photo), name, variant, a stepper and a line total; under them the code field; then the lit receipt with the shipping rail, the lines and the total.
- **quantity**: The stepper’s count, the line total and the receipt roll at once (700–1000ms); the change is sent 450ms after the last press, and the line dims while it’s saving.
- **refused**: If onQuantityChange throws, the row goes back to your quantity, shakes once and shows the message under it.
- **removed**: The row turns into "Removed · Undo" in 460ms with a hairline draining over 4s; totals already leave it out. Undo brings it back; otherwise it folds away and onRemove runs.
- **code**: Apply goes Checking; the field turns into a dashed chip that is stamped on (lands at 1.5× tilted out of a 6px blur and is thrown flat in 560ms) and the discount line opens in the receipt and rolls. A refusal shakes the field and shows the reason.
- **free shipping**: The rail’s light and its head glide to the new share (700ms); crossing the line swaps "Add $12 for free shipping" for "Free shipping unlocked" and Shipping morphs to Free.
- **checkout**: Check out → Opening checkout → Redirecting with a drawn check; a failure shakes into Try again and replaces the tax note with your message.

## What it handles

A cart is usually a list, a code field, a progress bar and a summary, each with its own loading and error state. This is one block that keeps them in step, against whatever runs your store.

- **Totals you can watch**: Every line, the subtotal, discount, shipping and total roll the way they changed.
- **One request per change**: Quantities are sent once you stop pressing; a refusal puts the row back and shows your message under it.
- **Remove with Undo**: The row folds into Undo over a draining hairline; onRemove is only called when it runs out.
- **Codes stamped on**: onApplyCode returns a percent or an amount; the code lands as a chip and the discount rolls into the receipt.
- **Free shipping you can see**: A rail of light fills toward freeShippingAt and says exactly what is left to add.
- **Checkout in place**: onCheckout gets the items, code and totals; Check out goes Opening checkout → Redirecting, or says why not.

## Replaces

a cart table, quantity steppers, a promo code field, a free-shipping bar, an order summary, an undo toast, a checkout button

## Where it fits

- **Store carts**: A cart page or drawer for Shopify, Stripe, Lemon Squeezy or a store of your own.
- **Order review**: The step before payment, with codes and shipping worked out in front of the buyer.
- **Add-ons and seats**: Extras on a subscription checkout, each with its own quantity.
- **Event tickets**: Ticket types with quantities, a promo code and fees on one receipt.

## Notes

- Each quantity is a labelled stepper ("Quantity of Linen overshirt"); row errors are alerts under their row and the code field’s error is tied to it.
- Removing and Undo, applied codes and removed codes are announced from a status region; totals are read as whole amounts.
- The free-shipping rail is a meter with its percentage; its words change, never a live region that chatters.
- Nothing leaves the cart until Undo has run out, and a change that’s still being sent is replaced by the next one rather than queued.
- Its light comes from your theme: the receipt mixes --primary-foreground into --primary, tiles take tints from --chart-1 to --chart-5, and 1px edges catch the light in light and dark. No drop shadows, glow or blur.
- Built from Input, Stepper input, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion rows and totals change in place.

License: MIT (https://hairlineui.com/license#components)
