Widgets
Transaction list
Betalinger grupperet pr. dag med dagens netto, beløb med fortegn i tabeltal og tilstande for indgående, afventende og fejlede, der aldrig kun bygger på farve.
Forhåndsvisning
Brug
transaction-list-demo.tsx
import { useState } from 'react'
import { Pills } from '@/components/chart-kit'
import { DataTile } from '@/components/data-tile'
import { TransactionList, type Transaction } from '@/components/transaction-list'
// Example payments for the demo, not a real account. "Today" is Thursday 8 October 2026.
const TODAY = '2026-10-08'
const TXS: Transaction[] = [
{ id: 't1', name: 'Harbour Market', category: 'Groceries', date: '2026-10-08T12:58', amount: -48.2, status: 'pending' },
{ id: 't2', name: 'City Transit', category: 'Transport', date: '2026-10-08T08:31', amount: -3.1 },
{ id: 't3', name: 'Corner Bakery', category: 'Eating out', date: '2026-10-08T08:12', amount: -6.4 },
{ id: 't4', name: 'Pixel & Plug', category: 'Shopping', date: '2026-10-07T19:40', amount: -129, status: 'failed', note: 'Card limit reached' },
{ id: 't5', name: 'Northwind Books', category: 'Shopping', date: '2026-10-07T16:05', amount: -31.5 },
{ id: 't6', name: 'From Savings', category: 'Transfer', date: '2026-10-07T09:00', amount: 200 },
{ id: 't7', name: 'Lumen Energy', category: 'Bills', date: '2026-10-06T07:00', amount: -74 },
{ id: 't8', name: 'Refund, Northwind Books', category: 'Shopping', date: '2026-10-06T11:20', amount: 12 },
{ id: 't9', name: 'Green Leaf Café', category: 'Eating out', date: '2026-10-06T12:41', amount: -9.8 },
]
const CATEGORIES = {
Groceries: 'var(--chart-1)',
Transport: 'var(--chart-2)',
'Eating out': 'var(--chart-3)',
Shopping: 'var(--chart-4)',
Bills: 'var(--chart-5)',
}
const SHOW = [
{ id: 'all', label: 'All' },
{ id: 'in', label: 'In' },
{ id: 'out', label: 'Out' },
]
const EUR = { currency: 'EUR', decimals: 2 }
export default function TransactionListDemo() {
const [show, setShow] = useState('all')
const [open, setOpen] = useState<Transaction | null>(null)
const rows = TXS.filter((t) => (show === 'in' ? t.amount > 0 : show === 'out' ? t.amount < 0 : true))
return (
<div className="grid w-full gap-4">
<DataTile
title="Transactions"
action={<Pills label="Show" options={SHOW} value={show} onChange={setShow} />}
footer={
<span>
{open ? `Opened ${open.name}. ` : 'Rows grouped by day, with chips for pending and failed payments. '}
Example data.
</span>
}
>
<TransactionList
data={rows}
label="Recent transactions"
today={TODAY}
format={EUR}
categories={CATEGORIES}
onSelect={setOpen}
selectedId={open?.id}
/>
</DataTile>
<div className="grid gap-4 sm:grid-cols-2">
<DataTile title="Latest" density="compact" footer={<span>Four rows, the rest behind a button. Example data.</span>}>
<TransactionList data={TXS} label="Latest transactions" today={TODAY} format={EUR} categories={CATEGORIES} limit={4} />
</DataTile>
<DataTile title="Money in" density="compact" inverted footer={<span>Example data.</span>}>
<TransactionList data={TXS.filter((t) => t.amount > 0)} label="Money in" today={TODAY} format={EUR} />
</DataTile>
</div>
</div>
)
}Kilde
transaction-list.tsx
// A list of payments grouped by day: "Today", "Yesterday", then the date, each with the day's net on the right.
// A row is a category glyph, the name, the category and time, and the signed amount in tabular mono with a real
// minus. Money coming in gets a plus, an arrow glyph and the success colour; a pending payment gets a dashed chip
// with a clock, a failed one a filled chip with a cross, a struck-through amount and its reason. None of these
// depends on colour alone. A limit hides the older rows behind a "Show more" button.
// With `onSelect` every row is a button; without it the rows are plain list items.
// Screen readers: each day is a heading followed by a list. Each row is read as one sentence, "Harbour Market,
// −€48.20, pending, Groceries, 12:58", and the visual pieces are hidden so nothing is read twice. Showing more
// rows says how many came in.
// Reduced motion: new rows appear at once instead of rising in.
import { ArrowDown, ChevronDown, Clock3, X } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useId, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'
import { EASE, formatValue, seriesColor, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'
import { useAnnounce } from '@/registry/manniche/chart-kit/use-chart'
import { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'
import { cn } from '@/lib/utils'
export type Transaction = {
/** Unique and stable. */
id: string
/** Who was paid, or who paid you. */
name: string
/** Negative for money out, positive for money in. */
amount: number
/** "YYYY-MM-DD", or "YYYY-MM-DDTHH:MM" to show a time. Read as written, without time zone maths. */
date: string
/** Shown under the name and used to pick the glyph's colour. */
category?: string
/** The glyph's colour. Default from `categories`, else a series colour per category; money in uses --success. */
color?: string
/** Replaces the initials (or the arrow for money in) in the glyph. */
glyph?: ReactNode
/** "pending" for a payment that has not cleared, "failed" for one that did not go through. */
status?: 'pending' | 'failed'
/** A short reason under a failed amount, such as "Card limit reached". */
note?: string
}
export type TransactionListLabels = {
/** The name of a day group. `offset` is days before `today`, or null without `today`. */
day?: (date: string, offset: number | null, locale: string) => string
pending?: string
failed?: string
/** Read after the amount of money that came in. */
incoming?: string
/** Read before a day's total. */
net?: string
more?: (hidden: number) => string
less?: string
/** Read when more rows are shown. */
shown?: (count: number) => string
/** The one-line sentence a row is read as. */
row?: (item: Transaction, parts: { amount: string; status: string; time: string }) => string
empty?: string
loading?: string
}
export type TransactionListProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> & {
/** The payments. Days are sorted newest first; rows keep your order within a day. */
data: Transaction[]
/** The accessible name of the list, such as "Recent transactions". */
label: string
/** "YYYY-MM-DD" of today, so the first days read "Today" and "Yesterday". Without it every day shows its date. */
today?: string
/** The amounts' format. Default two decimals; pass a currency for money. */
format?: ValueFormat
/** Colours per category name, such as { Groceries: 'var(--chart-1)' }. */
categories?: Record<string, string>
/** Show this many rows and put the rest behind "Show more". Default all. */
limit?: number
/** Makes each row a button and is called with its payment. */
onSelect?: (item: Transaction) => void
/** The id of the row that is open elsewhere, marked with the signal colour. Needs `onSelect`. */
selectedId?: string
/** The level of the day headings. Default 4. */
headingLevel?: 2 | 3 | 4 | 5 | 6
/** Show placeholder rows while the data loads. */
loading?: boolean
/** What to show when there are no payments. Default a short line. */
empty?: ReactNode
/** "compact" tightens the rows. Default follows the tile. */
density?: 'comfortable' | 'compact'
labels?: TransactionListLabels
}
const dayName = (date: string, offset: number | null, locale: string) => {
if (offset === 0) return 'Today'
if (offset === 1) return 'Yesterday'
const [y, m, d] = date.split('-').map(Number)
return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
.format(new Date(Date.UTC(y, m - 1, d)))
.replace(',', '')
}
const DEFAULT_LABELS: Required<TransactionListLabels> = {
day: dayName,
pending: 'Pending',
failed: 'Failed',
incoming: 'in',
net: 'Net',
more: (n) => `Show ${n} more`,
less: 'Show fewer',
shown: (n) => `${n} more transactions shown`,
row: (t, p) => [t.name, p.amount, p.status, t.category, p.time].filter(Boolean).join(', '),
empty: 'No transactions yet.',
loading: 'Loading transactions',
}
// Row metrics as variables, so a compact tile (or the prop) can swap them in one place.
const METRICS =
'[--tx-g:44px] [--tx-gr:14px] [--tx-gt:14px] [--tx-ic:18px] [--tx-row:62px] [--tx-gap:14px] [--tx-nm:15px] [--tx-dt:16px] [--tx-db:6px] ' +
'group-data-[density=compact]/tile:[--tx-g:36px] group-data-[density=compact]/tile:[--tx-gr:11px] group-data-[density=compact]/tile:[--tx-gt:12.5px] ' +
'group-data-[density=compact]/tile:[--tx-ic:16px] group-data-[density=compact]/tile:[--tx-row:52px] group-data-[density=compact]/tile:[--tx-gap:12px] ' +
'group-data-[density=compact]/tile:[--tx-nm:14px] group-data-[density=compact]/tile:[--tx-dt:10px] group-data-[density=compact]/tile:[--tx-db:4px]'
const DENSITY_VARS = {
comfortable: { '--tx-g': '44px', '--tx-gr': '14px', '--tx-gt': '14px', '--tx-ic': '18px', '--tx-row': '62px', '--tx-gap': '14px', '--tx-nm': '15px', '--tx-dt': '16px', '--tx-db': '6px' },
compact: { '--tx-g': '36px', '--tx-gr': '11px', '--tx-gt': '12.5px', '--tx-ic': '16px', '--tx-row': '52px', '--tx-gap': '12px', '--tx-nm': '14px', '--tx-dt': '10px', '--tx-db': '4px' },
} as Record<string, CSSProperties>
const dayNumber = (iso: string) => {
const [y, m, d] = iso.slice(0, 10).split('-').map(Number)
return Math.round(Date.UTC(y, m - 1, d) / 86400000)
}
/** Two letters for the glyph: the first letters of the first two words, or the first two of one word. */
function initials(name: string) {
const words = name.replace(/^refund,\s*/i, '').match(/[\p{L}\p{N}]+/gu) ?? []
if (!words.length) return '·'
const [a = '', b = ''] = words
return (b ? a.slice(0, 1) + b.slice(0, 1) : a.slice(0, 2)).toUpperCase()
}
export function TransactionList({
data,
label,
today,
format = { decimals: 2 },
categories,
limit,
onSelect,
selectedId,
headingLevel = 4,
loading = false,
empty,
density,
labels: labelsProp,
className,
style: styleProp,
...rest
}: TransactionListProps) {
const labels = { ...DEFAULT_LABELS, ...labelsProp }
const locale = format.locale ?? 'en-GB'
const reduced = useReducedMotion()
const { say, region } = useAnnounce()
const baseId = useId()
const [expanded, setExpanded] = useState(false)
// Rows that mount after the first paint (a filter, new data, "Show more") rise in; the first paint is still.
const [settled, setSettled] = useState(false)
useEffect(() => {
const r = requestAnimationFrame(() => setSettled(true))
return () => cancelAnimationFrame(r)
}, [])
const H = `h${headingLevel}` as 'h4'
const style = { ...(density ? DENSITY_VARS[density] : null), ...styleProp }
const autoColors = new Map<string, string>()
for (const t of data) {
if (t.category && !categories?.[t.category] && !autoColors.has(t.category) && t.amount <= 0)
autoColors.set(t.category, seriesColor(autoColors.size))
}
const colorOf = (t: Transaction) =>
t.color ??
(t.amount > 0 && t.status !== 'failed' ? 'var(--success)' : undefined) ??
(t.category ? (categories?.[t.category] ?? autoColors.get(t.category)) : undefined) ??
'var(--muted-foreground)'
if (loading) {
return (
<div role="status" aria-label={labels.loading} className={cn(METRICS, 'grid gap-1', className)} style={style} {...rest}>
{[0, 1, 2, 3].map((i) => (
<div key={i} aria-hidden className={cn('grid grid-cols-[var(--tx-g)_minmax(0,1fr)_auto] items-center gap-(--tx-gap) p-2', 'min-h-(--tx-row)')}>
<span className={cn('rounded-[14px] bg-muted motion-safe:animate-pulse', 'size-(--tx-g)')} />
<span className="grid gap-2">
<span className="h-3.5 w-2/3 max-w-40 rounded-full bg-muted motion-safe:animate-pulse" />
<span className="h-3 w-1/3 max-w-24 rounded-full bg-muted motion-safe:animate-pulse" />
</span>
<span className="h-3.5 w-16 rounded-full bg-muted motion-safe:animate-pulse" />
</div>
))}
</div>
)
}
if (!data.length) {
return (
<div role="group" aria-label={label} className={cn('grid min-h-[124px] place-items-center px-4 py-8 text-center', className)} style={styleProp} {...rest}>
{empty ?? <p className="text-[13.5px] text-muted-foreground">{labels.empty}</p>}
</div>
)
}
// Group by day, newest day first; keep the given order inside a day.
const groups: { date: string; items: Transaction[] }[] = []
const byDay = new Map<string, Transaction[]>()
for (const t of data) {
const d = t.date.slice(0, 10)
if (!byDay.has(d)) {
const items: Transaction[] = []
byDay.set(d, items)
groups.push({ date: d, items })
}
byDay.get(d)!.push(t)
}
groups.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
const total = data.length
const cap = limit !== undefined && limit < total && !expanded ? limit : total
const hidden = total - cap
const todayN = today ? dayNumber(today) : null
let shown = 0
let fresh = 0
const sections = groups.flatMap((g) => {
if (shown >= cap) return []
const items = g.items.slice(0, cap - shown)
shown += items.length
return [{ ...g, items, all: g.items }]
})
const amountSign = { ...format, sign: true }
return (
<div role="group" aria-label={label} className={cn(METRICS, 'grid min-w-0', className)} style={style} {...rest}>
{sections.map((g, gi) => {
const net = g.all.filter((t) => t.status !== 'failed').reduce((s, t) => s + t.amount, 0)
const offset = todayN === null ? null : todayN - dayNumber(g.date)
const headId = `${baseId}-d${gi}`
return (
<section key={g.date} aria-labelledby={headId} className="min-w-0">
<H
id={headId}
className={cn(
'flex items-baseline justify-between gap-3 px-2 text-[13px] leading-[1.3] font-medium text-muted-foreground',
gi === 0 ? 'pt-0.5' : 'pt-(--tx-dt)',
'pb-(--tx-db)',
)}
>
<span className="min-w-0 truncate">{labels.day(g.date, offset, locale)}</span>
<span className="flex-none font-mono text-[11.5px] tabular-nums">
<span className="sr-only">, {labels.net} </span>
{formatValue(net, amountSign)}
</span>
</H>
<ul className="grid">
{g.items.map((t) => {
const k = fresh
const animate = settled && !reduced
if (animate) fresh++
return (
<motion.li
key={t.id}
initial={animate ? { opacity: 0, y: 6 } : false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: EASE, delay: Math.min(k * 0.03, 0.36) }}
className="min-w-0"
>
<Row
item={t}
color={colorOf(t)}
format={format}
labels={labels}
onSelect={onSelect}
selected={onSelect !== undefined && selectedId === t.id}
/>
</motion.li>
)
})}
</ul>
</section>
)
})}
{limit !== undefined && limit < total && (
<button
type="button"
aria-expanded={expanded}
onClick={() => {
if (!expanded) say(labels.shown(hidden))
setExpanded(!expanded)
}}
className={cn(
'relative isolate mt-1 inline-flex min-h-11 cursor-pointer items-center justify-center gap-1.5 rounded-[14px] px-3 text-[13.5px] font-medium text-foreground',
'before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-muted before:opacity-60 before:transition-opacity before:duration-150 hover:before:opacity-100',
'transition-transform duration-150 ease-out-quint active:scale-[0.98] motion-reduce:transition-none motion-reduce:before:transition-none',
)}
>
{expanded ? labels.less : labels.more(hidden)}
<ChevronDown
aria-hidden
className={cn('size-4 transition-transform duration-200 ease-out-quint motion-reduce:transition-none', expanded && 'rotate-180')}
/>
</button>
)}
{region}
</div>
)
}
function Row({
item: t,
color,
format,
labels,
onSelect,
selected,
}: {
item: Transaction
color: string
format: ValueFormat
labels: Required<TransactionListLabels>
onSelect?: (item: Transaction) => void
selected: boolean
}) {
const failed = t.status === 'failed'
const pending = t.status === 'pending'
const incoming = t.amount > 0 && !failed
const p = valueParts(t.amount, { ...format, sign: true })
const time = t.date.length > 10 ? t.date.slice(11, 16) : ''
const amount = formatValue(t.amount, { ...format, sign: true })
const status = failed ? [labels.failed, t.note].filter(Boolean).join(': ') : pending ? labels.pending : incoming ? labels.incoming : ''
const sentence = labels.row(t, { amount, status, time })
const unit = p.unit && <span className="text-muted-foreground">{p.unit}</span>
const body = (
<>
<span className="sr-only">{sentence}</span>
<span
aria-hidden
className={cn(
'grid flex-none place-items-center text-foreground',
'size-(--tx-g) rounded-(--tx-gr) text-(length:--tx-gt)',
)}
style={{ background: `color-mix(in oklab, ${color} 18%, var(--card))`, fontFamily: 'var(--font-display, inherit)', fontWeight: 700 }}
>
{t.glyph ?? (incoming ? <ArrowDown className="size-(--tx-ic)" strokeWidth={2} /> : initials(t.name))}
</span>
<span aria-hidden className="grid min-w-0 gap-0.5">
<span className={cn('truncate leading-[1.25] font-medium text-foreground', 'text-(length:--tx-nm)')}>{t.name}</span>
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[13px] leading-[1.3] text-muted-foreground">
<span className="min-w-0 truncate">{[t.category, time].filter(Boolean).join(' · ')}</span>
{pending && (
<span className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full px-2 text-[11.5px] font-medium outline-1 -outline-offset-1 outline-dashed outline-[color-mix(in_oklab,var(--foreground)_35%,transparent)]">
<Clock3 className="size-3" strokeWidth={2.2} />
{labels.pending}
</span>
)}
{failed && (
<span
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full px-2 text-[11.5px] font-medium"
style={{
background: 'color-mix(in oklab, var(--destructive) 13%, var(--card))',
color: 'color-mix(in oklab, var(--destructive) 85%, var(--foreground))',
}}
>
<X className="size-3" strokeWidth={2.6} />
{labels.failed}
</span>
)}
</span>
</span>
<span aria-hidden className="grid justify-items-end gap-0.5 text-right">
<span
className={cn(
'font-mono leading-[1.2] whitespace-nowrap tabular-nums',
'text-(length:--tx-nm)',
failed ? 'font-medium text-muted-foreground line-through decoration-[1.5px]' : 'font-semibold',
)}
style={incoming ? { color: 'color-mix(in oklab, var(--success) 85%, var(--foreground))' } : failed ? undefined : { color: 'var(--foreground)' }}
>
{p.sign}
{!p.unitAfter && unit}
{p.whole}
{p.fraction}
{p.unitAfter && <> {unit}</>}
{p.suffix}
</span>
{failed && t.note && <span className="max-w-[24ch] font-mono text-[11px] leading-[1.3] text-muted-foreground">{t.note}</span>}
</span>
</>
)
const grid = cn(
'relative grid w-full min-w-0 items-center rounded-[16px] p-2 text-left',
'min-h-(--tx-row) grid-cols-[var(--tx-g)_minmax(0,1fr)_auto] gap-(--tx-gap)',
)
if (!onSelect) return <div className={grid}>{body}</div>
return (
<button
type="button"
aria-current={selected || undefined}
onClick={() => onSelect(t)}
className={cn(
grid,
'isolate cursor-pointer before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-muted before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-100',
'transition-transform duration-150 ease-out-quint active:scale-[0.98] motion-reduce:transition-none motion-reduce:before:transition-none',
selected && 'before:opacity-100',
)}
>
{selected && <span aria-hidden className="absolute top-3 bottom-3 -left-0.5 w-[3px] rounded-full bg-primary" />}
{body}
</button>
)
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/transaction-list.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/transaction-list