Analytics Dashboard in React: Charts, KPIs, Date Range Picker
Build a production-ready analytics dashboard in React with recharts, KPI cards, and a date range picker — from data wiring to responsive layout in one guide.
What You're Actually Building
Analytics dashboards are where front-end work gets humbling fast. You think you'll spend 20% of the time on charts and 80% on layout. It's usually the reverse — and that's before the product manager asks for a date range picker on Friday afternoon.
This guide builds a real, deployable dashboard: KPI cards at the top, a multi-series line chart below, a bar chart for channel breakdown, and a controlled date range picker that filters all of them simultaneously. No toy examples. The stack is React 18, recharts 2.12, and Tailwind CSS — the same combination used in the Empire UI component library.
Worth noting: we're assuming a Next.js 14 App Router project, but everything here works identically in Vite + React. The only difference is where you put 'use client' directives.
One more thing — if you want the visual polish handled for you, check out Empire UI's glassmorphism components — the KPI card pattern maps directly onto those glass surfaces and looks genuinely great with zero extra work.
Project Structure and Data Shape
Before any chart renders, you need a consistent data contract. Inconsistent timestamps are the silent killer of analytics UIs — I've seen dashboards that looked fine in dev, then exploded in prod because the API returned Unix seconds while recharts expected ISO strings.
Here's the data shape we'll use throughout. Keep it flat and typed from day one:
// types/analytics.ts
export interface DailyMetric {
date: string; // ISO 8601: '2026-08-01'
revenue: number; // in cents to avoid float rounding
sessions: number;
conversions: number;
}
export interface ChannelBreakdown {
channel: 'organic' | 'paid' | 'direct' | 'referral';
sessions: number;
revenue: number;
}
export interface DateRange {
from: Date;
to: Date;
}Keep revenue in cents. Formatting to dollars is a display concern, not a data concern. This one decision saves you from subtle float-addition bugs when you're summing 30 days of metrics at 3am before a launch.
Structure your dashboard folder like this: components/dashboard/KPICard.tsx, components/dashboard/RevenueChart.tsx, components/dashboard/ChannelChart.tsx, components/dashboard/DateRangePicker.tsx, and a parent components/dashboard/Dashboard.tsx that owns all state. Single source of truth for dateRange — everything subscribes, nothing duplicates.
KPI Cards: The Metric Tiles at the Top
KPI cards are deceptively tricky. The math is simple — current period value, previous period value, delta percentage. The UX is where people slip up. A -12% delta on sessions should be red. A -12% delta on bounce rate should be green. You need a positiveDirection prop so the card knows which way is good.
// components/dashboard/KPICard.tsx
'use client';
interface KPICardProps {
label: string;
value: string; // pre-formatted: '$12,400' or '1,203'
delta: number; // e.g. 8.4 for +8.4%
positiveDirection: 'up' | 'down'; // 'down' for cost/bounce
}
export function KPICard({ label, value, delta, positiveDirection }: KPICardProps) {
const isGood =
positiveDirection === 'up' ? delta >= 0 : delta <= 0;
return (
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-5 flex flex-col gap-3">
<p className="text-sm text-zinc-400 font-medium tracking-wide uppercase">
{label}
</p>
<p className="text-3xl font-bold text-white tabular-nums">{value}</p>
<span
className={[
'text-sm font-semibold',
isGood ? 'text-emerald-400' : 'text-rose-400',
].join(' ')}
>
{delta >= 0 ? '+' : ''}{delta.toFixed(1)}% vs last period
</span>
</div>
);
}The tabular-nums class is non-negotiable. Without it, numbers shift left and right as they update and it looks broken even if nothing is broken. Set it once, forget about it.
Render four of these in a grid grid-cols-2 lg:grid-cols-4 gap-4 container. On mobile, two-per-row is readable. Four-in-a-row on desktop gives you the classic dashboard feel without cramming 180px cards together.
Honestly, these cards look incredible with a glass surface behind them. If your dashboard has a dark gradient background — which it probably should for contrast — the bg-white/5 backdrop-blur-md border-white/10 combo gives you genuine depth for about 4 extra Tailwind classes. Empire UI's glassmorphism generator lets you dial in the exact blur and opacity values before committing to code.
Line Chart with Recharts: Revenue Over Time
Recharts 2.12 is still the React charting library. It's not the fastest (Victory.js and Visx edge it out for raw perf), but it's the most ergonomic and its defaults look decent without a theme. Install it: npm i recharts@2.12.
// components/dashboard/RevenueChart.tsx
'use client';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import type { DailyMetric } from '@/types/analytics';
interface RevenueChartProps {
data: DailyMetric[];
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const formatRevenue = (cents: number) =>
`$${(cents / 100).toLocaleString('en-US', { maximumFractionDigits: 0 })}`;
export function RevenueChart({ data }: RevenueChartProps) {
return (
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-6">
<h2 className="text-base font-semibold text-zinc-300 mb-4">Revenue</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fill: '#a1a1aa', fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={formatRevenue}
tick={{ fill: '#a1a1aa', fontSize: 12 }}
axisLine={false}
tickLine={false}
width={72}
/>
<Tooltip
formatter={(v: number) => [formatRevenue(v), 'Revenue']}
labelFormatter={formatDate}
contentStyle={{
background: 'rgba(24,24,27,0.9)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
color: '#e4e4e7',
}}
/>
<Line
type="monotone"
dataKey="revenue"
stroke="#a78bfa"
strokeWidth={2.5}
dot={false}
activeDot={{ r: 5, fill: '#a78bfa' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}Two things people always get wrong here: the YAxis width prop and tooltip styling. If you don't set width={72} (or whatever fits your formatted labels), recharts clips the tick labels and you get $12,4... on a 1920px monitor. Set it. For the tooltip, the default white background is jarring on dark UIs — the contentStyle override above matches the glass aesthetic.
Quick aside: dot={false} on the Line is a performance win. With 90 days of daily data, rendering 90 SVG circles on every animation frame adds up. Hide dots, show the activeDot only on hover. Users don't need to see every data point — they need to see the trend.
In practice, you'll want to add a second Line for sessions or conversions so the chart actually shows comparison data. Just add <Line dataKey="sessions" stroke="#34d399" ... /> and recharts handles the Legend automatically.
Bar Chart for Channel Breakdown
Bar charts work better than line charts for categorical data — channels, devices, countries. Don't use a line chart here just because you have one on screen already. The visual language matters.
// components/dashboard/ChannelChart.tsx
'use client';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import type { ChannelBreakdown } from '@/types/analytics';
const CHANNEL_COLORS: Record<string, string> = {
organic: '#a78bfa',
paid: '#34d399',
direct: '#fb923c',
referral:'#38bdf8',
};
interface ChannelChartProps {
data: ChannelBreakdown[];
}
export function ChannelChart({ data }: ChannelChartProps) {
return (
<div className="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md p-6">
<h2 className="text-base font-semibold text-zinc-300 mb-4">
Sessions by Channel
</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="channel"
tick={{ fill: '#a1a1aa', fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis hide />
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.04)' }}
contentStyle={{
background: 'rgba(24,24,27,0.9)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
color: '#e4e4e7',
}}
/>
<Bar dataKey="sessions" radius={[6, 6, 0, 0]}>
{data.map((entry) => (
<Cell
key={entry.channel}
fill={CHANNEL_COLORS[entry.channel] ?? '#a1a1aa'}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}The radius={[6, 6, 0, 0]} gives bars rounded top corners — a tiny detail that makes the chart feel like a 2026 design instead of a 2014 BI tool. Set it. The YAxis is hidden here because with a bar chart and good color contrast, the absolute numbers in the tooltip are enough. Showing a Y-axis would just add visual noise.
Look, channel charts at this size don't need a legend — there are only four bars and they're labelled on the X-axis. Adding a Legend component below the chart doubles the visual weight for no gain. Keep it off.
Date Range Picker: Filtering Everything at Once
This is the component that makes or breaks the whole dashboard. A date range picker that only affects one chart is useless. You need controlled state at the Dashboard level that flows down to every data-fetching hook.
For the picker UI, react-day-picker v8 is the cleanest option — no massive bundle overhead, composable, and it doesn't fight Tailwind. Install: npm i react-day-picker@8 date-fns@3.
// components/dashboard/DateRangePicker.tsx
'use client';
import { useState } from 'react';
import { DayPicker, DateRange as DPRange } from 'react-day-picker';
import { format, subDays } from 'date-fns';
import type { DateRange } from '@/types/analytics';
interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
}
const PRESETS = [
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 30 days', days: 30 },
{ label: 'Last 90 days', days: 90 },
];
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const handleSelect = (range: DPRange | undefined) => {
if (range?.from && range?.to) {
onChange({ from: range.from, to: range.to });
setOpen(false);
}
};
return (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5
px-4 py-2 text-sm text-zinc-300 hover:bg-white/10 transition-colors"
>
{format(value.from, 'MMM d')} — {format(value.to, 'MMM d, yyyy')}
</button>
{open && (
<div className="absolute right-0 top-11 z-50 rounded-2xl border border-white/10
bg-zinc-900/95 backdrop-blur-xl p-4 shadow-2xl">
<div className="flex gap-2 mb-3">
{PRESETS.map(({ label, days }) => (
<button
key={label}
onClick={() => {
onChange({ from: subDays(new Date(), days), to: new Date() });
setOpen(false);
}}
className="rounded-lg bg-white/10 px-3 py-1 text-xs
text-zinc-300 hover:bg-violet-500/30 transition-colors"
>
{label}
</button>
))}
</div>
<DayPicker
mode="range"
selected={{ from: value.from, to: value.to }}
onSelect={handleSelect}
numberOfMonths={2}
/>
</div>
)}
</div>
);
}The preset buttons — Last 7 days, Last 30 days, Last 90 days — handle 80% of real user interactions. Most people never touch the calendar pane. Put them front and centre.
Wire this up in the parent Dashboard component with a single useState<DateRange> and pass both value and onChange down. Every data-fetching hook receives dateRange as a dependency — when it changes, React Query (or SWR, or useEffect) re-fetches automatically. That's the clean version. The messy version is passing dateRange as a prop to charts that then do their own filtering client-side, which works but duplicates logic and misses server-side aggregation.
That said, if your data volume is under 10,000 rows per query, client-side filtering is totally fine. Filter the allData array with data.filter(d => d.date >= from && d.date <= to) and pass the slice to charts. No re-fetch needed. Simple wins.
Wiring It All Together in the Dashboard Component
The parent component owns the date range state and passes filtered data slices to each child. Here's the skeleton — the actual data fetching depends on your API, but the structure is the same whether you're using React Query, SWR, or plain fetch in a Server Component.
// components/dashboard/Dashboard.tsx
'use client';
import { useState } from 'react';
import { subDays } from 'date-fns';
import { KPICard } from './KPICard';
import { RevenueChart } from './RevenueChart';
import { ChannelChart } from './ChannelChart';
import { DateRangePicker } from './DateRangePicker';
import type { DateRange, DailyMetric, ChannelBreakdown } from '@/types/analytics';
interface DashboardProps {
metrics: DailyMetric[];
channels: ChannelBreakdown[];
}
export function Dashboard({ metrics, channels }: DashboardProps) {
const [range, setRange] = useState<DateRange>({
from: subDays(new Date(), 30),
to: new Date(),
});
const filtered = metrics.filter(
(m) => m.date >= range.from.toISOString().slice(0, 10)
&& m.date <= range.to.toISOString().slice(0, 10)
);
const totalRevenue = filtered.reduce((s, m) => s + m.revenue, 0);
const totalSessions = filtered.reduce((s, m) => s + m.sessions, 0);
return (
<div className="min-h-screen bg-gradient-to-br from-zinc-950 via-violet-950/30 to-zinc-950 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-white">Analytics</h1>
<DateRangePicker value={range} onChange={setRange} />
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<KPICard label="Revenue" value={`$${(totalRevenue / 100).toLocaleString()}`} delta={8.4} positiveDirection="up" />
<KPICard label="Sessions" value={totalSessions.toLocaleString()} delta={-3.1} positiveDirection="up" />
<KPICard label="Conv. Rate" value="3.2%" delta={0.4} positiveDirection="up" />
<KPICard label="Bounce Rate" value="41%" delta={-2.1} positiveDirection="down" />
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<RevenueChart data={filtered} />
</div>
<ChannelChart data={channels} />
</div>
</div>
);
}The lg:col-span-2 on the line chart gives it two-thirds of the width on desktop, leaving one-third for the channel bar chart. That ratio — 2:1 — is the standard analytics dashboard proportion and your eye expects it.
For the background, from-zinc-950 via-violet-950/30 to-zinc-950 gives you a dark canvas with a subtle violet bloom in the centre. Subtle. Not neon. This is a data product, not a landing page. If you want to explore more adventurous colour directions, the gradient generator lets you preview any combination before touching code.
One more thing — add a <Suspense> boundary around the whole Dashboard when you're server-fetching data. Streaming in the KPIs before the charts load gives users something to read immediately instead of staring at a skeleton for 800ms. React 18's streaming + Suspense was built for exactly this pattern.
That's the full dashboard. It's not 2,000 lines of code. It's six focused components, typed data contracts, and a single state atom for the date range. You can browse more components on Empire UI to extend it — a data table with TanStack Table, a sidebar layout, or toast notifications for live metric alerts all slot in cleanly.
FAQ
Recharts 2.x is the best balance of ergonomics and bundle size for most projects. If you need higher performance with 100k+ data points, look at Visx or uPlot. Avoid Chart.js in new React projects — the React wrappers are not well maintained.
Wrap every chart in <ResponsiveContainer width="100%" height={280}>. Never set a fixed pixel width on the chart itself — recharts measures its parent and that wrapper needs to have a defined width from the layout system, which flex and grid provide automatically.
react-day-picker v8 paired with date-fns v3 is the current standard — small bundle, Tailwind-friendly, and it doesn't impose its own styling system. For a fully headless option, Radix UI's Calendar primitive gives you total style control with zero preset opinions.
Under 10k rows, client-side filtering is fine and simpler. Above that, pass date range params to your API and return pre-aggregated data — the chart doesn't need every raw event, it needs daily/weekly rollups. Server-side aggregation also lets databases do what they're good at.