Fintech & Payments

Transaction Row

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.

Install

npx shadcn@latest add @paragon/transaction-row

transaction-row.tsx

"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>
  );
}