Dashboard 3
Revenue dashboard with a month stepper, plan-mix donut and legend, invoice status list and a transactions table with status dots and a pagination footer.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/dashboard-03.jsonCode
components/blocks/dashboard-03.tsx
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight, Download } from "lucide-react";
import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/hirael/ui/card";
import { Separator } from "@/registry/hirael/ui/separator";
type PlanSlice = {
label: string;
share: number;
mrr: string;
tone: string;
dot: string;
};
type MonthData = {
label: string;
total: string;
delta: string;
plans: readonly PlanSlice[];
invoices: readonly {
label: string;
count: number;
amount: string;
dot: string;
}[];
};
const MONTHS: readonly MonthData[] = [
{
label: "February 2025",
total: "$38,410",
delta: "+6.1% vs Jan",
plans: [
{
label: "Pro",
share: 48,
mrr: "$18,436",
tone: "stroke-foreground/85",
dot: "bg-foreground/85",
},
{
label: "Team",
share: 33,
mrr: "$12,675",
tone: "stroke-muted-foreground/50",
dot: "bg-muted-foreground/50",
},
{
label: "Enterprise",
share: 19,
mrr: "$7,299",
tone: "stroke-muted-foreground/20",
dot: "bg-muted-foreground/20",
},
],
invoices: [
{ label: "Paid", count: 212, amount: "$36,180", dot: "bg-success" },
{
label: "Open",
count: 18,
amount: "$1,940",
dot: "bg-muted-foreground/50",
},
{ label: "Overdue", count: 4, amount: "$290", dot: "bg-destructive" },
],
},
{
label: "March 2025",
total: "$41,260",
delta: "+7.4% vs Feb",
plans: [
{
label: "Pro",
share: 46,
mrr: "$18,980",
tone: "stroke-foreground/85",
dot: "bg-foreground/85",
},
{
label: "Team",
share: 34,
mrr: "$14,028",
tone: "stroke-muted-foreground/50",
dot: "bg-muted-foreground/50",
},
{
label: "Enterprise",
share: 20,
mrr: "$8,252",
tone: "stroke-muted-foreground/20",
dot: "bg-muted-foreground/20",
},
],
invoices: [
{ label: "Paid", count: 231, amount: "$39,410", dot: "bg-success" },
{
label: "Open",
count: 14,
amount: "$1,620",
dot: "bg-muted-foreground/50",
},
{ label: "Overdue", count: 3, amount: "$230", dot: "bg-destructive" },
],
},
{
label: "April 2025",
total: "$44,892",
delta: "+8.8% vs Mar",
plans: [
{
label: "Pro",
share: 44,
mrr: "$19,752",
tone: "stroke-foreground/85",
dot: "bg-foreground/85",
},
{
label: "Team",
share: 35,
mrr: "$15,712",
tone: "stroke-muted-foreground/50",
dot: "bg-muted-foreground/50",
},
{
label: "Enterprise",
share: 21,
mrr: "$9,428",
tone: "stroke-muted-foreground/20",
dot: "bg-muted-foreground/20",
},
],
invoices: [
{ label: "Paid", count: 247, amount: "$42,950", dot: "bg-success" },
{
label: "Open",
count: 16,
amount: "$1,780",
dot: "bg-muted-foreground/50",
},
{ label: "Overdue", count: 2, amount: "$162", dot: "bg-destructive" },
],
},
];
type Txn = {
initials: string;
name: string;
email: string;
status: "paid" | "open" | "refunded";
date: string;
amount: string;
};
const TRANSACTIONS: readonly Txn[] = [
{
initials: "LV",
name: "Lena Voss",
email: "lena@northbeam.io",
status: "paid",
date: "Apr 28",
amount: "+$249.00",
},
{
initials: "DR",
name: "Dario Reyes",
email: "dario@quantfold.com",
status: "paid",
date: "Apr 27",
amount: "+$1,188.00",
},
{
initials: "PB",
name: "Priya Banerjee",
email: "priya@helioslab.dev",
status: "open",
date: "Apr 27",
amount: "$96.00",
},
{
initials: "TW",
name: "Tomas Weber",
email: "tomas@arcadia.app",
status: "refunded",
date: "Apr 26",
amount: "−$249.00",
},
{
initials: "AK",
name: "Amara Keita",
email: "amara@stackline.co",
status: "paid",
date: "Apr 25",
amount: "+$468.00",
},
{
initials: "HS",
name: "Hana Suzuki",
email: "hana@driftwork.com",
status: "paid",
date: "Apr 24",
amount: "+$96.00",
},
];
const STATUS_META: Record<Txn["status"], { label: string; dot: string }> = {
paid: { label: "Paid", dot: "bg-success" },
open: { label: "Open", dot: "bg-muted-foreground/50" },
refunded: { label: "Refunded", dot: "bg-destructive" },
};
function Donut({ plans }: { plans: readonly PlanSlice[] }) {
return (
<svg viewBox="0 0 42 42" aria-hidden className="size-44">
<circle
cx="21"
cy="21"
r="15.9155"
fill="none"
strokeWidth="4"
className="stroke-accent"
/>
{plans.map((p, i) => {
// Each slice starts where the previous ones ended; 25 rotates the
// first slice to 12 o'clock.
const offset =
25 - plans.slice(0, i).reduce((sum, prev) => sum + prev.share, 0);
return (
<circle
key={p.label}
cx="21"
cy="21"
r="15.9155"
fill="none"
strokeWidth="4"
strokeDasharray={`${p.share - 1} ${100 - p.share + 1}`}
strokeDashoffset={offset}
className={p.tone}
/>
);
})}
</svg>
);
}
export default function Dashboard03() {
const [monthIndex, setMonthIndex] = React.useState(MONTHS.length - 1);
const month = MONTHS[monthIndex];
return (
<section className="bg-background py-20 sm:py-28">
<div className="container w-full">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="flex max-w-xl flex-col gap-3">
<Badge variant="outline" className="w-fit">
revenue
</Badge>
<h2 className="font-serif text-4xl font-medium tracking-tight sm:text-5xl">
Where the money lands.
</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-sm border border-border">
<Button
variant="ghost"
size="icon"
className="size-8 rounded-e-none"
onClick={() => setMonthIndex((i) => Math.max(0, i - 1))}
disabled={monthIndex === 0}
aria-label="Previous month"
>
<ChevronLeft className="size-3.5 rtl:rotate-180" />
</Button>
<span className="w-32 text-center font-mono text-xs tabular-nums">
{month.label}
</span>
<Button
variant="ghost"
size="icon"
className="size-8 rounded-s-none"
onClick={() =>
setMonthIndex((i) => Math.min(MONTHS.length - 1, i + 1))
}
disabled={monthIndex === MONTHS.length - 1}
aria-label="Next month"
>
<ChevronRight className="size-3.5 rtl:rotate-180" />
</Button>
</div>
<Button variant="outline" size="sm">
<Download className="size-3.5" />
<span className="hidden sm:inline">Export</span>
</Button>
</div>
</div>
<div className="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
plan mix
</CardDescription>
<CardTitle className="sr-only">Plan mix</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-5">
<div className="relative">
<Donut plans={month.plans} />
<div className="absolute inset-0 flex flex-col items-center justify-center gap-0.5">
<span className="text-2xl font-semibold tracking-[-0.035em] tabular-nums">
{month.total}
</span>
<span className="font-mono text-[10px] uppercase tracking-[0.1em] text-success">
{month.delta}
</span>
</div>
</div>
<div className="flex w-full flex-col gap-2.5">
{month.plans.map((p) => (
<div key={p.label} className="flex items-center gap-2.5">
<span className={`size-2 rounded-xs ${p.dot}`} />
<span className="text-xs text-foreground">{p.label}</span>
<span className="ms-auto font-mono text-xs tabular-nums text-muted-foreground">
{p.share}%
</span>
<span className="w-16 text-end font-mono text-xs tabular-nums">
{p.mrr}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
invoices
</CardDescription>
<CardTitle className="sr-only">Invoices</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{month.invoices.map((inv, i) => (
<React.Fragment key={inv.label}>
{i > 0 && <Separator />}
<div className="flex items-center gap-2.5">
<span className={`size-1.5 rounded-full ${inv.dot}`} />
<span className="text-xs text-foreground">
{inv.label}
</span>
<span className="ms-auto font-mono text-xs tabular-nums text-muted-foreground">
{inv.count}
</span>
<span className="w-20 text-end font-mono text-xs tabular-nums">
{inv.amount}
</span>
</div>
</React.Fragment>
))}
</CardContent>
</Card>
</div>
<Card className="lg:col-span-2">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
transactions
</CardDescription>
<CardTitle className="text-lg">Latest activity</CardTitle>
</div>
<Button variant="link" size="sm" className="h-auto p-0" asChild>
<a href="#">View all</a>
</Button>
</div>
</CardHeader>
<CardContent className="px-0">
<div className="hidden grid-cols-[1fr_110px_70px_110px] gap-3 border-b border-border px-6 pb-2 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground sm:grid">
<span>Customer</span>
<span>Status</span>
<span>Date</span>
<span className="text-end">Amount</span>
</div>
<ul className="flex flex-col">
{TRANSACTIONS.map((t, i) => {
const status = STATUS_META[t.status];
return (
<li
key={t.email}
className={`grid grid-cols-[1fr_auto] items-center gap-3 px-6 py-3 sm:grid-cols-[1fr_110px_70px_110px] ${
i < TRANSACTIONS.length - 1
? "border-b border-border"
: ""
}`}
>
<div className="flex min-w-0 items-center gap-3">
<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-muted font-mono text-xs font-medium">
{t.initials}
</span>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{t.name}
</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{t.email}
</span>
</div>
</div>
<Badge
variant="outline"
className="hidden w-fit gap-1.5 font-normal text-muted-foreground sm:inline-flex"
>
<span
className={`size-1.5 rounded-full ${status.dot}`}
/>
{status.label}
</Badge>
<span className="hidden font-mono text-xs tabular-nums text-muted-foreground sm:inline">
{t.date}
</span>
<span
className={`text-end font-mono text-sm tabular-nums ${
t.status === "refunded"
? "text-destructive"
: t.status === "open"
? "text-muted-foreground"
: "text-foreground"
}`}
>
{t.amount}
</span>
</li>
);
})}
</ul>
<Separator />
<div className="flex items-center justify-between px-6 pt-4">
<span className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
6 of 248 transactions
</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" disabled>
<ChevronLeft className="size-3.5 rtl:rotate-180" />
Prev
</Button>
<Button variant="ghost" size="sm">
Next
<ChevronRight className="size-3.5 rtl:rotate-180" />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</section>
);
}
Dependencies
shadcn registry
badgebuttoncardseparator
npm
lucide-react