A feed row where a pending amount shimmers until it settles to solid when cleared, with credit/debit color coding and an optional expandable detail.
npx shadcn@latest add @paragon/transaction-row"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TransactionRowProps
extends Omit<React.ComponentProps<"div">, "title"> {
/** Merchant or counterparty. */
merchant: string;
/** Secondary line, e.g. category or method. */
detail?: string;
/** Signed minor-neutral amount string, already formatted, e.g. "$42.00". */
amount: string;
/** Credit (money in) tints positive; debit tints as plain foreground. */
direction?: "credit" | "debit";
/** Pending rows shimmer until they settle; cleared rows render solid. */
status?: "pending" | "cleared";
/** Leading glyph (emoji or icon). */
icon?: React.ReactNode;
/** Timestamp line shown on the right, under the amount. */
timestamp?: string;
/** Expanded detail content; presence enables the disclosure. */
children?: React.ReactNode;
}
/**
* A feed row for a money movement. While `pending` it runs a quiet sweep
* shimmer over the amount; the moment it flips to `cleared` the shimmer stops
* and the figure settles to solid with a brief crossfade — the visual language
* of "this posted." Credits read in the success color, debits in foreground.
* If children are provided the row becomes a disclosure that opens to detail
* via the grid-rows transition.
*/
export function TransactionRow({
merchant,
detail,
amount,
direction = "debit",
status = "cleared",
icon,
timestamp,
children,
className,
...props
}: TransactionRowProps) {
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const expandable = Boolean(children);
// Track the pending→cleared edge to fire the one-shot settle crossfade.
const prevStatus = React.useRef(status);
const [settling, setSettling] = React.useState(false);
React.useEffect(() => {
if (prevStatus.current === "pending" && status === "cleared" && !reduced) {
setSettling(true);
}
prevStatus.current = status;
}, [status, reduced]);
const pending = status === "pending";
const amountNode = (
<span
onAnimationEnd={(e) => {
if (e.animationName === "paragon-txn-settle") setSettling(false);
}}
className={cn(
"relative inline-block text-sm font-medium tabular-nums",
direction === "credit" ? "text-success" : "text-foreground",
pending && "paragon-txn-shimmer",
settling && "paragon-txn-settle",
)}
>
{direction === "credit" ? "+" : ""}
{amount}
</span>
);
const Wrapper = expandable ? "button" : "div";
return (
<div
className={cn(
"rounded-lg transition-colors duration-(--duration-fast)",
className,
)}
{...props}
>
<style href="paragon-transaction-row" precedence="paragon">{`
@keyframes paragon-txn-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.paragon-txn-shimmer {
background-image: linear-gradient(
100deg,
currentColor 40%,
color-mix(in oklab, currentColor 35%, transparent) 50%,
currentColor 60%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: paragon-txn-shimmer 1.6s linear infinite;
}
@keyframes paragon-txn-settle {
from { filter: blur(2px); opacity: 0.5; }
to { filter: blur(0); opacity: 1; }
}
.paragon-txn-settle { animation: paragon-txn-settle 260ms var(--ease-out) both; }
@media (prefers-reduced-motion: reduce) {
.paragon-txn-shimmer {
animation: none;
background: none;
-webkit-text-fill-color: currentColor;
}
.paragon-txn-settle { animation: none; }
}
`}</style>
<Wrapper
type={expandable ? "button" : undefined}
onClick={expandable ? () => setOpen((o) => !o) : undefined}
aria-expanded={expandable ? open : undefined}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left",
expandable &&
"cursor-pointer outline-none transition-colors duration-(--duration-fast) hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{icon !== undefined && (
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-sm">
{icon}
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{merchant}</span>
{pending && (
<span className="shrink-0 rounded-full bg-warning/15 px-1.5 py-0.5 text-[11px] font-medium text-warning-foreground dark:bg-warning/20">
Pending
</span>
)}
</span>
{detail && (
<span className="truncate text-[13px] text-muted-foreground">
{detail}
</span>
)}
</span>
<span className="flex shrink-0 flex-col items-end">
{amountNode}
{timestamp && (
<span className="text-[12px] tabular-nums text-muted-foreground">
{timestamp}
</span>
)}
</span>
{expandable && (
<ChevronDown
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out",
open && "rotate-180",
)}
/>
)}
</Wrapper>
{expandable && (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="px-3 pb-3 pl-15 text-[13px] text-muted-foreground">
{children}
</div>
</div>
</div>
)}
</div>
);
}