Invoice Row
Data Display

Invoice Row

An expandable table-style invoice row that opens to a line-item mini-table via the grid-rows transition, with a synced chevron, status badge, and single-open group behavior.

Install

npx shadcn@latest add @paragon/invoice-row

invoice-row.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

export type InvoiceStatus = "paid" | "due" | "overdue" | "draft";

const statusClasses: Record<InvoiceStatus, string> = {
  paid: "bg-success/10 text-success dark:bg-success/15",
  due: "bg-primary/8 text-primary dark:bg-primary/15",
  overdue: "bg-destructive/10 text-destructive dark:bg-destructive/15",
  draft: "bg-muted text-muted-foreground",
};

const statusLabels: Record<InvoiceStatus, string> = {
  paid: "Paid",
  due: "Due",
  overdue: "Overdue",
  draft: "Draft",
};

interface InvoiceRowGroupContextValue {
  openId: string | null;
  setOpenId: (id: string | null) => void;
}

const InvoiceRowGroupContext =
  React.createContext<InvoiceRowGroupContextValue | null>(null);

interface InvoiceRevealValue {
  /** Rows have entered (or never animate). */
  shown: boolean;
  /** Reveal with no rise — opacity only. */
  reduced: boolean;
}

// Standalone rows (outside a group) render in place with no reveal.
const InvoiceRevealContext = React.createContext<InvoiceRevealValue>({
  shown: true,
  reduced: true,
});
const InvoiceIndexContext = React.createContext(0);
const InvoiceInGroupContext = React.createContext(false);

export interface InvoiceRowGroupProps extends React.ComponentProps<"div"> {
  /** Renders rows in place with no first-in-view reveal. */
  static?: boolean;
}

/**
 * Accordion behavior for invoice rows: at most one row is expanded at a
 * time — opening a row collapses its sibling. On first scroll into view the
 * rows rise in with a short stagger — once, never on re-scroll. Rows outside
 * a group manage their own state independently and skip the reveal.
 */
export function InvoiceRowGroup({
  static: isStatic = false,
  className,
  children,
  ...props
}: InvoiceRowGroupProps) {
  const [openId, setOpenId] = React.useState<string | null>(null);
  const context = React.useMemo(() => ({ openId, setOpenId }), [openId]);

  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
  const reducedMotion = useReducedMotion() ?? false;
  const reveal = React.useMemo<InvoiceRevealValue>(
    () => ({
      shown: isStatic || inView,
      reduced: reducedMotion || isStatic,
    }),
    [isStatic, inView, reducedMotion],
  );

  const items = React.Children.toArray(children);

  return (
    <InvoiceRowGroupContext.Provider value={context}>
      <InvoiceRevealContext.Provider value={reveal}>
        <InvoiceInGroupContext.Provider value={true}>
          <div
            ref={ref}
            data-slot="invoice-row-group"
            className={cn(
              "w-full divide-y divide-border overflow-hidden rounded-xl bg-card shadow-border",
              className,
            )}
            {...props}
          >
            {items.map((child, i) => (
              <InvoiceIndexContext.Provider key={i} value={i}>
                {child}
              </InvoiceIndexContext.Provider>
            ))}
          </div>
        </InvoiceInGroupContext.Provider>
      </InvoiceRevealContext.Provider>
    </InvoiceRowGroupContext.Provider>
  );
}

export interface InvoiceRowProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Invoice number, e.g. "INV-1042". */
  number: string;
  /** Client or account name. */
  client: string;
  /** Formatted amount — rendered tabular-nums, right-aligned. */
  amount: string;
  status?: InvoiceStatus;
  /** Issue or due date, shown next to the client on wide rows. */
  date?: string;
  /** Expanded detail — typically an InvoiceLineItems table. */
  children?: React.ReactNode;
  /** Disables the expand/collapse and chevron transitions. */
  static?: boolean;
}

/**
 * An expandable table-style row. The summary line is a real button; detail
 * expands via the `grid-template-rows: 0fr ↔ 1fr` transition so rapid
 * toggles retarget mid-flight. The chevron rotates in sync and the row
 * tints on hover. Status reads from the semantic tokens — paid/success,
 * overdue/destructive — never palette classes.
 */
