- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Total balance
$84,230
+3.2%Income
$18,420
+6.1%Expenses
$9,140
+2.4%Invested
$32,400
+8.4%Cash flow
$18,420
+6.1%IncomeSpending
Spending by category
$5,810+2.4%
Portfolio
7%
+6.2%Stocks8%
Funds3%
Crypto12%
Daily spending
$4,390+3.6%
MonTueWedThuFriSatSunW1W3W5W7W9W11
LessMore
Total Results
140 transactions
| Account | Category | Actions | ||||
|---|---|---|---|---|---|---|
| Greenfield Market | Groceries | Nov 05, 2026 | -$64 | |||
| Streamly | Subscriptions | Nov 02, 2026 | -$20 | |||
| Brightside payroll | Income | Oct 30, 2026 | +$2,160 | |||
| Saffron Table | Dining | Oct 30, 2026 | -$54 | |||
| Metro electric | Utilities | Oct 27, 2026 | -$94 | |||
| Greenfield Market | Groceries | Oct 24, 2026 | -$109 | |||
| City transit | Transport | Oct 24, 2026 | -$72 | |||
| Monthly rent | Housing | Oct 21, 2026 | -$403 | |||
| Saffron Table | Dining | Oct 18, 2026 | -$99 | |||
| Streamly | Subscriptions | Oct 18, 2026 | -$11 |
…
Page 1 of 14Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/finance-dashboard-01
The block is the page body only, with no sidebar or heading, so it drops into an app shell. Transactions live in local component state. Pass addOpen and onAddOpenChange to open the add dialog from your own button, and popupClassName to scope the theme of dialogs, menus and tooltips rendered outside the block.
Source
"use client";
import { cn } from "cn";
import * as React from "react";
import { FinanceDashboardCashFlowCard } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-cash-flow-card";
import {
dollars,
financeStats,
flowNodes,
flowRanges,
portfolioRanges,
spendingHeatmapRanges,
spendingRanges,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import { FinanceDashboardTransactionsTable } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-transactions-table";
import { HeatmapChartCard } from "@/components/blocks/heatmap-chart-card-01/components/heatmap-chart-card";
import { RadialChartCard } from "@/components/blocks/radial-chart-card-01/components/radial-chart-card";
import { ScatterChartCard } from "@/components/blocks/scatter-chart-card-01/components/scatter-chart-card";
import { StatCards } from "@/components/blocks/stat-cards-01/components/stat-cards";
import { Reveal } from "@/components/ui/reveal";
// 500ms, 12px rise with a 3px blur clearing, each row 40ms after the last. The
// cards inside a row do not animate again.
function Row({
step,
className,
children,
}: {
step: number;
className?: string;
children: React.ReactNode;
}) {
return (
<Reveal index={step} blur className="min-w-0">
<div className={className}>{children}</div>
</Reveal>
);
}
/**
* The body of a personal finance dashboard: four KPI cards, a cash flow
* diagram, spending by category, a portfolio chart, a daily spending heatmap
* and a transactions table. It carries no navigation or heading, so it drops
* into an app shell or any page.
*
* Adding a transaction is controlled from outside: pass `addOpen` and
* `onAddOpenChange` to open the dialog from your own button. Without them the
* block keeps that state itself. Pass `popupClassName` (a theme scope) so
* menus, dialogs and tooltips rendered outside the block pick up its tokens.
*/
export function FinanceDashboard({
addOpen,
onAddOpenChange,
className,
popupClassName,
}: {
addOpen?: boolean;
onAddOpenChange?: (open: boolean) => void;
className?: string;
popupClassName?: string;
}) {
const [innerOpen, setInnerOpen] = React.useState(false);
return (
<div className={cn("@container flex min-w-0 flex-col gap-4", className)}>
<Row step={0}>
<StatCards stats={financeStats} popupClassName={popupClassName} />
</Row>
<Row step={1}>
<FinanceDashboardCashFlowCard
title="Cash flow"
nodes={flowNodes}
ranges={flowRanges}
delta="+6.1%"
format={dollars}
axisLabels={["Income", "Spending"]}
popupClassName={popupClassName}
/>
</Row>
<Row step={2} className="grid min-w-0 gap-4 @4xl:grid-cols-3">
<RadialChartCard
title="Spending by category"
data={spendingRanges[0].data}
ranges={spendingRanges}
format={dollars}
className="max-w-none"
popupClassName={popupClassName}
/>
<ScatterChartCard
title="Portfolio"
series={portfolioRanges[0].series}
ranges={portfolioRanges}
bubble
tiles={false}
change="+6.2%"
format={(n) => `${Math.round(n)}%`}
formatX={(n) => String(Math.round(n))}
axisLabels={["Risk score", "Return"]}
className="max-w-none"
popupClassName={popupClassName}
/>
<HeatmapChartCard
title="Daily spending"
rows={spendingHeatmapRanges[0].rows}
columns={spendingHeatmapRanges[0].columns ?? []}
ranges={spendingHeatmapRanges}
format={dollars}
className="max-w-none"
popupClassName={popupClassName}
/>
</Row>
<Row step={3}>
<FinanceDashboardTransactionsTable
addOpen={addOpen ?? innerOpen}
onAddOpenChange={onAddOpenChange ?? setInnerOpen}
popupClassName={popupClassName}
/>
</Row>
</div>
);
}"use client";
import {
RiArrowLeftLine,
RiArrowRightLine,
RiDeleteBinLine,
RiMore2Line,
RiPencilLine,
RiSearchLine,
} from "@remixicon/react";
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import * as React from "react";
import {
accountDots,
accounts,
categories,
categoryIcons,
categoryTones,
dollars,
initialTransactions,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import type {
Account,
Transaction,
} from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-data";
import { FinanceDashboardTransactionDialog } from "@/components/blocks/finance-dashboard-01/components/finance-dashboard-transaction-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DataTable } from "@/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const ALL = "all";
const amountRanges = [
{ value: "income", label: "Income", match: (n: number) => n > 0 },
{
value: "small",
label: "Under $100",
match: (n: number) => n < 0 && n > -100,
},
{ value: "large", label: "$100 and over", match: (n: number) => n <= -100 },
];
const dateFormat = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "short",
timeZone: "UTC",
year: "numeric",
});
// A window of page buttons with an ellipsis before the last page.
function pageWindow(index: number, count: number): (number | "gap")[] {
if (count <= 7) {
return Array.from({ length: count }, (_, i) => i);
}
const start = Math.min(Math.max(index - 2, 0), count - 6);
const pages: (number | "gap")[] = Array.from(
{ length: 5 },
(_, i) => start + i
);
return [...pages, "gap", count - 1];
}
function FilterSelect({
label,
value,
onChange,
options,
popupClassName,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
popupClassName?: string;
}) {
const items = [{ label, value: ALL }, ...options];
return (
<Select
items={items}
value={value}
onValueChange={(next) => onChange(next ?? ALL)}
>
<SelectTrigger aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent className={popupClassName}>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/**
* The transactions table: account, category and amount filters, search, sort,
* row selection, inline account changes, edit and delete, and pagination.
* Adding is driven from outside through `addOpen`.
*/
export function FinanceDashboardTransactionsTable({
addOpen,
onAddOpenChange,
popupClassName,
}: {
addOpen: boolean;
onAddOpenChange: (open: boolean) => void;
popupClassName?: string;
}) {
const [rows, setRows] = React.useState(initialTransactions);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [rowSelection, setRowSelection] = React.useState<
Record<string, boolean>
>({
"tx-3": true,
"tx-5": true,
});
const [touched, setTouched] = React.useState(false);
const [search, setSearch] = React.useState("");
const [account, setAccount] = React.useState(ALL);
const [category, setCategory] = React.useState(ALL);
const [amount, setAmount] = React.useState(ALL);
const [editing, setEditing] = React.useState<Transaction | undefined>();
const counter = React.useRef(initialTransactions.length);
const nextTransactionId = () => {
counter.current += 1;
return `tx-${counter.current}`;
};
const filtered = React.useMemo(() => {
const range = amountRanges.find((item) => item.value === amount);
return rows.filter(
(row) =>
(account === ALL || row.account === account) &&
(category === ALL || row.category === category) &&
(!range || range.match(row.amount))
);
}, [rows, account, category, amount]);
const remove = React.useCallback((id: string) => {
setRows((current) => current.filter((item) => item.id !== id));
setRowSelection((current) => {
const { [id]: _removed, ...rest } = current as Record<string, boolean>;
return rest;
});
}, []);
const columns = React.useMemo<ColumnDef<Transaction>[]>(
() => [
{
id: "select",
enableSorting: false,
meta: { className: "w-[28px]" },
header: ({ table }) => (
<Checkbox
aria-label="Select all transactions on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() &&
!table.getIsAllPageRowsSelected()
}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label={`Select ${row.original.merchant}`}
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
/>
),
},
{
accessorKey: "merchant",
meta: { className: "w-[19%] pl-2" },
header: "Transaction",
cell: ({ row }) => {
const Icon = categoryIcons[row.original.category];
return (
<span className="flex items-center gap-2 font-medium">
<span className="flex size-7 shrink-0 items-center justify-center rounded-[8px] border border-border bg-card text-muted-foreground">
<Icon className="size-4" aria-hidden />
</span>
{row.original.merchant}
</span>
);
},
},
{
accessorKey: "account",
meta: { className: "w-[16.6%]" },
enableSorting: false,
header: "Account",
cell: ({ row }) => (
<Select
items={accounts.map((item) => ({ label: item, value: item }))}
value={row.original.account}
onValueChange={(next) => {
if (next) {
setRows((current) =>
current.map((item) =>
item.id === row.original.id
? { ...item, account: next as Account }
: item
)
);
}
}}
>
<SelectTrigger
aria-label={`Account for ${row.original.merchant}`}
className="w-[140px]"
>
<span
aria-hidden
className={`ml-0.5 size-2 shrink-0 rounded-full ring-4 ${accountDots[row.original.account]}`}
/>
<span className="flex-1 text-start">
<SelectValue />
</span>
</SelectTrigger>
<SelectContent className={popupClassName}>
{accounts.map((item) => (
<SelectItem key={item} value={item}>
{item}
</SelectItem>
))}
</SelectContent>
</Select>
),
},
{
accessorKey: "category",
meta: { className: "w-[16.6%]" },
enableSorting: false,
header: "Category",
cell: ({ row }) => (
<Badge variant="label" tone={categoryTones[row.original.category]}>
{row.original.category}
</Badge>
),
},
{
accessorKey: "date",
meta: { className: "w-[16.6%]" },
header: "Date",
cell: ({ row }) =>
dateFormat.format(new Date(`${row.original.date}T00:00:00Z`)),
},
{
accessorKey: "amount",
meta: { className: "w-[16.6%]" },
header: "Amount",
cell: ({ row }) =>
row.original.amount > 0 ? (
<Badge variant="label" tone="lime">
+{dollars(row.original.amount)}
</Badge>
) : (
<Badge variant="label" tone="gray" tabular>
-{dollars(Math.abs(row.original.amount))}
</Badge>
),
},
{
enableSorting: false,
header: "Actions",
id: "actions",
meta: { className: "w-[140px]" },
cell: ({ row }) => (
<div className="flex items-center gap-2.5">
<Button
aria-label={`Delete ${row.original.merchant}`}
tooltip={{ children: "Delete", side: "top" }}
size="icon-sm"
variant="outline"
onClick={() => remove(row.original.id)}
>
<RiDeleteBinLine />
</Button>
<Button
aria-label={`Edit ${row.original.merchant}`}
tooltip={{ children: "Edit", side: "top" }}
size="icon-sm"
variant="outline"
onClick={() => setEditing(row.original)}
>
<RiPencilLine />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label={`More actions for ${row.original.merchant}`}
tooltip={{ children: "More actions", side: "top" }}
size="icon-sm"
variant="outline"
/>
}
>
<RiMore2Line />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={popupClassName}>
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() =>
setRows((current) => [
{
...row.original,
id: nextTransactionId(),
},
...current,
])
}
>
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditing(row.original)}>
Edit details
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[popupClassName, remove]
);
const table = useReactTable({
columns,
data: filtered,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getRowId: (row) => row.id,
getSortedRowModel: getSortedRowModel(),
initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
onGlobalFilterChange: setSearch,
onRowSelectionChange: (updater) => {
setTouched(true);
setRowSelection(updater);
},
onSortingChange: setSorting,
state: { globalFilter: search, rowSelection, sorting },
});
const { pageIndex } = table.getState().pagination;
const total = table.getFilteredRowModel().rows.length;
// Two rows start checked for show; the selection bar waits for a real click.
const selected = touched ? Object.keys(rowSelection).length : 0;
const pageCount = Math.max(table.getPageCount(), 1);
return (
<>
<Card radius={16} size="flush" variant="outline" className="w-full">
<div className="flex flex-wrap items-center justify-between gap-3 p-3">
<div className="text-sm font-medium">
<div className="text-muted-foreground">
{selected > 0 ? `${selected} selected` : "Total Results"}
</div>
<div>{total} transactions</div>
</div>
<div className="flex flex-wrap items-center gap-2.5">
{selected > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
const ids = new Set(Object.keys(rowSelection));
setRows((current) =>
current.filter((item) => !ids.has(item.id))
);
setRowSelection({});
}}
>
<RiDeleteBinLine data-icon="inline-start" />
Delete selected
</Button>
)}
<FilterSelect
label="All accounts"
value={account}
onChange={setAccount}
options={accounts.map((item) => ({ label: item, value: item }))}
popupClassName={popupClassName}
/>
<FilterSelect
label="All categories"
value={category}
onChange={setCategory}
options={categories.map((item) => ({ label: item, value: item }))}
popupClassName={popupClassName}
/>
<FilterSelect
label="All amounts"
value={amount}
onChange={setAmount}
options={amountRanges.map((item) => ({
label: item.label,
value: item.value,
}))}
popupClassName={popupClassName}
/>
<InputGroup variant="muted" className="w-[153px]">
<InputGroupAddon>
<RiSearchLine />
</InputGroupAddon>
<InputGroupInput
aria-label="Search transactions"
placeholder="Search"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</InputGroup>
</div>
</div>
<DataTable
aria-label="Transactions"
emptyMessage="No transactions match your filters."
variant="ghost"
className="min-w-[960px] table-fixed"
table={table}
/>
<div className="flex items-center justify-between gap-2 border-t border-border p-3">
<Button
disabled={!table.getCanPreviousPage()}
size="sm"
variant="outline"
onClick={() => table.previousPage()}
>
<RiArrowLeftLine />
Previous
</Button>
<div className="hidden items-center gap-0.5 @3xl:flex">
{pageWindow(pageIndex, pageCount).map((page, slot) =>
page === "gap" ? (
<span
aria-hidden
className="grid size-8 place-items-center text-sm text-muted-foreground"
key={`gap-${slot}`}
>
…
</span>
) : (
<Button
aria-current={page === pageIndex ? "page" : undefined}
key={page}
size="icon-sm"
variant={page === pageIndex ? "outline" : "ghost"}
onClick={() => table.setPageIndex(page)}
>
{page + 1}
</Button>
)
)}
</div>
<span className="text-sm text-muted-foreground @3xl:hidden">
Page {pageIndex + 1} of {pageCount}
</span>
<Button
disabled={!table.getCanNextPage()}
size="sm"
variant="outline"
onClick={() => table.nextPage()}
>
Next
<RiArrowRightLine />
</Button>
</div>
</Card>
<FinanceDashboardTransactionDialog
open={addOpen}
onOpenChange={onAddOpenChange}
popupClassName={popupClassName}
onSubmit={(values) =>
setRows((current) => [
{ ...values, id: nextTransactionId() },
...current,
])
}
/>
<FinanceDashboardTransactionDialog
open={editing !== undefined}
onOpenChange={(open) => {
if (!open) {
setEditing(undefined);
}
}}
transaction={editing}
popupClassName={popupClassName}
onSubmit={(values) =>
setRows((current) =>
current.map((item) =>
item.id === editing?.id ? { ...item, ...values } : item
)
)
}
/>
</>
);
} On this page