Web3: Wallet & Identityv0.2.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react tailwind-merge viemAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/web3/format.tsimport { 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; }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); }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
| Prop | Type | Default | Description |
|---|---|---|---|
transactions* | TransactionRecord[] | – | Transactions, newest first. |
explorerUrl | string | – | Block explorer base URL for hash and address links. |
nativeSymbol | string | "ETH" | Native token symbol for fees. |
nativeDecimals | number | 18 | Native token decimals for fees. |
locale | string | "en-US" | Locale for dates and times. |
timeZone | string | "UTC" | Time zone for grouping and times. Fixed by default so server and client agree. |
loading | boolean | false | Show skeleton rows. |
emptyMessage | string | "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.