BasketBar

The full-width bar at the foot of a phone ordering screen that says how many things are in the basket and what they come to, and opens it: "3 · View basket · $42.50". It renders nothing while the basket is empty.

Commerce BasketBar.jsx BasketBar.d.ts BasketBar.md

Playground

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

Install and import

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

npm install @dovetail-ds/react react react-dom
import { BasketBar } 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 full-width bar at the foot of a phone ordering screen that says how many things are in the basket and what they come to, and opens it: "3 · View basket · $42.50". It renders nothing while the basket is empty.

Use it when

  • A phone menu or store page needs a way into the basket that stays in reach while the reader scrolls.

Don't use it when

  • It is a wide web page with a basket panel beside the menu. Show the OrderSummary there.
  • It is the checkout's pay button. Use Button with the amount in its label.
  • It is a header shortcut. Use an IconButton with a badge count in its label.

Example

<BasketBar
  count={3}
  total={42.5}
  locale="en-US"
  onClick={() => setScreen("basket")}
  style={{ position: "sticky", bottom: "var(--dt-space-inset-sm)" }}
/>

Placing it

It has no position of its own, so it can sit wherever the screen needs it. Pass style:

  • In an AppShell with a BottomNav: pass both in bottomNav (<><BasketBar … /><BottomNav … /></>), with an inline margin on the bar. The shell's footer is sticky, so the bar floats above the nav while the menu scrolls behind it.
  • In any other scrolling column: position: "sticky" and a bottom.
  • Over a whole page: position: "fixed" with insetInline and bottom, and pad the page's end so the last dish is not covered.

Leave it out of the tree, or pass count={0}, when the basket is empty.

Variants

PropWhat it is for
labelThe words in the middle. "View basket" by default; "View order" or "Checkout" where that is the next step.
itemsLabelThe count in words, for the accessible name and translations.
disabledA store that has just closed: the bar stays, greyed, so the basket is not lost.

Composition

Pair it with MenuBlock or MenuItems. Your basket holds the count and computes the total; nothing is added up here.

Tokens

None of its own. It reads Button's primary tokens (--dt-button-primary-bg, -bg-hover, -bg-active, -fg, -border, --dt-button-radius, --dt-button-transition, and the disabled pair), so it follows every retheme of Button. It is --dt-size-control-lg tall with --dt-elevation-3. The count sits in a pill tinted from the text colour; the total is a Price in the bar's text colour.

Accessibility

  • A native <button>, named "View basket, 3 items, $42.50": the visible label comes first, so voice control finds it by what it says.
  • The visible count, label and price are hidden from assistive technology, since the name already says them.
  • It is at least a touch target tall and keeps the focus ring. Its colour change on press is instant under reduced motion.

Content

  • Label is a verb phrase, sentence case, two or three words.
  • The total is what the basket costs before delivery; say so on the basket screen, not in the bar.

Props

The typed contract, from BasketBar.d.ts.

import * as React from "react";

/** The floating "View basket · 3 items · $42.50" bar at the foot of a phone ordering screen. Renders nothing while the basket is empty. */
export interface BasketBarProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> {
  /** How many items are in the basket. At 0 (or below) the bar renders nothing. */
  count: number;
  /** What the basket comes to, in major units, as your basket computed it. Shown with Price. */
  total: number;
  /** ISO 4217 currency code for the total. @default "USD" */
  currency?: string;
  /** BCP 47 locale for the total. Defaults to the runtime's; set it when server rendering. */
  locale?: string;
  /** Opens the basket. */
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
  /** The bar's words, and the start of its accessible name. @default "View basket" */
  label?: string;
  /** The count in words, for the accessible name. @default (n) => n === 1 ? "1 item" : `${n} items` */
  itemsLabel?: (count: number) => string;
  /** Turns the bar off, for a store that has just closed. */
  disabled?: boolean;
  /**
   * The bar is full width and has no position of its own. Place it with style: position "sticky" and
   * bottom inside a scrolling column, or "fixed" with inset, above a BottomNav.
   */
  style?: React.CSSProperties;
}

export declare function BasketBar(props: BasketBarProps): React.JSX.Element | null;

Tokens it reads

Every custom property BasketBar.jsx resolves, read from the source: 10 component, 159 semantic, 1 primitive. 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.
  • primitive Raw values. A component reading one directly is a deliberate exception.
