Skip to content

Transaction History

Grouped by date; rows expand to show from, to, fee and hash.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/transaction-history.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react tailwind-merge viem
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/web3/format.ts
    import { formatUnits, getAddress, isAddress, parseUnits } from "viem";
    
    /**
     * Formatting for on-chain values. Amounts are always `bigint` plus `decimals`: nothing here
     * converts a balance to a float, so large or precise values never lose digits.
     */
    
    /** Decimals used for USD values and prices across the web3 components (like Chainlink feeds). */
    export const USD_DECIMALS = 8;
    
    const COMPACT_UNITS = [
      { suffix: "T", exponent: 12 },
      { suffix: "B", exponent: 9 },
      { suffix: "M", exponent: 6 },
      { suffix: "K", exponent: 3 },
    ] as const;
    
    function groupThousands(integer: string) {
      return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
    
    function compact(value: bigint, decimals: number): string | null {
      const base = BigInt(10) ** BigInt(decimals);
      for (const { suffix, exponent } of COMPACT_UNITS) {
        const unit = base * BigInt(10) ** BigInt(exponent);
        if (value < unit) continue;
        const tenths = (value * BigInt(10)) / unit;
        const whole = tenths / BigInt(10);
        const fraction = tenths % BigInt(10);
        return `${groupThousands(whole.toString())}${fraction && whole < BigInt(100) ? `.${fraction}` : ""}${suffix}`;
      }
      return null;
    }
    
    export interface FormatAmountOptions {
      /** Most fraction digits shown; extra digits are cut, never rounded up. @default 4 */
      maxFractionDigits?: number;
      /** Fraction digits always shown (padded with zeros). @default 0 */
      minFractionDigits?: number;
      /** Abbreviate values of 1,000 and up: 1.2K, 3.4M, 5B. @default false */
      compact?: boolean;
      /** Prefix positive values with "+". @default false */
      signed?: boolean;
    }
    
    /**
     * Formats a token amount: `formatAmount(parseUnits("1.23456789", 18), 18)` → "1.2345".
     * Digits past `maxFractionDigits` are truncated so a balance is never overstated, and a tiny
     * non-zero amount shows as "<0.0001" instead of "0".
     */
    export function formatAmount(value: bigint, decimals: number, options: FormatAmountOptions = {}) {
      const { maxFractionDigits = 4, minFractionDigits = 0, compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
    
      const short = useCompact ? compact(abs, decimals) : null;
      if (short) return sign + short;
    
      const [integer, fraction = ""] = formatUnits(abs, decimals).split(".");
      let digits = fraction.slice(0, maxFractionDigits).replace(/0+$/, "");
      if (digits.length < minFractionDigits) digits = digits.padEnd(minFractionDigits, "0");
    
      if (abs > BigInt(0) && integer === "0" && !/[1-9]/.test(digits)) {
        return `${sign}<0.${"0".repeat(Math.max(0, maxFractionDigits - 1))}1`;
      }
      return `${sign}${groupThousands(integer)}${digits ? `.${digits}` : ""}`;
    }
    
    /** USD value of a token amount, as a bigint with `USD_DECIMALS` decimals. `price` uses the same decimals. */
    export function toUsd(amount: bigint, tokenDecimals: number, price: bigint) {
      return (amount * price) / BigInt(10) ** BigInt(tokenDecimals);
    }
    
    /** Formats a USD bigint (default `USD_DECIMALS`): "$1,234.56", or "$1.2M" with `compact`. */
    export function formatUsd(value: bigint, decimals = USD_DECIMALS, options: Omit<FormatAmountOptions, "maxFractionDigits"> = {}) {
      const { compact: useCompact = false, signed = false } = options;
      const negative = value < BigInt(0);
      const abs = negative ? -value : value;
      const sign = negative ? "-" : signed && abs > BigInt(0) ? "+" : "";
      // Under a cent, keep up to 6 significant fraction digits so token prices like $0.000042 stay readable.
      const tiny = abs > BigInt(0) && abs < BigInt(10) ** BigInt(Math.max(0, decimals - 2));
      const body = formatAmount(abs, decimals, {
        compact: useCompact,
        maxFractionDigits: tiny ? Math.min(decimals, 8) : 2,
        minFractionDigits: tiny ? 0 : 2,
      });
      return `${sign}$${body}`;
    }
    
    /** Formats a ratio as a percentage: `formatPercent(0.0123)` → "1.23%". */
    export function formatPercent(ratio: number, options: { fractionDigits?: number; signed?: boolean } = {}) {
      const { fractionDigits = 2, signed = false } = options;
      if (!Number.isFinite(ratio)) return "—";
      const text = `${(Math.abs(ratio) * 100).toFixed(fractionDigits)}%`;
      if (ratio < 0) return `-${text}`;
      return signed && ratio > 0 ? `+${text}` : text;
    }
    
    /** Formats basis points (1 bp = 0.01%): `formatBps(BigInt(50))` → "0.5%". */
    export function formatBps(bps: bigint | number) {
      const value = BigInt(bps);
      const abs = value < BigInt(0) ? -value : value;
      const fraction = abs % BigInt(100);
      return `${value < BigInt(0) ? "-" : ""}${abs / BigInt(100)}${fraction ? `.${fraction.toString().padStart(2, "0").replace(/0$/, "")}` : ""}%`;
    }
    
    /** Compact display for plain counts and non-token numbers: 1200 → "1.2K". */
    export function compactNumber(value: number | bigint) {
      return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value);
    }
    
    /** EIP-55 checksummed address, or the input unchanged when it isn't a valid address. */
    export function checksumAddress(address: string) {
      return isAddress(address, { strict: false }) ? getAddress(address) : address;
    }
    
    /** "0x1234…abcd": checksummed, with `start` characters (including 0x) and `end` characters kept. */
    export function truncateAddress(address: string, { start = 6, end = 4 }: { start?: number; end?: number } = {}) {
      const value = checksumAddress(address);
      if (value.length <= start + end + 1) return value;
      return `${value.slice(0, start)}…${value.slice(-end)}`;
    }
    
    /**
     * Parses user input into a bigint amount. Accepts digits with one "." (commas are ignored),
     * cuts fraction digits past `decimals`, and returns null for anything that isn't a number.
     */
    export function parseAmount(input: string, decimals: number): bigint | null {
      const text = input.replace(/,/g, "").trim();
      if (!/^\d*\.?\d*$/.test(text) || text === "" || text === ".") return null;
      const [integer, fraction = ""] = text.split(".");
      return parseUnits(`${integer || "0"}.${fraction.slice(0, decimals) || "0"}`, decimals);
    }
    
    /** A percentage of an amount, in whole percent: `percentOf(balance, 25)`. Rounds down. */
    export function percentOf(amount: bigint, percent: number) {
      return (amount * BigInt(Math.round(percent * 100))) / BigInt(10_000);
    }
    lib/web3/types.ts
    /** Shapes the web3 components accept. Your own token and chain objects only need these fields. */
    
    export interface Token {
      symbol: string;
      name?: string;
      /** Token decimals: 18 for ETH, 6 for USDC. Amounts are bigint in these units. */
      decimals: number;
      address?: string;
      /** Logo URL. Without one, a generated avatar (colored circle and symbol) is shown. */
      iconUrl?: string;
      /** Avatar color for the generated icon. Picked from the symbol when omitted. */
      color?: string;
      /** USD price as a bigint with USD_DECIMALS (8) decimals. */
      price?: bigint;
    }
    
    export interface Chain {
      id: number;
      name: string;
      shortName?: string;
      iconUrl?: string;
      color?: string;
      /** Block explorer base URL without a trailing slash, e.g. https://etherscan.io */
      explorerUrl?: string;
      nativeSymbol?: string;
    }
    
    export interface WalletOption {
      id: string;
      name: string;
      /** Logo URL. Without one, a generated squircle with the wallet's initial is shown. */
      iconUrl?: string;
      color?: string;
      /** Shows a "Recent" badge. */
      recent?: boolean;
      /** The wallet is detected in this browser. */
      installed?: boolean;
      /** Connects by scanning a QR code with a phone instead of a browser extension. */
      qr?: boolean;
      /** Short helper line under the name. */
      description?: string;
    }
    
    /** Lifecycle of anything the user signs: every state gets its own visual. */
    export type TxStatus = "idle" | "awaiting-signature" | "pending" | "confirmed" | "failed";
    
    /** Explorer URL for an address or transaction, or undefined without an explorer. */
    export function explorerLink(explorerUrl: string | undefined, kind: "address" | "tx", value: string) {
      return explorerUrl ? `${explorerUrl.replace(/\/$/, "")}/${kind}/${value}` : undefined;
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/transaction-history/transaction-history.tsx
    "use client";
    
    import { useId, useState, type ComponentProps } from "react";
    import {
      ArrowDownLeft,
      ArrowLeftRight,
      ArrowUpRight,
      Check,
      ChevronDown,
      Gift,
      Layers,
      Route,
      ShieldCheck,
      X,
      type LucideIcon,
    } from "lucide-react";
    import { AddressPill } from "@/components/ui/address-pill/address-pill";
    import { CopyButton } from "@/components/ui/copy-button/copy-button";
    import { cn } from "@/lib/utils";
    import { formatAmount, truncateAddress } from "@/lib/web3/format";
    import { explorerLink } from "@/lib/web3/types";
    
    export type TransactionType = "swap" | "send" | "receive" | "approve" | "bridge" | "stake" | "claim";
    export type TransactionStatus = "pending" | "confirmed" | "failed";
    
    export interface TransactionRecord {
      hash: string;
      type: TransactionType;
      status: TransactionStatus;
      /** ISO 8601 timestamp. */
      timestamp: string;
      from: string;
      to: string;
      /** One-line description, e.g. "Swap 0.5 ETH for USDC". */
      summary: string;
      /** Main value moved; "in" shows as +, "out" as −. */
      amount?: { symbol: string; value: bigint; decimals: number; direction: "in" | "out" };
      /** Network fee in the native token's smallest unit. */
      fee: bigint;
    }
    
    export interface TransactionHistoryProps extends Omit<ComponentProps<"div">, "children"> {
      /** Transactions, newest first. */
      transactions: TransactionRecord[];
      /** Block explorer base URL for hash and address links. */
      explorerUrl?: string;
      /** Native token symbol for fees. @default "ETH" */
      nativeSymbol?: string;
      /** Native token decimals for fees. @default 18 */
      nativeDecimals?: number;
      /** Locale for dates and times. @default "en-US" */
      locale?: string;
      /** Time zone for grouping and times. Fixed by default so server and client agree. @default "UTC" */
      timeZone?: string;
      /** Show skeleton rows. @default false */
      loading?: boolean;
      /** Message when there are no transactions. @default "No transactions yet." */
      emptyMessage?: string;
    }
    
    const TYPE_ICON: Record<TransactionType, LucideIcon> = {
      swap: ArrowLeftRight,
      send: ArrowUpRight,
      receive: ArrowDownLeft,
      approve: ShieldCheck,
      bridge: Route,
      stake: Layers,
      claim: Gift,
    };
    
    function StatusBadge({ status }: { status: TransactionStatus }) {
      if (status === "pending") {
        return (
          <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
            <svg className="size-3 animate-spin motion-reduce:animate-pulse" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="3" />
              <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
            </svg>
            Pending
          </span>
        );
      }
      if (status === "failed") {
        return (
          <span className="inline-flex items-center gap-1 text-xs text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]">
            <X className="size-3" aria-hidden="true" strokeWidth={3} />
            Failed
          </span>
        );
      }
      return (
        <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
          <Check className="size-3 text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" aria-hidden="true" strokeWidth={3} />
          <span className="sr-only">Confirmed</span>
        </span>
      );
    }
    
    function Row({
      tx,
      explorerUrl,
      nativeSymbol,
      nativeDecimals,
      time,
    }: {
      tx: TransactionRecord;
      explorerUrl?: string;
      nativeSymbol: string;
      nativeDecimals: number;
      time: string;
    }) {
      const [open, setOpen] = useState(false);
      const id = useId();
      const Icon = TYPE_ICON[tx.type];
      const href = explorerLink(explorerUrl, "tx", tx.hash);
    
      return (
        <li>
          <button
            type="button"
            aria-expanded={open}
            aria-controls={id}
            onClick={() => setOpen(!open)}
            className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left outline-none transition-colors hover:bg-surface focus-visible:bg-surface focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
          >
            <span
              className={cn(
                "grid size-9 shrink-0 place-items-center rounded-full",
                tx.status === "failed" ? "bg-destructive/8 text-[color-mix(in_oklab,var(--destructive)_80%,var(--foreground))]" : "bg-muted text-foreground",
              )}
            >
              <Icon className="size-4" aria-hidden="true" />
            </span>
            <span className="min-w-0 flex-1">
              <span className={cn("block truncate text-sm font-medium", tx.status === "failed" ? "text-muted-foreground line-through decoration-border" : "text-foreground")}>
                {tx.summary}
              </span>
              <span className="flex items-center gap-2 font-mono text-xs text-muted-foreground tabular-nums">
                {time}
                <StatusBadge status={tx.status} />
              </span>
            </span>
            {tx.amount ? (
              <span
                className={cn(
                  "font-mono text-sm tabular-nums",
                  tx.amount.direction === "in" ? "text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))]" : "text-foreground",
                )}
              >
                {tx.amount.direction === "in" ? "+" : "−"}
                {formatAmount(tx.amount.value, tx.amount.decimals)} {tx.amount.symbol}
              </span>
            ) : null}
            <ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-aria-expanded:rotate-180" aria-hidden="true" />
          </button>
          <div
            id={id}
            className={cn(
              "grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none",
              open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
            )}
            inert={!open}
          >
            <div className="min-h-0 overflow-hidden">
              <dl className="mx-4 mb-3 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2.5 rounded-xl bg-surface p-3 text-[13px]">
                <dt className="text-muted-foreground">From</dt>
                <dd className="justify-self-end">
                  <AddressPill address={tx.from} size="sm" explorerUrl={explorerUrl} />
                </dd>
                <dt className="text-muted-foreground">To</dt>
                <dd className="justify-self-end">
                  <AddressPill address={tx.to} size="sm" explorerUrl={explorerUrl} />
                </dd>
                <dt className="text-muted-foreground">Network fee</dt>
                <dd className="justify-self-end font-mono text-foreground tabular-nums">
                  {formatAmount(tx.fee, nativeDecimals, { maxFractionDigits: 6 })} {nativeSymbol}
                </dd>
                <dt className="text-muted-foreground">Hash</dt>
                <dd className="flex items-center justify-self-end">
                  <span className="pr-1 font-mono text-foreground tabular-nums" title={tx.hash}>
                    {truncateAddress(tx.hash, { start: 8, end: 6 })}
                  </span>
                  <CopyButton value={tx.hash} variant="ghost" size="sm" aria-label="Copy transaction hash" className="size-7 rounded-full text-muted-foreground" />
                  {href ? (
                    <a
                      href={href}
                      target="_blank"
                      rel="noreferrer"
                      aria-label="View transaction on block explorer"
                      className="relative inline-flex size-7 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors after:absolute after:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      <ArrowUpRight className="size-4" aria-hidden="true" />
                    </a>
                  ) : null}
                </dd>
              </dl>
            </div>
          </div>
        </li>
      );
    }
    
    export function TransactionHistory({
      transactions,
      explorerUrl,
      nativeSymbol = "ETH",
      nativeDecimals = 18,
      locale = "en-US",
      timeZone = "UTC",
      loading = false,
      emptyMessage = "No transactions yet.",
      className,
      ...props
    }: TransactionHistoryProps) {
      const dayFormat = new Intl.DateTimeFormat(locale, { weekday: "short", month: "short", day: "numeric", year: "numeric", timeZone });
      const keyFormat = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone });
      const timeFormat = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false, timeZone });
    
      const groups: { key: string; label: string; items: TransactionRecord[] }[] = [];
      for (const tx of transactions) {
        const date = new Date(tx.timestamp);
        const key = keyFormat.format(date);
        const group = groups.at(-1);
        if (group?.key === key) group.items.push(tx);
        else groups.push({ key, label: dayFormat.format(date), items: [tx] });
      }
    
      return (
        <div className={cn("w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-background shadow-sm", className)} {...props}>
          {loading ? (
            <ul aria-busy="true" aria-label="Loading transactions" className="divide-y divide-border">
              {[0, 1, 2, 3].map((i) => (
                <li key={i} className="flex items-center gap-3 px-4 py-3.5">
                  <span className="size-9 animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
                  <span className="flex-1 space-y-1.5">
                    <span className="block h-3 w-40 animate-pulse rounded bg-muted motion-reduce:animate-none" />
                    <span className="block h-2.5 w-20 animate-pulse rounded bg-muted motion-reduce:animate-none" />
                  </span>
                </li>
              ))}
            </ul>
          ) : groups.length === 0 ? (
            <p className="px-4 py-12 text-center text-sm text-muted-foreground">{emptyMessage}</p>
          ) : (
            groups.map((group) => (
              <div key={group.key} role="group" aria-label={group.label}>
                <p aria-hidden="true" className="sticky top-0 z-10 border-b border-border bg-background/90 px-4 py-2 text-xs font-medium text-muted-foreground backdrop-blur">
                  {group.label}
                </p>
                <ul className="divide-y divide-border">
                  {group.items.map((tx) => (
                    <Row
                      key={tx.hash}
                      tx={tx}
                      explorerUrl={explorerUrl}
                      nativeSymbol={nativeSymbol}
                      nativeDecimals={nativeDecimals}
                      time={timeFormat.format(new Date(tx.timestamp))}
                    />
                  ))}
                </ul>
              </div>
            ))
          )}
        </div>
      );
    }

