An admin dashboard is mostly three things: a row of numbers that matter, a chart that shows the trend, and a table of recent activity. In this tutorial we build all three with oks-ui and arrange them in a layout that works from phone to desktop.
import { Card } from "oks-ui/card";
import "oks-ui/card.css";
import { Stat } from "oks-ui/stat";
import "oks-ui/stat.css";
import { Chart } from "oks-ui/chart";
import "oks-ui/chart.css";
import { Table, type TableColumn } from "oks-ui/table";
import "oks-ui/table.css";
import { Chip } from "oks-ui/chip";
import "oks-ui/chip.css";Decide what goes on it first
The hardest part of a dashboard isn't the code, it's choosing what to show. A useful test: every number on the first screen should answer a question someone actually asks every day. "How much did we sell this week?" earns a card. "Total registered users since launch" usually doesn't, because it only ever goes up and nobody acts on it.
Keep the top row to four numbers. Show each one next to its change over a comparable period, because a number without context can't tell you whether today is good or bad. Put detail — the chart and the table — underneath, where people look once the top row has told them something is worth a closer look.
1. KPI cards
Stat shows a label, a big value, and an optional change with a direction. The direction colours the change pill and picks the arrow.
const kpis = [
{ label: "Revenue", value: "$48,210", delta: "+12.4%", trend: "up" },
{ label: "Orders", value: "1,284", delta: "+3.1%", trend: "up" },
{ label: "Refunds", value: "18", delta: "-2", trend: "down" },
{ label: "Conversion", value: "3.2%", delta: "0.0%", trend: "flat" },
] as const;
<div className="kpi-grid">
{kpis.map((k) => (
<Card key={k.label} shadow="sm" radius="lg" style={{ padding: 20 }}>
<Stat label={k.label} value={k.value} delta={k.delta} trend={k.trend} />
</Card>
))}
</div>Stat also takes an icon, a help line under the value, and a spark slot for a tiny chart.
2. The revenue chart
Chart takes rows of data, the key for the x-axis, and the series to draw. Multiple series get a legend automatically.
const revenue = [
{ month: "Jan", online: 12400, store: 8200 },
{ month: "Feb", online: 13900, store: 7900 },
{ month: "Mar", online: 15100, store: 8800 },
{ month: "Apr", online: 16800, store: 9100 },
{ month: "May", online: 18200, store: 9600 },
{ month: "Jun", online: 21000, store: 10400 },
];
<Card shadow="sm" radius="lg" style={{ padding: 20 }}>
<Chart
type="area"
title="Revenue"
description="Online vs. in-store, last 6 months"
data={revenue}
x="month"
series={[
{ key: "online", name: "Online" },
{ key: "store", name: "In store" },
]}
height={280}
/>
</Card>Change type to "line" or "column" and nothing else needs to change. The chart has tooltips on hover and can be browsed with the arrow keys.
3. Recent orders
Table takes column definitions and rows. A column's render function controls how a cell looks — here, a status Chip.
type Order = { id: string; customer: string; total: number; status: "Paid" | "Pending" | "Refunded" };
const STATUS_COLOR = { Paid: "success", Pending: "warning", Refunded: "default" } as const;
const columns: TableColumn<Order>[] = [
{ key: "id", header: "Order" },
{ key: "customer", header: "Customer", sortable: true },
{
key: "total",
header: "Total",
align: "end",
sortable: true,
render: (o) => `$${o.total.toFixed(2)}`,
},
{
key: "status",
header: "Status",
render: (o) => (
<Chip size="sm" variant="soft" color={STATUS_COLOR[o.status]}>{o.status}</Chip>
),
},
];
<Card shadow="sm" radius="lg" style={{ padding: 20 }}>
<Table aria-label="Recent orders" columns={columns} rows={orders} getRowKey={(o) => o.id} />
</Card>Sortable columns sort on click, with no extra code.
4. The layout
A few lines of CSS grid make it responsive: four KPI cards in a row on desktop, two on phones.
.dashboard { display: grid; gap: 16px; }
.kpi-grid { display: grid; gap: 16px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.dashboard-main { display: grid; gap: 16px; grid-template-columns: 2fr 1fr; }
@media (max-width: 900px) {
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-main { grid-template-columns: 1fr; }
}Put the chart in the wider column of .dashboard-main and a smaller card — top products, or a donut chart — in the narrow one.
5. Loading states
Dashboards load data, and blank cards while waiting look broken. Table shows skeleton rows with isLoading, and Chart shows a loading overlay with the same prop:
<Table aria-label="Recent orders" columns={columns} rows={orders ?? []} getRowKey={(o) => o.id} isLoading={!orders} />
<Chart type="area" data={revenue ?? []} x="month" series="online" isLoading={!revenue} />Dark mode
Every component here uses oks-ui's design tokens, so the whole dashboard switches to dark mode when you set data-theme="dark" on the html element — cards, chart, table and chips included.
See a complete version on the patterns page.