TokenTierDeclared as
--dt-button-disabled-bgcomponentvar(--dt-surface-action-disabled)
--dt-button-disabled-fgcomponentvar(--dt-text-on-action-disabled)
--dt-button-primary-bgcomponentvar(--dt-surface-action)
--dt-button-primary-bg-activecomponentvar(--dt-surface-action-active)
--dt-button-primary-bg-hovercomponentvar(--dt-surface-action-hover)
--dt-button-primary-bordercomponentvar(--dt-color-transparent)
--dt-button-primary-fgcomponentvar(--dt-text-on-action)
--dt-button-radiuscomponentvar(--dt-radius-pill)
--dt-button-transitioncomponentvar(--dt-motion-micro)
--dt-price-colorcomponentvar(--dt-text-primary)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-elevation-3semanticvar(--dt-shadow-raw-3)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-size-control-lgsemanticvar(--dt-dim-12)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-size-touch-targetsemanticvar(--dt-dim-11)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-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)
--dt-font-weight-semiboldprimitive600

Source

React only, no dependencies, every value a token.

import React from "react";
import { Price } from "./Price.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)`,
});

const defaultItemsLabel = (n) => (n === 1 ? "1 item" : `${n} items`);

function format(amount, currency, locale) {
  try {
    return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
  } catch (err) {
    return String(amount);
  }
}

/* The bar at the foot of a phone ordering screen: how many things are in the
   basket, what they come to, and the way in. It renders nothing while the
   basket is empty. Where it sits (sticky, fixed, in a footer) is the page's
   choice, through style. */
export function BasketBar({
  count,
  total,
  currency = "USD",
  locale,
  onClick,
  label = "View basket",
  itemsLabel = defaultItemsLabel,
  disabled = false,
  style,
  ...rest
}) {
  const [hover, setHover] = React.useState(false);
  const [down, setDown] = React.useState(false);
  if (!(count > 0)) return null;

  const bg = disabled
    ? "var(--dt-button-disabled-bg)"
    : down
      ? "var(--dt-button-primary-bg-active)"
      : hover
        ? "var(--dt-button-primary-bg-hover)"
        : "var(--dt-button-primary-bg)";
  const fg = disabled ? "var(--dt-button-disabled-fg)" : "var(--dt-button-primary-fg)";

  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-label={`${label}, ${itemsLabel(count)}, ${format(total, currency, locale)}`}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => { setHover(false); setDown(false); }}
      onPointerDown={() => setDown(true)}
      onPointerUp={() => setDown(false)}
      style={{
        appearance: "none", boxSizing: "border-box", margin: 0,
        display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)",
        width: "100%", minHeight: "max(var(--dt-size-control-lg), var(--dt-size-touch-target))",
        paddingBlock: 0, paddingInlineStart: "var(--dt-space-inset-xs)",
        paddingInlineEnd: "var(--dt-space-inset-md)",
        border: "var(--dt-border-width-default) solid var(--dt-button-primary-border)",
        borderRadius: "var(--dt-button-radius)",
        background: bg, color: fg,
        boxShadow: "var(--dt-elevation-3)",
        cursor: disabled ? "not-allowed" : "pointer",
        textAlign: "start",
        transition: "background var(--dt-button-transition)",
        "--dt-price-color": "currentColor",
        ...style,
      }}
      {...rest}
    >
      <span
        aria-hidden="true"
        style={{
          ...role("label-md"), flex: "none", boxSizing: "border-box",
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          minWidth: "var(--dt-size-control-sm)", height: "var(--dt-size-control-sm)",
          padding: "0 var(--dt-space-inset-xs)", borderRadius: "var(--dt-radius-pill)",
          background: "color-mix(in oklab, currentColor 18%, transparent)",
          fontWeight: "var(--dt-font-weight-semibold)", fontVariantNumeric: "tabular-nums",
        }}
      >
        {count}
      </span>
      <span aria-hidden="true" style={{ ...role("label-lg"), flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: "var(--dt-font-weight-semibold)" }}>
        {label}
      </span>
      <span aria-hidden="true" style={{ flex: "none", fontWeight: "var(--dt-font-weight-semibold)" }}>
        <Price amount={total} currency={currency} locale={locale} size="md" />
      </span>
    </button>
  );
}