All patterns

Invoice Line Expand

A charge unfolds into the lines that make it up, with its own amount pinned in place.

commerceminimalpremiuminteraction · finite · starter · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

334 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Invoice Line Expand
 *
 * A charge unfolds into the lines that make it up. The charge's own
 * amount never moves — it stays pinned to the right of its row while the
 * breakdown opens underneath, so the figure being explained is still
 * where the eye left it. Rows open independently, because reconciling an
 * invoice means holding two charges open at once.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the ledger reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultOpenIds`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type InvoiceLineExpandProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Charge ids open on first render. */
  defaultOpenIds?: string[];
  /** Notified with the ids currently open. */
  onOpenChange?: (openIds: string[]) => void;
};

type VariantConfig = {
  /** Seconds for the panel height tween. */
  height: number;
  /** Seconds for the contents to fade. */
  fade: number;
  /** Seconds between itemised lines arriving. */
  stagger: number;
  chevron: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: height is tweened and short — it is a genuine size
// change, so it gets a curve rather than a spring, and a spring here
// would bounce the rows below a billing figure. The chevron is the only
// sprung element, at a damping ratio at or above 0.8.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Snappy, for a dense billing history someone is scanning.
  subtle: {
    height: 0.18,
    fade: 0.14,
    stagger: 0.018,
    chevron: { type: "spring", stiffness: 620, damping: 44 },
  },
  // The lines arrive as the panel finishes opening. All-purpose.
  default: {
    height: 0.26,
    fade: 0.2,
    stagger: 0.035,
    chevron: { type: "spring", stiffness: 460, damping: 36 },
  },
  // A slower unfold for a single invoice being read closely.
  playful: {
    height: 0.34,
    fade: 0.26,
    stagger: 0.05,
    chevron: { type: "spring", stiffness: 380, damping: 33 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CHARGES = [
  {
    id: "platform",
    label: "Platform subscription",
    period: "1 Aug – 31 Aug",
    amount: "$240.00",
    lines: [
      { id: "base", label: "Base plan", detail: "1 × $180.00", amount: "$180.00" },
      { id: "seats", label: "Additional seats", detail: "4 × $15.00", amount: "$60.00" },
    ],
  },
  {
    id: "usage",
    label: "Usage above plan",
    period: "Metered",
    amount: "$86.40",
    lines: [
      { id: "calls", label: "API requests", detail: "2,160 × $0.03", amount: "$64.80" },
      { id: "storage", label: "Stored objects", detail: "540 × $0.04", amount: "$21.60" },
    ],
  },
  {
    id: "support",
    label: "Priority support",
    period: "Retainer",
    amount: "$150.00",
    lines: [
      { id: "retainer", label: "Response guarantee", detail: "1 × $150.00", amount: "$150.00" },
    ],
  },
] as const;

const TOTAL = "$476.40";

export default function InvoiceLineExpand({
  variant = "default",
  defaultOpenIds = [],
  onOpenChange,
}: InvoiceLineExpandProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [openIds, setOpenIds] = useState<string[]>(defaultOpenIds);

  const toggle = (id: string) => {
    const next = openIds.includes(id)
      ? openIds.filter((entry) => entry !== id)
      : [...openIds, id];
    setOpenIds(next);
    onOpenChange?.(next);
  };

  const heightTween = {
    duration: reduceMotion ? 0 : cfg.height,
    ease: "easeOut" as const,
  };

  return (
    <div
      style={{
        width: 342,
        padding: "6px 16px 14px",
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        fontFamily: "inherit",
        boxSizing: "border-box",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          justifyContent: "space-between",
          gap: 10,
          padding: "12px 0 4px",
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>Invoice 2026-084</span>
        <span style={{ fontSize: 11, opacity: 0.5 }}>Due 31 Aug</span>
      </div>

      {CHARGES.map((charge) => {
        const isOpen = openIds.includes(charge.id);
        const panelId = `invoice-panel-${charge.id}`;
        return (
          <div key={charge.id} style={{ borderTop: `1px solid ${tone(10)}` }}>
            <button
              type="button"
              onClick={() => toggle(charge.id)}
              aria-expanded={isOpen}
              aria-controls={panelId}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                width: "100%",
                padding: "11px 0",
                textAlign: "left",
                border: "none",
                background: "transparent",
                color: "inherit",
                fontFamily: "inherit",
                cursor: "pointer",
              }}
            >
              <motion.span
                aria-hidden
                initial={false}
                animate={{ rotate: isOpen ? 90 : 0 }}
                transition={
                  reduceMotion ? { duration: 0.12 } : cfg.chevron
                }
                style={{ lineHeight: 0, opacity: 0.55, flexShrink: 0 }}
              >
                <svg
                  width="12"
                  height="12"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.1"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="m7 4 6 6-6 6" />
                </svg>
              </motion.span>
              <span style={{ flex: 1, minWidth: 0 }}>
                <span
                  style={{ display: "block", fontSize: 12.5, fontWeight: 620 }}
                >
                  {charge.label}
                </span>
                <span
                  style={{ display: "block", fontSize: 10.5, opacity: 0.5 }}
                >
                  {charge.period}
                </span>
              </span>
              {/* The amount under explanation stays exactly where it was.
                  Moving it while its own breakdown opens would make the
                  reader re-find the number they were already looking at. */}
              <span
                style={{
                  fontSize: 12.5,
                  fontWeight: 620,
                  fontVariantNumeric: "tabular-nums",
                }}
              >
                {charge.amount}
              </span>
            </button>

            <AnimatePresence initial={false}>
              {isOpen && (
                <motion.div
                  key="panel"
                  id={panelId}
                  role="region"
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{
                    height: 0,
                    opacity: 0,
                    transition: {
                      height: heightTween,
                      opacity: { duration: reduceMotion ? 0 : cfg.fade * 0.6 },
                    },
                  }}
                  transition={{
                    height: heightTween,
                    opacity: {
                      duration: reduceMotion ? 0.1 : cfg.fade,
                      ease: "easeOut",
                      delay: reduceMotion ? 0 : cfg.height * 0.3,
                    },
                  }}
                  // The clip is what keeps the type honest: the lines are
                  // laid out at their final width from frame one and
                  // simply revealed, never squeezed by the container.
                  style={{ overflow: "hidden" }}
                >
                  <div
                    style={{
                      margin: "0 0 11px 22px",
                      padding: "9px 11px",
                      borderRadius: 11,
                      background: tone(5),
                      border: `1px solid ${tone(9)}`,
                      display: "grid",
                      gap: 7,
                    }}
                  >
                    {charge.lines.map((line, index) => (
                      <motion.div
                        key={line.id}
                        initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{
                          duration: reduceMotion ? 0.1 : cfg.fade,
                          ease: "easeOut",
                          delay: reduceMotion
                            ? 0
                            : cfg.height * 0.35 + index * cfg.stagger,
                        }}
                        style={{
                          display: "flex",
                          alignItems: "baseline",
                          gap: 8,
                          fontSize: 11.5,
                        }}
                      >
                        <span style={{ flex: 1, minWidth: 0 }}>
                          {line.label}
                        </span>
                        <span
                          style={{
                            opacity: 0.5,
                            fontVariantNumeric: "tabular-nums",
                          }}
                        >
                          {line.detail}
                        </span>
                        <span
                          style={{
                            width: 62,
                            textAlign: "right",
                            fontWeight: 600,
                            fontVariantNumeric: "tabular-nums",
                          }}
                        >
                          {line.amount}
                        </span>
                      </motion.div>
                    ))}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}

      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "baseline",
          paddingTop: 12,
          borderTop: `1px solid ${tone(14)}`,
          fontSize: 13.5,
          fontWeight: 680,
        }}
      >
        <span>Total due</span>
        <span style={{ fontVariantNumeric: "tabular-nums" }}>{TOTAL}</span>
      </div>
    </div>
  );
}

About this pattern

Reconciling a bill, treated as a motion problem. The charge's amount never moves: it stays pinned to the right of its row while the breakdown opens underneath, so the figure being explained is still where the eye left it. Charges open independently rather than one at a time, because comparing two charges means holding both open. Height is tweened and short — a genuine size change deserves a curve, and a spring here would bounce every billing figure below it — while the itemised lines fade in a hair apart as the panel finishes opening.

Invoice breakdownBilling statement detailSubscription charge itemisationExpense report line

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    Invoice charges expand into their metered components without the totals shifting.

Related patterns