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 { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

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

const statusClasses: Record<InvoiceStatus, string> = {
  paid: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
  due: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
  overdue: "bg-red-500/10 text-red-700 dark:text-red-400",
  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);

export interface InvoiceRowGroupProps extends React.ComponentProps<"div"> {}

/**
 * Accordion behavior for invoice rows: at most one row is expanded at a
 * time — opening a row collapses its sibling. Rows outside a group manage
 * their own state independently.
 */
export function InvoiceRowGroup({
  className,
  children,
  ...props
}: InvoiceRowGroupProps) {
  const [openId, setOpenId] = React.useState<string | null>(null);
  const context = React.useMemo(() => ({ openId, setOpenId }), [openId]);

  return (
    <InvoiceRowGroupContext.Provider value={context}>
      <div
        data-slot="invoice-row-group"
        className={cn(
          "w-full divide-y divide-border overflow-hidden rounded-xl bg-card shadow-border",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </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.
 */
export function InvoiceRow({
  number,
  client,
  amount,
  status = "due",
  date,
  static: isStatic = false,
  className,
  children,
  ...props
}: InvoiceRowProps) {
  const group = React.useContext(InvoiceRowGroupContext);
  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);
  };

  return (
    <div
      data-slot="invoice-row"
      data-state={open ? "open" : "closed"}
      className={cn("bg-card", 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">
          {client}
          {date ? (
            <span className="hidden text-muted-foreground/70 sm:inline">
              {" "}
              · {date}
            </span>
          ) : null}
        </span>
        <span
          className={cn(
            "shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
            statusClasses[status],
          )}
        >
          {statusLabels[status]}
        </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 className="pb-2 font-medium">Description</th>
          <th className="pb-2 text-right font-medium">Qty</th>
          <th className="hidden pb-2 text-right font-medium sm:table-cell">
            Unit
          </th>
          <th 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>
  );
}