OrderSummary

The money breakdown of a cart or checkout: subtotal, shipping, tax and discounts as a description list, an emphasised total, an optional free-shipping progress bar, and a footer for the checkout button. It is presentational: it prints the figures it is given and adds nothing up, so your cart or your commerce platform stays the source of truth.

Commerce OrderSummary.jsx OrderSummary.d.ts OrderSummary.md

Playground

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

Install and import

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

npm install @dovetail-ds/react react react-dom
import { OrderSummary } 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

The money breakdown of a cart or checkout: subtotal, shipping, tax and discounts as a description list, an emphasised total, an optional free-shipping progress bar, and a footer for the checkout button. It is presentational: it prints the figures it is given and adds nothing up, so your cart or your commerce platform stays the source of truth.

Use it when

  • A cart page or checkout needs the totals beside the items.
  • An order confirmation or receipt repeats what was charged.
  • A cart drawer shows a compact subtotal with the checkout button (pass fewer lines).

Don't use it when

  • You are comparing plans or prices side by side. Use Table or plan cards.
  • The numbers are not money for one order (analytics, balances). Use Stat or Table.
  • You need the component to calculate tax, shipping or discounts. Compute them in your app or platform and pass the results.

Example

<OrderSummary
  currency="USD"
  locale="en-US"
  lines={[
    { label: "Subtotal", amount: 138 },
    { label: "Shipping", amount: 0, hint: "Standard, 3โ€“5 days" },
    { label: "Discount (SUMMER10)", amount: 13.8, kind: "discount" },
    { label: "Tax", amount: 9.94, kind: "muted", hint: "Estimated" },
  ]}
  total={{ amount: 134.14 }}
  freeShippingProgress={{ current: 138, threshold: 150 }}
  footer={
    <>
      <PromoCode value={code} onChange={setCode} onApply={apply} />
      <Button fullWidth size="lg">Check out</Button>
    </>
  }
/>

Variants

PropWhat it is for
lines[].kind="default"An ordinary figure: subtotal, shipping, tax.
lines[].kind="discount"Money off. The amount always shows as a negative ("-$13.80"), in the discount colour.
lines[].kind="muted"An estimate or a figure still to come: "Tax, calculated at next step". Label and figure in tertiary text.
lines[].hintA smaller line under the label: the shipping method, "Estimated".
amount: 0Shows "Free" (from Price), e.g. for free shipping.
totalEmphasised under a rule: label-lg label, Price at size="lg". label defaults to "Total".
freeShippingProgressA Progress bar and "You're $12.00 away from free shipping", or "You've got free shipping" (success tone) once current reaches threshold. The remaining amount is the one subtraction the component does, for the message.
footerThe checkout Button, a PromoCode, payment marks, trust copy.
title / headingLevelThe heading, "Order summary" by default, as an h2 unless you pick another level to fit the page outline.

Composition

A panel on its own (it draws its own border and padding). It sits in the side column of a cart or checkout page, under the lines in a Drawer, or on an order confirmation. It renders Price for every figure, Progress for free shipping and Heading for its title; put PromoCode and a Button in footer.

Tokens

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

  • --dt-summary-bg (--dt-surface-base), --dt-summary-border (--dt-border-subtle), --dt-summary-radius (--dt-radius-container), --dt-summary-padding (--dt-space-inset-lg): the panel.
  • --dt-summary-divider (--dt-border-subtle): the rule above the total.
  • --dt-summary-label-color (--dt-text-secondary), --dt-summary-value-color (--dt-text-primary): a line's label and figure.
  • --dt-summary-muted-color (--dt-text-tertiary): a muted line. --dt-summary-hint-color (--dt-text-tertiary): hints.
  • --dt-summary-discount-color (--dt-text-success): a discount's figure. It deliberately does not reuse --dt-price-sale-color (danger text): in a summary a discount is money saved, and red next to a promo field reads as an error. The minus sign carries the meaning, so colour is never the only signal.
  • --dt-summary-total-color (--dt-text-primary): the total's label and figure.

