Fintech & Payments

Expense Card

A Ramp-style expense row with receipt thumbnail, category, amount, and status — with a policy-flag strip that expands beneath when a spend rule trips.

Install

npx shadcn@latest add @paragon/expense-card

expense-card.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  AlertTriangle,
  Car,
  Check,
  Coffee,
  Plane,
  Receipt,
  UtensilsCrossed,
} from "lucide-react";
import { cn } from "@/lib/utils";

type Category = "meals" | "travel" | "transport" | "software" | "other";

const CATEGORY_META: Record<
  Category,
  { label: string; icon: React.ComponentType<{ className?: string }> }
> = {
  meals: { label: "Meals", icon: UtensilsCrossed },
  travel: { label: "Travel", icon: Plane },
  transport: { label: "Transport", icon: Car },
  software: { label: "Software", icon: Coffee },
  other: { label: "Other", icon: Receipt },
};

export type ExpenseStatus = "approved" | "pending" | "flagged";

export interface ExpenseCardProps extends React.ComponentProps<"div"> {
  merchant: string;
  amount: number;
  currency?: string;
  date: string;
  category: Category;
  status?: ExpenseStatus;
  /** Reason shown when a policy rule is tripped. */
  policyFlag?: string;
  /** True when a receipt image is attached. */
  hasReceipt?: boolean;
  cardLast4?: string;
}

/**
 * A Ramp-style expense row: receipt thumbnail on the left (a placeholder
 * ledger glyph when none is attached), merchant + category in the middle,
 * amount and status on the right. When a policy rule trips, a flag strip
 * expands beneath with the reason — grid-rows 0fr→1fr so no height jank.
 * Hovering lifts the receipt tile; reduced motion keeps color, drops the lift.
 */
export function ExpenseCard({
  merchant,
  amount,
  currency = "USD",
  date,
  category,
  status = "pending",
  policyFlag,
  hasReceipt = false,
  cardLast4,
  className,
  ...props
}: ExpenseCardProps) {
  const reduced = useReducedMotion();
  const meta = CATEGORY_META[category];
  const Icon = meta.icon;

  const money = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency,
  }).format(amount);

  const statusStyles: Record<ExpenseStatus, string> = {
    approved: "bg-success/10 text-success",
    pending: "bg-muted text-muted-foreground",
    flagged: "bg-warning/15 text-warning",
  };
  const statusLabel: Record<ExpenseStatus, string> = {
    approved: "Approved",
    pending: "Pending",
    flagged: "Needs review",
  };

  return (
    <div
      className={cn(
        "w-full max-w-md overflow-hidden rounded-xl bg-card shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-center gap-3 p-3">
        <div
          className={cn(
            "relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-lg border",
            hasReceipt
              ? "bg-gradient-to-br from-secondary to-muted"
              : "bg-muted",
          )}
        >
          {hasReceipt ? (
            <Receipt className="size-5 text-muted-foreground" aria-hidden />
          ) : (
            <Receipt
              className="size-5 text-muted-foreground/40"
              aria-hidden
            />
          )}
          {hasReceipt && (
            <span
              aria-hidden
              className="absolute right-0 bottom-0 flex size-4 items-center justify-center rounded-tl-md bg-success text-success-foreground"
            >
              <Check className="size-3" />
            </span>
          )}
        </div>

        <div className="min-w-0 flex-1">
          <div className="flex items-center gap-1.5">
            <p className="truncate text-sm font-medium">{merchant}</p>
          </div>
          <div className="mt-0.5 flex items-center gap-1.5 text-[13px] text-muted-foreground">
            <Icon className="size-3.5 shrink-0" aria-hidden />
            <span>{meta.label}</span>
            <span aria-hidden>·</span>
            <span className="tabular-nums">{date}</span>
            {cardLast4 && (
              <>
                <span aria-hidden>·</span>
                <span className="tabular-nums">•••• {cardLast4}</span>
              </>
            )}
          </div>
        </div>

        <div className="flex flex-col items-end gap-1">
          <span className="text-sm font-semibold tabular-nums">{money}</span>
          <span
            className={cn(
              "rounded-full px-2 py-0.5 text-[11px] font-medium",
              statusStyles[status],
            )}
          >
            {statusLabel[status]}
          </span>
        </div>
      </div>

      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
          policyFlag ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
        )}
      >
        <div className="overflow-hidden">
          <AnimatePresence initial={false}>
            {policyFlag && (
              <motion.div
                initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
                className="flex items-center gap-2 border-t bg-warning/10 px-3 py-2 text-[13px] text-warning"
              >
                <AlertTriangle className="size-3.5 shrink-0" aria-hidden />
                <span>{policyFlag}</span>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </div>
  );
}