Transaction List

Chronological credit/debit history with locale-aware currency formatting and a pinned subscription row.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/transaction-list.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import {
  type ComponentPropsWithoutRef,
  forwardRef,
  type ReactNode,
} from "react";

import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";

const CENTS_PER_UNIT = 100;
const DEFAULT_LOCALE = "en-US";
const DEFAULT_CURRENCY = "USD";

const CURRENCY_FORMATTER_CACHE = new Map<string, Intl.NumberFormat>();
function getCurrencyFormatter(
  locale: string,
  currency: string,
): Intl.NumberFormat {
  const key = `${locale}|${currency}`;
  let formatter = CURRENCY_FORMATTER_CACHE.get(key);
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, {
      currency,
      style: "currency",
    });
    CURRENCY_FORMATTER_CACHE.set(key, formatter);
  }
  return formatter;
}

const DATE_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getTransactionDateFormatter(locale: string): Intl.DateTimeFormat {
  let formatter = DATE_FORMATTER_CACHE.get(locale);
  if (!formatter) {
    formatter = new Intl.DateTimeFormat(locale, {
      day: "numeric",
      month: "short",
      year: "numeric",
    });
    DATE_FORMATTER_CACHE.set(locale, formatter);
  }
  return formatter;
}

/**
 * Transaction type for {@link TransactionListItem}.
 *
 * @public
 */
export type TransactionType = "credit" | "debit" | "initial" | "refund";

/**
 * Renewal interval for {@link TransactionListSubscriptionRow}.
 *
 * @public
 */
export type SubscriptionInterval = "day" | "month" | "week" | "year";

/**
 * Subscription status for {@link TransactionListSubscriptionRow}.
 *
 * @public
 */
export type SubscriptionStatus =
  | "active"
  | "canceled"
  | "past_due"
  | "trialing";

/**
 * Localizable strings.
 *
 * @public
 */
export type TransactionListLabels = {
  /** Caption for the active subscription badge. Defaults to `"Active"`. */
  active?: string;
  /** Caption for the canceled subscription badge. Defaults to `"Canceled"`. */
  canceled?: string;
  /** Caption for the past-due subscription badge. Defaults to `"Past due"`. */
  pastDue?: string;
  /** Renewal label prefix. Defaults to `"Renews"`. */
  renews?: string;
  /** Caption for the trial subscription badge. Defaults to `"Trial"`. */
  trialing?: string;
};

const DEFAULT_LABELS = {
  active: "Active",
  canceled: "Canceled",
  pastDue: "Past due",
  renews: "Renews",
  trialing: "Trial",
} as const satisfies Required<TransactionListLabels>;

/**
 * One transaction entry.
 *
 * @public
 */
export type Transaction = {
  /** Amount in minor units (cents). Always positive — `type` decides the sign. */
  amountCents: number;
  /** Unix timestamp (ms) for the transaction. */
  createdAt: number;
  /** Free-form description. */
  description: ReactNode;
  /** Stable identifier. */
  id: string;
  /** Optional secondary line (e.g. `"VAT incl."`). */
  meta?: ReactNode;
  /** Transaction type. */
  type: TransactionType;
};

const SIGN_BY_TYPE: Record<TransactionType, "negative" | "positive"> = {
  credit: "positive",
  debit: "negative",
  initial: "positive",
  refund: "positive",
};

const AMOUNT_CLASS: Record<"negative" | "positive", string> = {
  negative: "text-destructive",
  positive: "text-emerald-600 dark:text-emerald-400",
};

const STATUS_VARIANT: Record<
  SubscriptionStatus,
  "default" | "destructive" | "outline" | "secondary"
> = {
  active: "default",
  canceled: "secondary",
  past_due: "destructive",
  trialing: "outline",
};

const STATUS_LABEL_KEY: Record<
  SubscriptionStatus,
  keyof Required<TransactionListLabels>
> = {
  active: "active",
  canceled: "canceled",
  past_due: "pastDue",
  trialing: "trialing",
};

const INTERVAL_LABEL: Record<SubscriptionInterval, string> = {
  day: "day",
  month: "mo",
  week: "wk",
  year: "yr",
};

/**
 * Format an amount in minor units as a localized currency string. Use the
 * locale + currency from {@link TransactionListProps} to drive the output.
 *
 * @public
 */
