Transaction List
Bank-style transactions grouped by day with daily totals; tap a row to expand details, filter In / Out at the top.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/transaction-list.json'use client'
import { useState } from 'react'
import './transaction-list.css'
export interface Txn { id: string; name: string; amount: number; date: string; category: string; method?: string }
export interface TransactionListProps {
items: Txn[]
currency?: string
defaultFilter?: 'all' | 'in' | 'out'
fill?: 'dark' | 'light'
className?: string
}
/** Grouped, filterable, expandable transactions. */
export function TransactionList({ items, currency = 'USD', defaultFilter = 'all', fill = 'dark', className }: TransactionListProps) {
const [filter, setFilter] = useState(defaultFilter)
const [open, setOpen] = useState<string | null>(null)
const money = (c: number, sign = false) => `${sign && c > 0 ? '+' : c < 0 ? '−' : ''}${(Math.abs(c) / 100).toLocaleString('en-US', { style: 'currency', currency })}`
const list = items.filter((t) => filter === 'all' || (filter === 'in' ? t.amount > 0 : t.amount < 0))
const days = [...new Set(list.map((t) => t.date))]
const label = (d: string) => new Date(d + 'T12:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
return (
<div className={['sd-txl', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-txl__f" role="radiogroup" aria-label="Filter">
{(['all', 'in', 'out'] as const).map((f) => <button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)}>{f === 'all' ? 'All' : f === 'in' ? 'Money in' : 'Spending'}</button>)}
</div>
<div className="sd-txl__scroll" key={filter}>
{days.map((d) => {
const rows = list.filter((t) => t.date === d)
return (
<section key={d} aria-label={label(d)}>
<div className="sd-txl__day"><span>{label(d)}</span><span>{money(rows.reduce((a, t) => a + t.amount, 0), true)}</span></div>
{rows.map((t, i) => (
<div key={t.id} className="sd-txl__row" style={{ animationDelay: `${i * 30}ms` }}>
<button type="button" className="sd-txl__btn" aria-expanded={open === t.id} aria-controls={`tx-${t.id}`} onClick={() => setOpen(open === t.id ? null : t.id)}>
<span className="sd-txl__ic" aria-hidden>{t.name[0]}</span>
<span style={{ minWidth: 0 }}><span className="sd-txl__n" style={{ display: 'block' }}>{t.name}</span><span className="sd-txl__c">{t.category}</span></span>
<span className="sd-txl__a" data-in={t.amount > 0 || undefined}>{money(t.amount, true)}</span>
</button>
<div id={`tx-${t.id}`} className="sd-txl__more" data-open={open === t.id || undefined}>
<div>
<div className="sd-txl__det">
<span>Category<b>{t.category}</b></span>
<span>Paid with<b>{t.method ?? 'Card •• 4242'}</b></span>
<button type="button" tabIndex={open === t.id ? 0 : -1}>View receipt</button>
</div>
</div>
</div>
</div>
))}
</section>
)
})}
</div>
</div>
)
}.sd-txl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-txl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-txl button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-txl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-txl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-txl { width: min(100%, 330px); padding: 10px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-txl__f { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; padding: 3px; margin: 2px 2px 8px; border-radius: 11px; background: var(--soft); }
.sd-txl__f button { height: 30px; border-radius: 8px; font-size: 12.5px; color: var(--mute); }
.sd-txl__f button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-txl__scroll { max-height: 330px; overflow: auto; }
.sd-txl__day { position: sticky; top: 0; z-index: 1; display: flex; justify-content: space-between; padding: 8px 8px 6px; background: var(--bg); font-size: 11.5px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.05em; }
.sd-txl__row { animation: sd-txl-in 260ms ease both; }
@keyframes sd-txl-in { from { opacity: 0; transform: translateY(4px); } }
.sd-txl__btn { display: grid; grid-template-columns: 34px 1fr auto; gap: 10px; align-items: center; width: 100%; padding: 8px; border-radius: 12px; text-align: left; }
.sd-txl__btn:hover, .sd-txl__btn[aria-expanded='true'] { background: var(--soft) !important; }
.sd-txl__ic { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font-weight: 650; font-size: 13px; }
.sd-txl__n { font-size: 13.5px; font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sd-txl__c { font-size: 11.5px; color: var(--mute); }
.sd-txl__a { font-size: 13.5px; font-weight: 600; }
.sd-txl__a[data-in] { text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.sd-txl__more { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 260ms ease; }
.sd-txl__more[data-open] { grid-template-rows: 1fr; }
.sd-txl__more > div { overflow: hidden; }
.sd-txl__det { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; margin: 2px 8px 10px 52px; font-size: 12px; color: var(--mute); }
.sd-txl__det b { display: block; color: var(--fg); font-weight: 500; font-size: 12.5px; }
.sd-txl__det button { grid-column: 1 / -1; justify-self: start; height: 28px; padding: 0 10px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-txl *, .sd-txl *::before, .sd-txl *::after { animation: none !important; transition: none !important; } }Overview
Rows group under sticky day headers that show the day’s net. Tapping a row expands it in place to show category, card and a receipt action, and only one row is open at a time.
An All / In / Out filter animates the list down to matching rows.
When to use
- Banking, wallets, expense tools, payouts.
When not to use
- Large ledgers: use a data table with sorting.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/transaction-list.json$
npx shadcn@latest add https://sundaeui.dev/r/transaction-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/transaction-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/transaction-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy transaction-list.tsx and transaction-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TransactionList } from '@/components/sundae/transaction-list'
<TransactionList />Props
| Prop | Type | Default |
|---|---|---|
items*Transactions; amount in cents, negative for spend. | { id: string; name: string; amount: number; date: string; category: string; method?: string }[] | — |
currencyISO currency. | string | 'USD' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rows are disclosure buttons with aria-expanded; details are linked with aria-controls.
- The filter is a radiogroup.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-txl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Transaction List free?
- Yes. Transaction List is free for personal and commercial projects, with full source on this page.
- How do I install Transaction List?
- Run npx shadcn@latest add https://sundaeui.dev/r/transaction-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Transaction List support keyboard and screen readers?
- Rows are disclosure buttons with aria-expanded; details are linked with aria-controls. The filter is a radiogroup.
- Does Transaction List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Savings Goal
Data · RefinedLeaderboard
Data · RefinedWatchlist
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined