CartLine

One line in a cart, bag or order confirmation: a thumbnail, the item's name and variant details, a quantity stepper with remove, and the price. It is presentational and controlled: it shows what it is given and calls back, and holds no cart state, adds nothing up and talks to no server.

Commerce CartLine.jsx CartLine.d.ts CartLine.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

CartLine is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { CartLine } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

One line in a cart, bag or order confirmation: a thumbnail, the item's name and variant details, a quantity stepper with remove, and the price. It is presentational and controlled: it shows what it is given and calls back, and holds no cart state, adds nothing up and talks to no server.

Use it when

  • Listing what is in a cart page, a cart drawer or a mini bag (size="sm" in drawers).
  • Showing what was bought on an order confirmation or receipt (readOnly).
  • Checking out, in the list of items beside the OrderSummary.

Don't use it when

  • The item is being browsed, not bought. Use a product card (Card with Price and Rating).
  • You need a table of many orders or SKUs with sortable columns. Use Table.
  • A menu row where the only control is the count. Use QuantityStepper beside the dish name.

Example

<CartLine
  name="Linen shirt"
  href="/products/linen-shirt"
  image={{ src: "/img/linen-shirt.jpg", alt: "Linen shirt in sand" }}
  details={["Size M", "Colour Sand"]}
  price={48}
  compareAt={60}
  quantity={line.qty}
  maxQuantity={5}
  lineTotal={line.qty * 48}
  note="Only 2 left"
  onQuantityChange={(qty) => updateLine(line.id, qty)}
  onRemove={() => removeLine(line.id)}
  currency="USD"
  locale="en-US"
  divider
/>

{/* Order confirmation */}
<CartLine name="Linen shirt" details={["Size M"]} price={48} quantity={2} lineTotal={96} readOnly />

lineTotal is whatever your cart computed (after line discounts, bundles, tax rules); the component never multiplies price by quantity. Prices are in major units, as Price takes them.

Variants

PropWhat it is for
size="md"Default. The cart page: 64px thumbnail, label-lg name, md price.
size="sm"Drawers and mini carts: 48px thumbnail, label-md name, sm price.
readOnlyOrder confirmation and receipts: "Qty 2" as text, no stepper, no remove.
lineTotalThe line's total on the right, and the unit price with "each" under the name. Without it the unit price is on the right.
compareAtA sale on the unit price: struck through, and the price in the sale colour (from Price).
maxQuantityThe stepper's upper limit, e.g. the stock left. Pair it with a note saying so.
noteOne short line of status: stock, delivery time, a gift message.
onRemoveA Remove button beside the stepper, and the stepper's minus turns into remove at 1. Without it the line cannot be removed from here.
dividerA rule under the line and padding above and below it, for stacked lines.
image without srcA small placeholder frame with an image icon, like Image with no file. Omit image for no thumbnail column at all.

Composition

Stack lines in a <ul>/<li> list (or a Stack) with divider, inside a cart page's main column or a Drawer. It renders Price for its prices and QuantityStepper for the quantity, so both follow their own tokens and rules. The totals belong in an OrderSummary beside or under the list. When the line is too narrow for the price beside the text (a phone, a slim drawer), the price moves under the text, at the inline end, rather than squeezing the name.

Tokens

Tier 3, in tokens/component/commerce.css, colours repeated under .dark:

  • --dt-cart-line-divider (--dt-border-subtle): the rule under a line with divider.
  • --dt-cart-thumb-bg (--dt-surface-sunken), --dt-cart-thumb-border (--dt-border-subtle), --dt-cart-thumb-fg (--dt-text-tertiary): the thumbnail frame and its placeholder icon.
  • --dt-cart-detail-color (--dt-text-secondary): the variant details and "Qty 2".
  • --dt-cart-note-color (--dt-text-secondary): the note.
  • --dt-cart-thumb-radius (--dt-radius-media), --dt-cart-thumb-size-sm (--dt-size-control-lg), --dt-cart-thumb-size-md (--dt-size-avatar-xl).

The name reads --dt-text-primary and the --dt-text-label-md|lg-* roles; details and the note read --dt-text-body-xs|sm-*. Prices read the --dt-price-* tokens through Price, and the stepper the input and ghost button tokens.

Accessibility

  • The stepper is named "Quantity, Linen shirt", its buttons "Decrease quantity, Linen shirt" and "Increase quantity, Linen shirt", and remove "Remove Linen shirt", so a screen-reader user moving through a cart of several lines always knows which line a control belongs to. The visible Remove button has the same name, which begins with its visible text.
  • The stepper's keyboard contract is QuantityStepper's: arrows step, Home and End jump, typing commits on Enter or blur, and ArrowDown never removes.
  • A read-only quantity is shown as "Qty 2" and read as "Quantity 2".
  • The thumbnail's alt is read. When the name already says everything the picture does, pass alt: "".
  • Detail lines are separated by a middle dot on screen and a comma for screen readers.

Content

  • name is the product name as the store lists it, in its own casing. Don't repeat the variant in it; that is what details is for.
  • details are short "Attribute Value" pairs in sentence case: "Size M", "Colour Sand", "Gift wrap".
  • note is one short sentence fragment, no full stop: "Only 2 left", "Ships in 3 days".

Props

The typed contract, from CartLine.d.ts.

import * as React from "react";

/** One line in a cart, bag or order confirmation: thumbnail, name, variant details, quantity and price. */
export interface CartLineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The item's name. Also goes into the stepper's and the remove button's accessible names. */
  name: string;
  /** Link to the product page. The name becomes a link when given. */
  href?: string;
  /** Product thumbnail. With no src, a placeholder frame shows in its place. */
  image?: { src?: string; alt: string };
  /** Variant lines under the name, e.g. ["Size M", "Colour Sand"]. */
  details?: string[];
  /** Unit price in major units (24.5 for $24.50). */
  price: number;
  /** Original unit price. When greater than price, the price shows as a sale. */
  compareAt?: number;
  /** How many of the item are in the cart. */
  quantity: number;
  /** Called with the next quantity from the stepper, already clamped to 1 and maxQuantity. Not needed when readOnly. */
  onQuantityChange?: (next: number) => void;
  /** Removes the line. Shows a Remove button, and turns the stepper's minus into remove at 1. */
  onRemove?: () => void;
  /** ISO 4217 currency code for every price on the line. @default "USD" */
  currency?: string;
  /** BCP 47 locale for the prices. Defaults to the runtime's; set it when server rendering. */
  locale?: string;
  /**
   * Price for the whole line, as your cart computed it. Shown on the right when given, with the
   * unit price under the name; otherwise the unit price is shown on the right.
   */
  lineTotal?: number;
  /** Highest quantity the stepper allows, e.g. the stock left. No limit when omitted. */
  maxQuantity?: number;
  /** A short line under the details: "Only 2 left", "Ships in 3 days". */
  note?: React.ReactNode;
  /** Order confirmation: the quantity shows as "Qty 2" text and there are no controls. @default false */
  readOnly?: boolean;
  /** sm for drawers and mini carts (48px thumbnail), md for the cart page (64px). @default "md" */
  size?: "sm" | "md";
  /** Draws a rule under the line and pads it, for lines stacked in a list. @default false */
  divider?: boolean;
}

export declare function CartLine(props: CartLineProps): React.JSX.Element;

Tokens it reads

Every custom property CartLine.jsx resolves, read from the source: 9 component, 160 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-cart-detail-colorcomponentvar(--dt-text-secondary)
--dt-cart-line-dividercomponentvar(--dt-border-subtle)
--dt-cart-note-colorcomponentvar(--dt-text-secondary)
--dt-cart-thumb-bgcomponentvar(--dt-surface-sunken)
--dt-cart-thumb-bordercomponentvar(--dt-border-subtle)
--dt-cart-thumb-fgcomponentvar(--dt-text-tertiary)
--dt-cart-thumb-radiuscomponentvar(--dt-radius-media)
--dt-cart-thumb-size-mdcomponentvar(--dt-size-avatar-xl)
--dt-cart-thumb-size-smcomponentvar(--dt-size-control-lg)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-size-control-lgsemanticvar(--dt-dim-12)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-space-inline-mdsemanticvar(--dt-dim-4)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-space-stack-mdsemanticvar(--dt-dim-4)
--dt-space-stack-smsemanticvar(--dt-dim-3)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";
import { Price } from "./Price.jsx";
import { QuantityStepper } from "./QuantityStepper.jsx";
import { Button } from "../actions/Button.jsx";
import { Link } from "../actions/Link.jsx";
import { VisuallyHidden } from "../primitives/VisuallyHidden.jsx";

/* Each size is a thumbnail, the type roles for the name and the lines under
   it, the Price size, and the gap between the columns. */
const SIZES = {
  sm: { thumb: "var(--dt-cart-thumb-size-sm)", name: "label-md", detail: "body-xs", price: "sm", gap: "var(--dt-space-inline-sm)", pad: "var(--dt-space-stack-sm)" },
  md: { thumb: "var(--dt-cart-thumb-size-md)", name: "label-lg", detail: "body-sm", price: "md", gap: "var(--dt-space-inline-md)", pad: "var(--dt-space-stack-md)" },
};

const role = (name) => ({
  fontFamily: `var(--dt-text-${name}-family)`,
  fontSize: `var(--dt-text-${name}-size)`,
  lineHeight: `var(--dt-text-${name}-line)`,
  fontWeight: `var(--dt-text-${name}-weight)`,
  letterSpacing: `var(--dt-text-${name}-tracking)`,
});

/* Stands in for a product image that has no src yet, like Image's empty
   frame but small enough for a cart line: an icon, no caption. */
function Placeholder() {
  return (
    <svg
      viewBox="0 0 24 24" aria-hidden="true" focusable="false"
      fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
      style={{ width: "var(--dt-size-icon-md)", height: "var(--dt-size-icon-md)" }}
    >
      <rect x="3" y="3" width="18" height="18" rx="2" />
      <circle cx="9" cy="9" r="2" />
      <path d="m21 15-5-5L5 21" />
    </svg>
  );
}

export function CartLine({
  name,
  href,
  image,
  details = [],
  price,
  compareAt,
  quantity,
  onQuantityChange,
  onRemove,
  currency = "USD",
  locale,
  lineTotal,
  maxQuantity,
  note,
  readOnly = false,
  size = "md",
  divider = false,
  style,
  ...rest
}) {
  const s = SIZES[size] || SIZES.md;
  const hasTotal = typeof lineTotal === "number";
  const money = { currency, locale };

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: image ? `${s.thumb} minmax(0, 1fr)` : "minmax(0, 1fr)",
        columnGap: s.gap,
        alignItems: "start",
        paddingBlock: divider ? s.pad : undefined,
        borderBlockEnd: divider ? "var(--dt-border-width-default) solid var(--dt-cart-line-divider)" : undefined,
        ...style,
      }}
      {...rest}
    >
      {image && (
        <span
          style={{
            position: "relative", display: "flex", alignItems: "center", justifyContent: "center",
            width: s.thumb, height: s.thumb, overflow: "hidden", boxSizing: "border-box",
            borderRadius: "var(--dt-cart-thumb-radius)",
            border: "var(--dt-border-width-default) solid var(--dt-cart-thumb-border)",
            background: "var(--dt-cart-thumb-bg)", color: "var(--dt-cart-thumb-fg)",
          }}
        >
          {image.src
            ? <img src={image.src} alt={image.alt || ""} loading="lazy" style={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }} />
            : <Placeholder />}
        </span>
      )}

      {/* The text and the price share a wrapping row. The text keeps at least
          three large controls' width; when the price no longer fits beside
          it, the price drops under it instead of squeezing the name. */}
      <div style={{ display: "flex", flexWrap: "wrap", alignItems: "flex-start", columnGap: s.gap, rowGap: "var(--dt-space-stack-xs)", minWidth: 0 }}>
        <div style={{ flex: "1 1 calc(var(--dt-size-control-lg) * 3)", display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)", minWidth: 0 }}>
          <span style={{ ...role(s.name), color: "var(--dt-text-primary)", overflowWrap: "anywhere" }}>
            {href ? <Link href={href} tone="inherit" underline="hover">{name}</Link> : name}
          </span>
          {details.length > 0 && (
            <span style={{ ...role(s.detail), color: "var(--dt-cart-detail-color)" }}>
              {details.map((d, i) => (
                <React.Fragment key={i}>
                  {i > 0 && <span aria-hidden="true"> · </span>}
                  {i > 0 && <VisuallyHidden>, </VisuallyHidden>}
                  <span style={{ whiteSpace: "nowrap" }}>{d}</span>
                </React.Fragment>
              ))}
            </span>
          )}
          {hasTotal && (
            <Price amount={price} compareAt={compareAt} unit="each" size="sm" {...money} />
          )}
          {note && (
            <span style={{ ...role(s.detail), color: "var(--dt-cart-note-color)" }}>{note}</span>
          )}
          {readOnly ? (
            <span style={{ ...role(s.detail), color: "var(--dt-cart-detail-color)", fontVariantNumeric: "tabular-nums" }}>
              <span aria-hidden="true">Qty {quantity}</span>
              <VisuallyHidden>{`Quantity ${quantity}`}</VisuallyHidden>
            </span>
          ) : (
            <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--dt-space-inline-xs)", marginBlockStart: "var(--dt-space-stack-2xs)" }}>
              <QuantityStepper
                size="sm"
                label={`Quantity, ${name}`}
                decreaseLabel={`Decrease quantity, ${name}`}
                increaseLabel={`Increase quantity, ${name}`}
                removeLabel={`Remove ${name}`}
                value={quantity}
                max={maxQuantity}
                onChange={onQuantityChange}
                onRemove={onRemove}
              />
              {onRemove && (
                <Button variant="ghost" size="sm" aria-label={`Remove ${name}`} onClick={onRemove}>
                  Remove
                </Button>
              )}
            </div>
          )}
        </div>

        <div style={{ display: "flex", justifyContent: "flex-end", textAlign: "end", marginInlineStart: "auto", maxWidth: "100%" }}>
          {hasTotal
            ? <Price amount={lineTotal} size={s.price} style={{ justifyContent: "flex-end" }} {...money} />
            : <Price amount={price} compareAt={compareAt} size={s.price} style={{ justifyContent: "flex-end" }} {...money} />}
        </div>
      </div>
    </div>
  );
}