Transaction List

Fintech & data

Dashboard rows that cascade on scroll-in; loading renders shimmering skeleton rows.

Swiggy

Food & drinks

₹420.00

Salary

Acme Corp

+₹85,000.00

Uber

Transport

₹240.50

Refund — Amazon

Shopping

+₹1,299.00

Netflix

Entertainment

₹649.00

Install

npx shadcn@latest add https://labs.duku.design/r/transaction-list.json

Ask 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

TryWhat happens
Scroll into viewRows cascade at 0.04s, once
Toggle loading5 skeleton rows crossfade to content

Props

PropTypeDefaultDescription
itemsTransaction[]id, title, subtitle?, amount, icon?.
loadingbooleanfalseSkeleton state.
locale / currencystring"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>
  );
}