Figures reach Price by re-pointing --dt-price-color on each figure, so a line's colour changes without changing how it is formatted. Figures are right-aligned with tabular numerals.

Accessibility

  • The panel is a <section> named by its heading. The breakdown is a <dl>: each label is a <dt> and its figure a <dd>, so a screen reader pairs them, and the total is the last pair.
  • A discount's figure is formatted as a negative ("-$13.80"), so it is read as money off without relying on colour.
  • The free-shipping bar is a role="progressbar" named by its message, with aria-valuenow and aria-valuemax in money.
  • Figures come from Intl.NumberFormat for the currency and locale; pass locale when server rendering so the server and the browser print the same.

Content

  • Line labels are sentence-case nouns: "Subtotal", "Shipping", "Estimated tax". Put a discount's code in its label: "Discount (SUMMER10)".
  • hint is a short fragment with no full stop: "Standard, 3โ€“5 days", "Calculated at next step".
  • Keep the total's label "Total" unless the page needs more: "Total due today", "Total (incl. VAT)".

Props

The typed contract, from OrderSummary.d.ts.

import * as React from "react";

/** One row of the money breakdown. */
export interface OrderSummaryLine {
  /** What the amount is: "Subtotal", "Shipping", "Discount (SUMMER10)". */
  label: string;
  /** Amount in major units. A discount shows as negative whatever its sign; 0 shows as "Free". */
  amount: number;
  /** discount shows the amount as a negative in the discount colour; muted is for estimates and pending figures. @default "default" */
  kind?: "default" | "discount" | "muted";
  /** A smaller line under the label: "Standard, 3โ€“5 days", "Calculated at next step". */
  hint?: string;
}

/** The money breakdown of a cart or checkout: lines, a total, an optional free-shipping bar and a footer for actions. */
export interface OrderSummaryProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "title"> {
  /** The rows above the total, in order. Pass what your cart computed; nothing is added up here. */
  lines: OrderSummaryLine[];
  /** The total, emphasised under a rule. */
  total: {
    /** @default "Total" */
    label?: string;
    /** Amount in major units. */
    amount: number;
  };
  /** ISO 4217 currency code. @default "USD" */
  currency?: string;
  /** BCP 47 locale. Defaults to the runtime's; set it when server rendering. */
  locale?: string;
  /** Under the total: the checkout Button, a PromoCode, trust copy. */
  footer?: React.ReactNode;
  /** Heading of the panel, also its accessible name. @default "Order summary" */
  title?: string;
  /** Level of the title's heading element. @default 2 */
  headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
  /** Shows a progress bar and "You're $12.00 away from free shipping", or "You've got free shipping" once current reaches threshold. */
  freeShippingProgress?: {
    /** The amount that counts toward the threshold, usually the subtotal. */
    current: number;
    /** The amount that unlocks free shipping. */
    threshold: number;
  };
}

export declare function OrderSummary(props: OrderSummaryProps): React.JSX.Element;

Tokens it reads

