Loading
Create a data dashboard with charts, filters, and responsive layout — from data fetching to polished UI.
In this tutorial, you'll build a developer activity dashboard that displays metrics, charts, and filterable data. You'll learn component composition, state management, responsive design, and data visualization patterns.
What you'll learn:
Before writing code, sketch the layout:
Components we'll build:
MetricCard — Single stat with label and trendActivityChart — Bar chart for daily activityActivityTable — Filterable table of recent itemsCategoryBreakdown — Category summary with progress barsDashboardLayout — The overall layout wrapperPlanning the layout first prevents rework later.
Create src/lib/dashboard-data.ts:
Typed sample data lets us build UI without waiting for a backend.
Usage:
Reusable metric cards display key stats at a glance.
No chart library needed — CSS can handle bar charts:
CSS bar charts are lightweight and work without any dependencies.
Filtered tables are one of the most common dashboard patterns.
Progress bars give immediate visual feedback on data distribution.
Put all components together:
Component composition keeps each piece simple while building complex UIs.
Key responsive patterns used:
Test at these breakpoints:
Use Chrome DevTools device toolbar (Ctrl+Shift+M) to test each size.
Mobile-first means designing for the smallest screen first, then adding complexity.
Every data-driven component needs a loading state:
Skeletons that match the real layout feel faster than spinners.
Skeleton loading states communicate progress without blocking the UI.
Easy:
Medium:
Hard:
You've built a professional-grade dashboard. This same pattern powers analytics tools everywhere.
What you built: A responsive developer activity dashboard with metric cards, bar charts, filtered tables, and category breakdowns — built entirely with React and Tailwind CSS.
┌─────────────────────────────────────────┐
│ Header (title + date range picker) │
├──────────┬──────────┬──────────┬────────┤
│ Metric │ Metric │ Metric │ Metric │
│ Card │ Card │ Card │ Card │
├──────────┴──────────┴──────────┴────────┤
│ Activity Chart (line/bar) │
├─────────────────────┬───────────────────┤
│ Recent Activity │ Top Categories │
│ (table) │ (breakdown) │
└─────────────────────┴───────────────────┘export interface DailyActivity {
date: string;
commits: number;
pullRequests: number;
reviews: number;
issues: number;
}
export interface ActivityItem {
id: string;
type: "commit" | "pr" | "review" | "issue";
title: string;
repo: string;
timestamp: string;
}
export interface CategoryStat {
name: string;
count: number;
color: string;
}
// Generate 30 days of sample data
export function generateDailyData(): DailyActivity[] {
const data: DailyActivity[] = [];
const now = new Date();
for (let i = 29; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
data.push({
date: date.toISOString().split("T")[0],
commits: isWeekend ? Math.floor(Math.random() * 5) : Math.floor(Math.random() * 15) + 3,
pullRequests: Math.floor(Math.random() * 4),
reviews: Math.floor(Math.random() * 6),
issues: Math.floor(Math.random() * 3),
});
}
return data;
}
export function generateRecentActivity(): ActivityItem[] {
const types = ["commit", "pr", "review", "issue"] as const;
const repos = ["frontend", "api", "docs", "infra", "mobile"];
const titles = {
commit: [
"Fix auth token refresh",
"Add dark mode toggle",
"Update dependencies",
"Refactor user service",
"Add input validation",
],
pr: [
"feat: Add user dashboard",
"fix: Resolve memory leak",
"refactor: Extract hooks",
"docs: Update API guide",
],
review: [
"Review: Auth middleware",
"Review: Database schema",
"Review: CI pipeline",
"Review: Error handling",
],
issue: ["Bug: Login fails on Safari", "Feature: Export to CSV", "Perf: Slow dashboard load"],
};
return Array.from({ length: 20 }, (_, i) => {
const type = types[Math.floor(Math.random() * types.length)];
const titleList = titles[type];
return {
id: `act_${i}`,
type,
title: titleList[Math.floor(Math.random() * titleList.length)],
repo: repos[Math.floor(Math.random() * repos.length)],
timestamp: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString(),
};
}).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
export function getCategoryStats(data: DailyActivity[]): CategoryStat[] {
const totals = data.reduce(
(acc, day) => ({
commits: acc.commits + day.commits,
pullRequests: acc.pullRequests + day.pullRequests,
reviews: acc.reviews + day.reviews,
issues: acc.issues + day.issues,
}),
{ commits: 0, pullRequests: 0, reviews: 0, issues: 0 }
);
return [
{ name: "Commits", count: totals.commits, color: "#10b981" },
{ name: "Pull Requests", count: totals.pullRequests, color: "#06b6d4" },
{ name: "Reviews", count: totals.reviews, color: "#8b5cf6" },
{ name: "Issues", count: totals.issues, color: "#f59e0b" },
];
}interface MetricCardProps {
label: string;
value: number;
previousValue?: number;
icon: string;
color: string;
}
function MetricCard({ label, value, previousValue, icon, color }: MetricCardProps) {
const change = previousValue ? Math.round(((value - previousValue) / previousValue) * 100) : null;
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-[var(--color-text-secondary)]">{label}</span>
<span className="text-xl">{icon}</span>
</div>
<div className="text-3xl font-semibold text-[var(--color-text-primary)]">
{value.toLocaleString()}
</div>
{change !== null && (
<div className="mt-2 flex items-center gap-1 text-sm">
<span style={{ color: change >= 0 ? "#10b981" : "#ef4444" }}>
{change >= 0 ? "+" : ""}
{change}%
</span>
<span className="text-[var(--color-text-muted)]">vs last period</span>
</div>
)}
</div>
);
}<MetricCard label="Commits" value={142} previousValue={128} icon="📝" color="#10b981" />interface ActivityChartProps {
data: DailyActivity[];
}
function ActivityChart({ data }: ActivityChartProps) {
const maxValue = Math.max(...data.map((d) => d.commits + d.pullRequests));
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<h3 className="mb-4 text-lg font-semibold text-[var(--color-text-primary)]">
Daily Activity
</h3>
<div className="flex items-end gap-1" style={{ height: "200px" }}>
{data.map((day) => {
const total = day.commits + day.pullRequests;
const height = maxValue > 0 ? (total / maxValue) * 100 : 0;
const commitPct = total > 0 ? (day.commits / total) * 100 : 0;
return (
<div key={day.date} className="group relative flex-1" style={{ height: "100%" }}>
{/* Tooltip */}
<div className="pointer-events-none absolute -top-12 left-1/2 z-10 hidden -translate-x-1/2 rounded bg-gray-900 px-2 py-1 text-xs text-white group-hover:block">
{day.date}: {total}
</div>
{/* Bar */}
<div
className="absolute bottom-0 w-full overflow-hidden rounded-t transition-all hover:opacity-80"
style={{ height: `${height}%` }}
>
<div
className="absolute bottom-0 w-full bg-emerald-500"
style={{ height: `${commitPct}%` }}
/>
<div
className="absolute top-0 w-full bg-cyan-400"
style={{ height: `${100 - commitPct}%` }}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex gap-4 text-xs text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
Commits
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-cyan-400" />
Pull Requests
</span>
</div>
</div>
);
}"use client";
import { useState, useMemo } from "react";
import type { ActivityItem } from "@/lib/dashboard-data";
interface ActivityTableProps {
items: ActivityItem[];
}
function ActivityTable({ items }: ActivityTableProps) {
const [filter, setFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
return items.filter((item) => {
if (filter !== "all" && item.type !== filter) return false;
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [items, filter, search]);
const typeColors: Record<string, string> = {
commit: "bg-emerald-500/20 text-emerald-400",
pr: "bg-cyan-500/20 text-cyan-400",
review: "bg-purple-500/20 text-purple-400",
issue: "bg-amber-500/20 text-amber-400",
};
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">Recent Activity</h3>
<div className="flex gap-2">
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-3 py-1.5 text-sm outline-none focus:border-emerald-500"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-bg-primary)] px-3 py-1.5 text-sm"
>
<option value="all">All</option>
<option value="commit">Commits</option>
<option value="pr">PRs</option>
<option value="review">Reviews</option>
<option value="issue">Issues</option>
</select>
</div>
</div>
<div className="space-y-2">
{filtered.slice(0, 10).map((item) => (
<div
key={item.id}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/5"
>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${typeColors[item.type]}`}
>
{item.type}
</span>
<span className="flex-1 truncate text-sm text-[var(--color-text-primary)]">
{item.title}
</span>
<span className="text-xs text-[var(--color-text-muted)]">{item.repo}</span>
</div>
))}
</div>
</div>
);
}interface CategoryBreakdownProps {
categories: CategoryStat[];
}
function CategoryBreakdown({ categories }: CategoryBreakdownProps) {
const total = categories.reduce((sum, c) => sum + c.count, 0);
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<h3 className="mb-4 text-lg font-semibold text-[var(--color-text-primary)]">By Category</h3>
<div className="space-y-4">
{categories.map((cat) => {
const pct = total > 0 ? Math.round((cat.count / total) * 100) : 0;
return (
<div key={cat.name}>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-[var(--color-text-primary)]">{cat.name}</span>
<span className="text-[var(--color-text-muted)]">
{cat.count} ({pct}%)
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${pct}%`,
backgroundColor: cat.color,
}}
/>
</div>
</div>
);
})}
</div>
</div>
);
}"use client";
import { useMemo } from "react";
import { generateDailyData, generateRecentActivity, getCategoryStats } from "@/lib/dashboard-data";
export default function Dashboard() {
const dailyData = useMemo(() => generateDailyData(), []);
const activity = useMemo(() => generateRecentActivity(), []);
const categories = useMemo(() => getCategoryStats(dailyData), [dailyData]);
const thisWeek = dailyData.slice(-7);
const lastWeek = dailyData.slice(-14, -7);
const sum = (data: typeof dailyData, key: keyof (typeof dailyData)[0]) =>
data.reduce((s, d) => s + (d[key] as number), 0);
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<h1 className="text-3xl font-semibold text-[var(--color-text-primary)]">
Developer Dashboard
</h1>
{/* Metric cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="Commits"
value={sum(thisWeek, "commits")}
previousValue={sum(lastWeek, "commits")}
icon="📝"
color="#10b981"
/>
<MetricCard
label="Pull Requests"
value={sum(thisWeek, "pullRequests")}
previousValue={sum(lastWeek, "pullRequests")}
icon="🔀"
color="#06b6d4"
/>
<MetricCard
label="Reviews"
value={sum(thisWeek, "reviews")}
previousValue={sum(lastWeek, "reviews")}
icon="👁"
color="#8b5cf6"
/>
<MetricCard
label="Issues"
value={sum(thisWeek, "issues")}
previousValue={sum(lastWeek, "issues")}
icon="🐛"
color="#f59e0b"
/>
</div>
{/* Chart */}
<ActivityChart data={dailyData} />
{/* Bottom row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ActivityTable items={activity} />
</div>
<CategoryBreakdown categories={categories} />
</div>
</div>
);
}/* Metric cards: 1 column on mobile, 2 on tablet, 4 on desktop */
grid-cols-1 sm:grid-cols-2 lg:grid-cols-4
/* Bottom row: stacked on mobile, 2/3 + 1/3 on desktop */
grid-cols-1 lg:grid-cols-3
/* Table header: stacked on mobile, inline on tablet+ */
flex-col sm:flex-rowfunction MetricCardSkeleton() {
return (
<div className="animate-pulse rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<div className="mb-3 h-4 w-20 rounded bg-white/10" />
<div className="h-8 w-16 rounded bg-white/10" />
<div className="mt-2 h-3 w-24 rounded bg-white/10" />
</div>
);
}
function ChartSkeleton() {
return (
<div className="animate-pulse rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-surface)] p-5">
<div className="mb-4 h-5 w-32 rounded bg-white/10" />
<div className="flex items-end gap-1" style={{ height: "200px" }}>
{Array.from({ length: 30 }, (_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-white/5"
style={{ height: `${20 + Math.random() * 60}%` }}
/>
))}
</div>
</div>
);
}