export function formatTransactionAmount(
  amountCents: number,
  options: {
    currency?: string;
    locale?: string;
  } = {},
): string {
  const { currency = DEFAULT_CURRENCY, locale = DEFAULT_LOCALE } = options;
  return getCurrencyFormatter(locale, currency).format(
    amountCents / CENTS_PER_UNIT,
  );
}

/**
 * Format a Unix timestamp (ms) as a short locale-aware date.
 *
 * @public
 */
export function formatTransactionDate(
  timestamp: number,
  locale: string = DEFAULT_LOCALE,
): string {
  return getTransactionDateFormatter(locale).format(new Date(timestamp));
}

/**
 * Props for {@link TransactionList}.
 *
 * @public
 */
export type TransactionListProps = {
  /** Currency code (ISO 4217). Defaults to `"USD"`. */
  currency?: string;
  /** Caption shown when the list is empty and no pinned children exist. */
  emptyMessage?: ReactNode;
  /** Localizable strings. */
  labels?: TransactionListLabels;
  /** BCP-47 locale tag. Defaults to `"en-US"`. */
  locale?: string;
  /** Transaction array (rendered after pinned children). */
  transactions: Transaction[];
} & ComponentPropsWithoutRef<"div">;

type TransactionListComponent = ReturnType<
  typeof forwardRef<HTMLDivElement, TransactionListProps>
> & {
  Pinned: typeof TransactionListPinned;
  SubscriptionRow: typeof TransactionListSubscriptionRow;
};

const TransactionListBase = forwardRef<HTMLDivElement, TransactionListProps>(
  (props, ref) => {
    const {
      children,
      className,
      currency,
      emptyMessage,
      labels,
      locale,
      transactions,
      ...rest
    } = props;
    const isEmpty = transactions.length === 0;
    const hasPinned = Boolean(children);
    return (
      <div
        className={cn(
          "flex w-full flex-col gap-2 rounded-2xl border bg-background p-3",
          className,
        )}
        ref={ref}
        {...rest}
      >
        {children}
        {isEmpty && !hasPinned && emptyMessage ? (
          <p className="py-6 text-center text-sm text-muted-foreground">
            {emptyMessage}
          </p>
        ) : null}
        {isEmpty ? null : (
          <ul className="flex flex-col gap-1.5">
            {transactions.map((transaction) => (
              <TransactionListItem
                currency={currency}
                key={transaction.id}
                labels={labels}
                locale={locale}
                transaction={transaction}
              />
            ))}
          </ul>
        )}
      </div>
    );
  },
);
TransactionListBase.displayName = "TransactionList";

/**
 * Props for {@link TransactionListPinned}.
 *
 * @public
 */
export type TransactionListPinnedProps = ComponentPropsWithoutRef<"div">;

/**
 * Wrapper that renders pinned content (typically the active subscription
 * row) above the main transaction list.
 *
 * @public
 */
export const TransactionListPinned = forwardRef<
  HTMLDivElement,
  TransactionListPinnedProps
>(({ children, className, ...rest }, ref) => (
  <div className={cn("flex flex-col gap-1.5", className)} ref={ref} {...rest}>
    {children}
  </div>
));
TransactionListPinned.displayName = "TransactionList.Pinned";

type TransactionListItemProps = {
  currency?: string;
  labels?: TransactionListLabels;
  locale?: string;
  transaction: Transaction;
};

function TransactionListItem({
  currency,
  locale,
  transaction,
}: TransactionListItemProps): ReactNode {
  const sign = SIGN_BY_TYPE[transaction.type];
  const formatted = formatTransactionAmount(transaction.amountCents, {
    currency,
    locale,
  });
  const display = sign === "negative" ? `-${formatted}` : `+${formatted}`;
  const ariaLabel =
    typeof transaction.description === "string"
      ? `${sign === "negative" ? "Debit" : "Credit"} ${display} for ${transaction.description}`
      : undefined;
  return (
    <li
      className="flex items-start justify-between gap-3 rounded-lg border border-border bg-muted/20 px-3 py-2"
      data-transaction-type={transaction.type}
    >
      <div className="flex min-w-0 flex-col gap-0.5">
        <p className="truncate text-sm font-medium text-foreground">
          {transaction.description}
        </p>
        <p className="text-xs text-muted-foreground">
          {formatTransactionDate(transaction.createdAt, locale)}
          {transaction.meta ? <span> · {transaction.meta}</span> : null}
        </p>
      </div>
      <span
        aria-label={ariaLabel}
        className={cn(
          "shrink-0 font-mono text-sm font-semibold",
          AMOUNT_CLASS[sign],
        )}
      >
        {display}
      </span>
    </li>
  );
}

/**
 * Props for {@link TransactionListSubscriptionRow}.
 *
 * @public
 */
export type TransactionListSubscriptionRowProps = {
  /** Subscription amount per interval, in minor units. */
  amountCents: number;
  /** Currency code (ISO 4217). Defaults to `"USD"`. */
  currency?: string;
  /** Renewal interval. */
  interval: SubscriptionInterval;
  /** Localizable strings. */
  labels?: TransactionListLabels;
  /** BCP-47 locale tag. Defaults to `"en-US"`. */
  locale?: string;
  /** Optional secondary metadata (e.g. `"VAT incl."`). */
  meta?: ReactNode;
  /** Plan display name. */
  plan: ReactNode;
  /** Optional renewal timestamp (ms). */
  renewsAt?: number;
  /** Subscription status. */
  status: SubscriptionStatus;
} & ComponentPropsWithoutRef<"div">;

type SubscriptionMetaProps = {
  locale?: string;
  meta?: ReactNode;
  renewsAt?: number;
  renewsLabel: string;
};

function SubscriptionMeta({
  locale,
  meta,
  renewsAt,
  renewsLabel,
}: SubscriptionMetaProps): ReactNode {
  if (renewsAt === undefined && !meta) return null;
  return (
    <p className="text-xs text-muted-foreground">
      {renewsAt === undefined
        ? null
        : `${renewsLabel} ${formatTransactionDate(renewsAt, locale)}`}
      {renewsAt !== undefined && meta ? <span> · {meta}</span> : null}
      {renewsAt === undefined && meta ? meta : null}
    </p>
  );
}

/**
 * Active-subscription row for the pinned section. Renders a green-border
 * card with plan name, status badge, amount/interval, and optional
 * renewal date.
 *
 * @public
 */
export const TransactionListSubscriptionRow = forwardRef<
  HTMLDivElement,
  TransactionListSubscriptionRowProps
>((props, ref) => {
  const {
    amountCents,
    className,
    currency,
    interval,
    labels,
    locale,
    meta,
    plan,
    renewsAt,
    status,
    ...rest
  } = props;
  const resolvedLabels = { ...DEFAULT_LABELS, ...labels };
  const formatted = formatTransactionAmount(amountCents, { currency, locale });
  const intervalSuffix = INTERVAL_LABEL[interval];
  const isActive = status === "active";

  return (
    <div
      className={cn(
        "flex items-start justify-between gap-3 rounded-lg border px-3 py-2",
        isActive
          ? "border-emerald-500/40 bg-emerald-500/5"
          : "border-border bg-muted/20",
        className,
      )}
      data-status={status}
      ref={ref}
      {...rest}
    >
      <div className="flex min-w-0 flex-col gap-1">
        <div className="flex flex-wrap items-center gap-2">
          <p className="text-sm font-semibold text-foreground">{plan}</p>
          <Badge variant={STATUS_VARIANT[status]}>
            {resolvedLabels[STATUS_LABEL_KEY[status]]}
          </Badge>
        </div>
        <SubscriptionMeta
          locale={locale}
          meta={meta}
          renewsAt={renewsAt}
          renewsLabel={resolvedLabels.renews}
        />
      </div>
      <span className="shrink-0 font-mono text-sm font-semibold text-foreground">
        {formatted}/{intervalSuffix}
      </span>
    </div>
  );
});
TransactionListSubscriptionRow.displayName = "TransactionList.SubscriptionRow";

/**
 * Chronological list of financial transactions with credit/debit color
 * coding, locale-aware currency / date formatting, and an optional pinned
 * section for the active subscription.
 *
 * @example
 * ```tsx
 * <TransactionList
 *   transactions={transactions}
 *   currency="EUR"
 *   locale="en-IE"
 *   emptyMessage="No transactions yet"
 * >
 *   <TransactionList.Pinned>
 *     <TransactionList.SubscriptionRow
 *       plan="AI OS Pro"
 *       status="active"
 *       amountCents={1200}
 *       renewsAt={1713139200000}
 *       interval="month"
 *     />
 *   </TransactionList.Pinned>
 * </TransactionList>
 * ```
 *
 * @public
 */
export const TransactionList = TransactionListBase as TransactionListComponent;
TransactionList.Pinned = TransactionListPinned;
TransactionList.SubscriptionRow = TransactionListSubscriptionRow;
typescript

Dependencies

  • @vllnt/ui@^0.2.1