Every custom property OrderSummary.jsx resolves, read from the source: 12 component, 156 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-price-colorcomponentvar(--dt-text-primary)
--dt-summary-bgcomponentvar(--dt-surface-base)
--dt-summary-bordercomponentvar(--dt-border-subtle)
--dt-summary-discount-colorcomponentvar(--dt-text-success)
--dt-summary-dividercomponentvar(--dt-border-subtle)
--dt-summary-hint-colorcomponentvar(--dt-text-tertiary)
--dt-summary-label-colorcomponentvar(--dt-text-secondary)
--dt-summary-muted-colorcomponentvar(--dt-text-tertiary)
--dt-summary-paddingcomponentvar(--dt-space-inset-lg)
--dt-summary-radiuscomponentvar(--dt-radius-container)
--dt-summary-total-colorcomponentvar(--dt-text-primary)
--dt-summary-value-colorcomponentvar(--dt-text-primary)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-space-inline-mdsemanticvar(--dt-dim-4)
--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 { Progress } from "../feedback/Progress.jsx";
import { Heading } from "../typography/Heading.jsx";

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)`,
});

/* Label and figure colours per kind. A figure's colour reaches Price by
   re-pointing --dt-price-color on its wrapper, so Price keeps its own
   formatting and only the colour changes. */
const KINDS = {
  default: { label: "var(--dt-summary-label-color)", value: "var(--dt-summary-value-color)" },
  discount: { label: "var(--dt-summary-label-color)", value: "var(--dt-summary-discount-color)" },
  muted: { label: "var(--dt-summary-muted-color)", value: "var(--dt-summary-muted-color)" },
};

/* The amount in the free-shipping message, formatted the way Price formats. */
function useMoney(currency, locale) {
  return React.useMemo(() => {
    try {
      const nf = new Intl.NumberFormat(locale, { style: "currency", currency });
      return (n) => nf.format(n);
    } catch (err) {
      return (n) => `${n} ${currency}`;
    }
  }, [currency, locale]);
}

export function OrderSummary({
  lines = [],
  total,
  currency = "USD",
  locale,
  footer,
  title = "Order summary",
  headingLevel = 2,
  freeShippingProgress,
  style,
  ...rest
}) {
  const titleId = React.useId();
  const money = useMoney(currency, locale);
  const fs = freeShippingProgress;
  const reached = fs ? fs.current >= fs.threshold : false;
  const message = fs
    ? reached
      ? "You've got free shipping"
      : `You're ${money(Math.max(0, fs.threshold - fs.current))} away from free shipping`
    : null;

  const row = { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "var(--dt-space-inline-md)" };
  const dd = { margin: 0, flex: "none", textAlign: "end", fontVariantNumeric: "tabular-nums" };

  return (
    <section
      aria-labelledby={titleId}
      style={{
        display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-md)",
        padding: "var(--dt-summary-padding)", boxSizing: "border-box", minWidth: 0,
        background: "var(--dt-summary-bg)", color: "var(--dt-text-primary)",
        border: "var(--dt-border-width-default) solid var(--dt-summary-border)",
        borderRadius: "var(--dt-summary-radius)",
        ...style,
      }}
      {...rest}
    >
      <Heading id={titleId} level={headingLevel} size="heading-xs" tone="primary">{title}</Heading>

      {fs && (
        <Progress
          size="sm"
          tone={reached ? "success" : "primary"}
          value={Math.min(Math.max(fs.current, 0), fs.threshold)}
          max={fs.threshold}
          label={message}
        />
      )}

      <dl style={{ margin: 0, display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-xs)" }}>
        {lines.map((line, i) => {
          const kind = KINDS[line.kind] || KINDS.default;
          const discount = line.kind === "discount";
          return (
            <div key={line.label + i} style={row}>
              <dt style={{ minWidth: 0, display: "flex", flexDirection: "column" }}>
                <span style={{ ...role("body-sm"), color: kind.label }}>{line.label}</span>
                {line.hint && <span style={{ ...role("body-xs"), color: "var(--dt-summary-hint-color)" }}>{line.hint}</span>}
              </dt>
              <dd style={dd}>
                <Price
                  amount={discount ? -Math.abs(line.amount) : line.amount}
                  size="sm"
                  currency={currency}
                  locale={locale}
                  style={{ "--dt-price-color": kind.value }}
                />
              </dd>
            </div>
          );
        })}
        <div
          style={{
            ...row,
            paddingBlockStart: "var(--dt-space-stack-sm)",
            marginBlockStart: lines.length ? "var(--dt-space-stack-2xs)" : 0,
            borderBlockStart: lines.length ? "var(--dt-border-width-default) solid var(--dt-summary-divider)" : undefined,
          }}
        >
          <dt style={{ ...role("label-lg"), color: "var(--dt-summary-total-color)" }}>{total.label || "Total"}</dt>
          <dd style={dd}>
            <Price
              amount={total.amount}
              size="lg"
              currency={currency}
              locale={locale}
              style={{ "--dt-price-color": "var(--dt-summary-total-color)" }}
            />
          </dd>
        </div>
      </dl>

      {footer && <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-sm)" }}>{footer}</div>}
    </section>
  );
}