Transaction List
Fintech & dataDashboard rows that cascade on scroll-in; loading renders shimmering skeleton rows.
Swiggy
Food & drinks
Salary
Acme Corp
Uber
Transport
Refund — Amazon
Shopping
Netflix
Entertainment
Install
npx shadcn@latest add https://labs.duku.design/r/transaction-list.jsonAsk your agent to use this
Connect DUKU Labs to MCP, then hand your coding agent this prompt:
Use the DUKU Labs MCP server to install the Transaction List component (get_component "transaction-list", then its install command). Adapt it to my existing design tokens, preserve all accessibility and reduced-motion behavior, and keep every interaction state working.
Interaction
| Try | What happens |
|---|---|
| Scroll into view | Rows cascade at 0.04s, once |
| Toggle loading | 5 skeleton rows crossfade to content |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Transaction[] | — | id, title, subtitle?, amount, icon?. |
| loading | boolean | false | Skeleton state. |
| locale / currency | string | "en-IN" / "INR" | Amount formatting. |
Source — registry/default/fintech/transaction-list.tsx
"use client";
import * as React from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/lib/use-reduced-motion";
gsap.registerPlugin(ScrollTrigger);
export interface Transaction {
id: string;
title: string;
subtitle?: string;
/** Positive = credit (emerald), negative = debit. */
amount: number;
icon?: React.ReactNode;
}
export interface TransactionListProps
extends React.HTMLAttributes<HTMLDivElement> {
items: Transaction[];
loading?: boolean;
locale?: string;
currency?: string;
ref?: React.Ref<HTMLDivElement>;
}
const SKELETON_WIDTHS = [
["w-24", "w-16", "w-14"],
["w-32", "w-20", "w-12"],
["w-20", "w-14", "w-16"],
["w-28", "w-24", "w-12"],
["w-24", "w-16", "w-14"],
];
export function TransactionList({
items,
loading = false,
locale = "en-IN",
currency = "INR",
className,
ref,
...rest
}: TransactionListProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
const format = React.useCallback(
(n: number) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(Math.abs(n)),
[locale, currency]
);
// Rows cascade on scroll-in, once.
useGSAP(
() => {
if (loading) return;
const rows = gsap.utils.toArray<HTMLElement>(
rootRef.current?.querySelectorAll('[data-slot="transaction-row"]') ?? []
);
if (!rows.length) return;
if (reduced) {
gsap.set(rows, { y: 0, opacity: 1 });
return;
}
gsap.fromTo(
rows,
{ y: 12, opacity: 0 },
{
y: 0,
opacity: 1,
duration: 0.35,
ease: "power2.out",
stagger: 0.04,
scrollTrigger: {
trigger: rootRef.current,
start: "top 85%",
once: true,
},
}
);
},
{ dependencies: [loading, items, reduced], scope: rootRef }
);
return (
<div
ref={rootRef}
data-slot="transaction-list"
className={cn(
"w-full divide-y divide-border rounded-xl border border-border bg-card",
className
)}
{...rest}
>
{loading
? SKELETON_WIDTHS.map((widths, i) => (
<div
key={i}
data-slot="transaction-skeleton"
className="flex animate-[duku-fade-in_0.25s_ease-out] items-center gap-3 px-4 py-3"
>
<span className="relative size-9 shrink-0 overflow-hidden rounded-full bg-muted">
<span className="absolute inset-0 animate-[duku-shimmer_1.6s_linear_infinite] bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent" />
</span>
<div className="flex flex-1 flex-col gap-1.5">
<span
className={cn(
"relative h-3 overflow-hidden rounded bg-muted",
widths[0]
)}
>
<span className="absolute inset-0 animate-[duku-shimmer_1.6s_linear_infinite] bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent" />
</span>
<span
className={cn(
"relative h-2.5 overflow-hidden rounded bg-muted",
widths[1]
)}
>
<span className="absolute inset-0 animate-[duku-shimmer_1.6s_linear_infinite] bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent" />
</span>
</div>
<span
className={cn(
"relative h-3 overflow-hidden rounded bg-muted",
widths[2]
)}
>
<span className="absolute inset-0 animate-[duku-shimmer_1.6s_linear_infinite] bg-gradient-to-r from-transparent via-foreground/[0.06] to-transparent" />
</span>
</div>
))
: items.map((tx) => (
<div
key={tx.id}
data-slot="transaction-row"
className="flex items-center gap-3 px-4 py-3 opacity-0 transition-colors duration-200 hover:bg-muted/50"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground [&>svg]:size-4">
{tx.icon ?? (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</svg>
)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{tx.title}
</p>
{tx.subtitle ? (
<p className="truncate text-xs text-muted-foreground">
{tx.subtitle}
</p>
) : null}
</div>
<span
data-slot="amount"
className={cn(
"shrink-0 text-sm font-medium tabular-nums",
tx.amount >= 0
? "text-emerald-600 dark:text-emerald-400"
: "text-foreground"
)}
>
{tx.amount >= 0 ? "+" : "−"}
{format(tx.amount)}
</span>
</div>
))}
</div>
);
}