Usage

import { TransactionHistory } from "@/components/ui/transaction-history/transaction-history";

<TransactionHistory
  transactions={txs} // { hash, type, status, timestamp, from, to, summary, amount?, fee }[]
  explorerUrl="https://etherscan.io"
  timeZone="UTC"
/>

API reference

PropTypeDefaultDescription
transactions*TransactionRecord[]–Transactions, newest first.
explorerUrlstring–Block explorer base URL for hash and address links.
nativeSymbolstring"ETH"Native token symbol for fees.
nativeDecimalsnumber18Native token decimals for fees.
localestring"en-US"Locale for dates and times.
timeZonestring"UTC"Time zone for grouping and times. Fixed by default so server and client agree.
loadingbooleanfalseShow skeleton rows.
emptyMessagestring"No transactions yet."Message when there are no transactions.

Also accepts every native <div> attribute, including ref and className.

  • Dates are grouped and formatted in a fixed time zone (UTC by default) so server and client render the same output. Pass your user's time zone if you render on the client only.
  • From and To reuse Address Pill, and the hash row reuses Copy Button, so copy and explorer behave the same everywhere.

Accessibility

  • Each row is a button with aria-expanded and aria-controls; its details panel is inert while collapsed, so Tab skips hidden links.
  • Status is never color-only: pending shows a spinner and the word "Pending", failed shows "Failed", and confirmed has screen-reader text.
  • Day groups are labelled groups, and their sticky labels stay visible while the list scrolls.
  • Details open with the grid-rows height trick. Reduced motion makes it instant, and the pending spinner turns into a soft pulse.