Modern Dashboards with Tailwind and TypeScript: From Empty Table to Decision Tool
Most dashboards are beautiful and useless. What I learned building the admin panel for a commerce platform — about ordering numbers, designing empty states, and making cards clickable.
Mehran Hatami6 min readTailwind CSSTypeScriptDashboardsThe first dashboard of every project looks the same: four stat cards along the top, a chart, a table. The trouble starts when a user sees the total revenue figure, suspects it's wrong, clicks it — and nothing happens. In that moment the dashboard stops being a tool and becomes a poster.
Write the decision before you write the component
Every number on a dashboard should link to somewhere the user can act on it. A number with no destination is decoration.
Before any code I write this sentence: "The user opens this page to decide whether…". If I can't finish it, the page isn't ready to be built. For the store panel the answer was: "to find out what needs their attention today." That single sentence reordered the whole page — pending orders moved to the top and the annual revenue chart moved to the bottom.
A stat card needs a destination
This is the simplest improvement you can make to any dashboard and it pays the most: make every stat card a link to the thing it counted. A card reading "12 orders pending" should open the list of those twelve orders with the filter already applied. The user immediately understands where the number came from and can verify it when it looks off.
export function StatCard({ label, value, href }: {
label: string; value: string; href?: string;
}) {
const inner = (
<>
<p className="text-sm text-foreground/60">{label}</p>
<p className="mt-1 text-2xl font-bold tabular-nums">{value}</p>
</>
);
if (!href) return <div className="rounded-card p-5">{inner}</div>;
return (
<Link
href={href}
className="rounded-card p-5 transition hover:bg-foreground/5
focus-visible:outline focus-visible:outline-2"
>
{inner}
</Link>
);
}One small detail with outsized effect: tabular-nums. Without it, figures jitter on every refresh because digits have different widths. With it, the column holds still and the whole page feels calmer.
The empty state is the most serious state
Every new user sees it first. An empty table saying "No data available" tells them the product is broken. The same table with a sentence of context and an "Add your first product" button tells them what to do next. Design empty states like a guide, not like an error.
The same logic applies to loading. A skeleton shaped like the eventual content beats a spinner in the middle of the screen, because the eye finds the layout in advance and nothing shifts when the data lands.
Spend TypeScript where mistakes are expensive
In a dashboard the costly bugs are about units and kinds: a currency swapped for its subunit, a percentage passed where a ratio was expected, a date in the wrong calendar. Branded types catch those at compile time and cost a handful of lines.
type Toman = number & { readonly __brand: "Toman" };
type Ratio = number & { readonly __brand: "Ratio" }; // 0..1
const toman = (n: number) => n as Toman;
const ratio = (n: number) => n as Ratio;
function formatPercent(r: Ratio) {
return `${(r * 100).toFixed(1)}%`;
}
formatPercent(ratio(0.42)); // ✅ "42.0%"
// formatPercent(toman(42)); // ❌ compile errorWith Tailwind, build tokens instead of repeating classes
Tailwind tempts you to write rounded-xl border border-white/10 bg-white/5 backdrop-blur everywhere. By the twentieth occurrence, restyling your cards means grepping the codebase. The fix is small: define colours and radii as CSS variables and register them in the Tailwind theme. After that, changing one variable changes the entire dashboard.
:root {
--surface: 0 0% 100% / 0.04;
--hairline: 0 0% 100% / 0.10;
--r-md: 0.875rem;
}
.card {
background: hsl(var(--surface));
border: 1px solid hsl(var(--hairline));
border-radius: var(--r-md);
}Things users always want and we always forget
- A way to delete test data. Everyone starts with a few dummy orders; if they can't remove them, the statistics stay wrong forever.
- An active state on every nav item. Without it people get lost in multi-level panels.
- Confirmation for irreversible actions — but only for those. Confirming everything trains users to click through blindly.
- A visible "last updated" timestamp. Otherwise nobody knows whether the number is live or yesterday's cache.
The final test
A good dashboard isn't the one showing the most information. It's the one where, ten seconds in, the user knows what to do today. If getting to that answer takes three scrolls and two menus, the problem isn't the data — it's the order.