export function InvoiceRow({
  number,
  client,
  amount,
  status = "due",
  date,
  static: isStatic = false,
  className,
  children,
  ...props
}: InvoiceRowProps) {
  const group = React.useContext(InvoiceRowGroupContext);
  const inGroup = React.useContext(InvoiceInGroupContext);
  const index = React.useContext(InvoiceIndexContext);
  const { shown, reduced } = React.useContext(InvoiceRevealContext);
  const [localOpen, setLocalOpen] = React.useState(false);
  const id = React.useId();
  const contentId = `${id}-detail`;

  const open = group ? group.openId === id : localOpen;
  const toggle = () => {
    if (group) group.setOpenId(open ? null : id);
    else setLocalOpen(!open);
  };

  // First-in-view reveal (group only): opacity + a small rise, staggered by
  // document order. A transition, so a mid-reveal scroll retargets cleanly.
  const revealState = !inGroup
    ? ""
    : reduced
      ? shown
        ? "opacity-100"
        : "opacity-0"
      : shown
        ? "translate-y-0 opacity-100"
        : "translate-y-1.5 opacity-0";

  return (
    <div
      data-slot="invoice-row"
      data-state={open ? "open" : "closed"}
      style={
        inGroup && shown
          ? { transitionDelay: `${Math.min(index, 8) * 50}ms` }
          : undefined
      }
      className={cn(
        "bg-card",
        inGroup &&
          "transition-[opacity,transform] duration-(--duration-base) ease-(--ease-out)",
        revealState,
        className,
      )}
      {...props}
    >
      <button
        type="button"
        aria-expanded={open}
        aria-controls={contentId}
        onClick={toggle}
        className={cn(
          "flex w-full items-center gap-3 px-4 py-3 text-left transition-colors duration-(--duration-fast) ease-out sm:gap-4",
          "hover:bg-accent/50",
          open && "bg-accent/30",
        )}
      >
        <ChevronDown
          aria-hidden
          className={cn(
            "size-4 shrink-0 text-muted-foreground",
            open && "rotate-180",
            !isStatic && "transition-[rotate] motion-reduce:transition-none",
            !isStatic &&
              (open
                ? "duration-(--duration-base) ease-out"
                : "duration-(--duration-quick) ease-(--ease-exit)"),
          )}
        />
        <span className="w-20 shrink-0 text-sm font-medium tabular-nums">
          {number}
        </span>
        <span
          className="min-w-0 flex-1 truncate text-sm text-muted-foreground"
          title={date ? `${client} · ${date}` : client}
        >
          {client}
          {date ? (
            <span className="hidden text-muted-foreground/70 sm:inline">
              {" "}
              · {date}
            </span>
          ) : null}
        </span>
        {/* Fixed-width status column so chips rag left but the amount column
            stays a true column across every row. */}
        <span className="flex w-18 shrink-0">
          <span
            className={cn(
              "rounded-full px-2 py-0.5 text-xs font-medium",
              statusClasses[status],
            )}
          >
            {statusLabels[status]}
          </span>
        </span>
        <span className="w-24 shrink-0 text-right text-sm font-medium tabular-nums">
          {amount}
        </span>
      </button>

      <div
        id={contentId}
        role="region"
        aria-label={`${number} line items`}
        className={cn(
          "grid",
          open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
          !isStatic &&
            "transition-[grid-template-rows] motion-reduce:transition-none",
          !isStatic &&
            (open
              ? "duration-(--duration-base) ease-out"
              : "duration-(--duration-quick) ease-(--ease-exit)"),
        )}
      >
        <div
          inert={!open}
          className={cn(
            "min-h-0 overflow-hidden",
            open ? "opacity-100" : "opacity-0",
            !isStatic &&
              (open
                ? "transition-opacity duration-(--duration-base) ease-out"
                : "transition-opacity duration-(--duration-quick) ease-(--ease-exit)"),
          )}
        >
          <div className="border-t border-border bg-secondary/40 px-4 py-3 pl-11 dark:bg-secondary/20">
            {children}
          </div>
        </div>
      </div>
    </div>
  );
}

export interface InvoiceLineItem {
  description: string;
  quantity: number;
  /** Formatted unit price, e.g. "$120.00". */
  unitPrice: string;
  /** Formatted line total. */
  total: string;
}

export interface InvoiceLineItemsProps extends React.ComponentProps<"table"> {
  items: InvoiceLineItem[];
}

/** The mini line-item table used inside an expanded InvoiceRow. */
export function InvoiceLineItems({
  items,
  className,
  ...props
}: InvoiceLineItemsProps) {
  return (
    <table
      data-slot="invoice-line-items"
      className={cn("w-full text-sm", className)}
      {...props}
    >
      <thead>
        <tr className="text-left text-xs text-muted-foreground">
          <th scope="col" className="pb-2 font-medium">
            Description
          </th>
          <th scope="col" className="pb-2 text-right font-medium">
            Qty
          </th>
          <th
            scope="col"
            className="hidden pb-2 text-right font-medium sm:table-cell"
          >
            Unit
          </th>
          <th scope="col" className="pb-2 text-right font-medium">
            Amount
          </th>
        </tr>
      </thead>
      <tbody className="divide-y divide-border/60">
        {items.map((item) => (
          <tr key={item.description}>
            <td className="py-1.5 pr-4">{item.description}</td>
            <td className="py-1.5 text-right tabular-nums">{item.quantity}</td>
            <td className="hidden py-1.5 text-right tabular-nums sm:table-cell">
              {item.unitPrice}
            </td>
            <td className="py-1.5 text-right font-medium tabular-nums">
              {item.total}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}