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.
npx shadcn@latest add @paragon/invoice-row"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>
);
}