Chart Dashboard in React: Recharts, Filters, Export to PNG
Build a real chart dashboard in React using Recharts — add date filters, a dark/light toggle, and one-click PNG export. No fluff, just working code.
Why Recharts Is the Right Choice for React Dashboards
Honestly, the chart library ecosystem for React is a mess. You've got D3 — which is magnificent but requires a PhD to customize. You've got Chart.js wrappers that leak imperative APIs into your React tree. Then there's Recharts, which just works the way React works: composable components, props-driven data, and nothing fighting your state management.
Recharts v2.12 ships as a set of SVG-based components that you compose like JSX. A <LineChart> wraps <XAxis>, <YAxis>, <Tooltip>, and <Line> elements. It's declarative all the way down. The bundle weighs around 320 KB before tree-shaking, and with Next.js dynamic imports you can drop the initial load impact significantly.
One thing to know upfront: Recharts relies on ResizeObserver for responsive sizing. Wrap your charts in a parent div with width: 100% and use <ResponsiveContainer> — not the fixed-dimension chart components — or you'll spend an afternoon debugging why everything looks fine on your MacBook and broken on a 1280px display.
Project Setup: Recharts, html2canvas, and Tailwind v4
Start clean. Assuming a Next.js 15 project with App Router and Tailwind v4.0.2 already configured:
npm install recharts html2canvas
npm install --save-dev @types/html2canvasThat's two packages. recharts handles all the chart rendering. html2canvas turns a DOM node into a canvas element so you can export it as a PNG — no server-side dependencies, no external API calls. For Tailwind v4, the CSS-first config lives in your app/globals.css via @import "tailwindcss". If you're still on v3, the tailwind.config.ts approach works identically with this code.
Create a /components/dashboard folder. You'll build three files: ChartCard.tsx (the wrapper with the export button), RevenueChart.tsx (a composed Recharts line+bar combo), and FilterBar.tsx (date range + metric selector). Keeping them separate means you can swap individual charts without touching the filter logic.
Building the RevenueChart Component with Recharts
Here's the chart component itself. It accepts a data array and a range string, renders a ComposedChart with both a Bar (revenue) and a Line (conversion rate on a secondary axis), and uses Tailwind classes for the container styling.
// components/dashboard/RevenueChart.tsx
'use client';
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface DataPoint {
month: string;
revenue: number;
conversion: number;
}
interface RevenueChartProps {
data: DataPoint[];
isDark?: boolean;
}
export function RevenueChart({ data, isDark = false }: RevenueChartProps) {
const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const textColor = isDark ? '#a1a1aa' : '#52525b';
return (
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 8, right: 24, left: 0, bottom: 0 }}>
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tick={{ fill: textColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
yAxisId="revenue"
tick={{ fill: textColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
/>
<YAxis
yAxisId="conversion"
orientation="right"
tick={{ fill: textColor, fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
background: isDark ? '#18181b' : '#fff',
border: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: '8px',
fontSize: '13px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px', color: textColor }} />
<Bar yAxisId="revenue" dataKey="revenue" fill="#6366f1" radius={[4, 4, 0, 0]} />
<Line
yAxisId="conversion"
type="monotone"
dataKey="conversion"
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3, fill: '#f59e0b' }}
/>
</ComposedChart>
</ResponsiveContainer>
);
}A few things worth noting. The dual YAxis setup — one for revenue on the left, one for conversion percentage on the right — uses yAxisId as the linking prop. Every Bar and Line must declare which axis it belongs to or Recharts silently renders nothing. The CartesianGrid with vertical={false} keeps horizontal reference lines without the noisy vertical grid that makes charts look cheap.
Filter Bar: Date Range and Metric Selector
Filters are where dashboards live or die. A chart without context is just a pretty picture. The filter bar here gives users a date range selector (7d / 30d / 90d / YTD) and a metric toggle between Revenue, Orders, and Sessions.
// components/dashboard/FilterBar.tsx
'use client';
export type DateRange = '7d' | '30d' | '90d' | 'ytd';
export type Metric = 'revenue' | 'orders' | 'sessions';
interface FilterBarProps {
range: DateRange;
metric: Metric;
onRangeChange: (r: DateRange) => void;
onMetricChange: (m: Metric) => void;
}
const RANGES: { label: string; value: DateRange }[] = [
{ label: '7D', value: '7d' },
{ label: '30D', value: '30d' },
{ label: '90D', value: '90d' },
{ label: 'YTD', value: 'ytd' },
];
const METRICS: { label: string; value: Metric }[] = [
{ label: 'Revenue', value: 'revenue' },
{ label: 'Orders', value: 'orders' },
{ label: 'Sessions', value: 'sessions' },
];
export function FilterBar({ range, metric, onRangeChange, onMetricChange }: FilterBarProps) {
return (
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex rounded-lg border border-zinc-200 dark:border-zinc-700 overflow-hidden">
{RANGES.map((r) => (
<button
key={r.value}
onClick={() => onRangeChange(r.value)}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
range === r.value
? 'bg-indigo-600 text-white'
: 'bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
{r.label}
</button>
))}
</div>
<select
value={metric}
onChange={(e) => onMetricChange(e.target.value as Metric)}
className="px-3 py-1.5 text-sm rounded-lg border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300"
>
{METRICS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
);
}State lives one level up in the dashboard page — FilterBar is a controlled component that fires callbacks. This makes the data-fetching logic (or mock data slice selection) easy to keep in one place. If you're wiring this to a real API, you'd pass range and metric into a useQuery call and let the server handle aggregation.
Notice the button group uses a shared border on the container div rather than individual borders on each button. It avoids the double-border problem between adjacent buttons without any CSS trickery. Small thing, but you notice it immediately if you get it wrong.
Export to PNG with html2canvas
One-click chart export is a feature that product managers always ask for and engineers underestimate. html2canvas makes it genuinely simple — point it at a DOM node, get a canvas back, trigger a download. The only tricky part is that html2canvas doesn't handle backdrop-filter or certain CSS gradients, so keep your chart container's background explicit and solid.
// Inside ChartCard.tsx
import html2canvas from 'html2canvas';
import { useRef } from 'react';
export function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
const cardRef = useRef<HTMLDivElement>(null);
async function handleExport() {
if (!cardRef.current) return;
const canvas = await html2canvas(cardRef.current, {
backgroundColor: '#ffffff',
scale: 2, // 2x for retina-quality PNG
useCORS: true,
logging: false,
});
const link = document.createElement('a');
link.download = `${title.replace(/\s+/g, '-').toLowerCase()}-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
}
return (
<div
ref={cardRef}
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-6"
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-zinc-900 dark:text-zinc-100">{title}</h3>
<button
onClick={handleExport}
className="text-xs px-3 py-1 rounded-md bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
Export PNG
</button>
</div>
{children}
</div>
);
}The scale: 2 option is non-negotiable if you're targeting retina screens — the default produces a blurry 72 DPI image that looks awful in presentations. The filename includes a timestamp so repeated exports don't overwrite each other in the user's Downloads folder. Small quality-of-life detail that costs nothing.
Dark Mode and Theme Toggle Integration
Dashboards are used in dim office environments as much as bright ones. Dark mode isn't optional anymore. The cleanest approach in Next.js 15 with Tailwind v4 is the dark class strategy: add class="dark" to the <html> element and let Tailwind's dark: variants do the rest.
You can wire this to a theme toggle component that persists the preference to localStorage. The isDark boolean you pass into RevenueChart controls the SVG-level colors (tooltip background, axis tick color) that Tailwind can't reach because Recharts renders into SVG, not the HTML class tree. Those two color values — rgba(255,255,255,0.08) for dark grid lines and rgba(0,0,0,0.08) for light — cover the vast majority of chart backgrounds without needing a full theming system.
If your dashboard has multiple chart types, consider putting isDark into a context rather than prop-drilling it through every chart card. A single useDarkMode() hook that reads from context keeps the chart components themselves clean. You might also look at how animated tabs in React handle active-state tracking — the same pattern (shared state + callback) applies here for the filter buttons.
Composing the Full Dashboard Page
With the three pieces built, the page itself is straightforward. State for range and metric lives at the page level. A useMemo derives the filtered dataset from your source data. The layout is a CSS Grid — two columns on desktop, single column on mobile.
// app/dashboard/page.tsx
'use client';
import { useState, useMemo } from 'react';
import { FilterBar, DateRange, Metric } from '@/components/dashboard/FilterBar';
import { ChartCard } from '@/components/dashboard/ChartCard';
import { RevenueChart } from '@/components/dashboard/RevenueChart';
import { allData } from '@/lib/mock-data'; // your data source
export default function DashboardPage() {
const [range, setRange] = useState<DateRange>('30d');
const [metric, setMetric] = useState<Metric>('revenue');
const [isDark, setIsDark] = useState(false);
const data = useMemo(() => {
const limits: Record<DateRange, number> = { '7d': 7, '30d': 30, '90d': 90, ytd: 365 };
return allData.slice(-limits[range]);
}, [range]);
return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950 p-6 lg:p-10">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-100">Analytics</h1>
<button onClick={() => setIsDark(!isDark)} className="text-sm px-4 py-2 rounded-lg border border-zinc-200 dark:border-zinc-700">
{isDark ? 'Light' : 'Dark'} Mode
</button>
</div>
<FilterBar
range={range}
metric={metric}
onRangeChange={setRange}
onMetricChange={setMetric}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChartCard title="Revenue vs Conversion">
<RevenueChart data={data} isDark={isDark} />
</ChartCard>
<ChartCard title="Session Trends">
{/* another chart here */}
</ChartCard>
</div>
</div>
</main>
);
}The 8px gap between stat cards at the top of real dashboards (gap-2) versus the 24px gap between chart cards (gap-6) is one of those spacing decisions that immediately distinguishes a designed dashboard from a developer prototype. The numbers matter.
Need more visual variety between chart sections? Wrapping individual stat tiles in a bento grid layout pairs extremely well with full-width Recharts panels — the asymmetric cell sizes let you rank information hierarchy visually instead of relying entirely on typography.
Performance: Avoid Re-rendering on Every Filter Change
Here's something that'll bite you in production. Recharts re-renders the entire SVG tree on every state change. With 12 months of daily data points (365 points) and 4 charts on screen, that's 1,460 SVG element trees updating simultaneously. On mid-range Android devices it stutters.
The fix is React.memo on each chart component plus useMemo for the derived data (which you already have). The Recharts isAnimationActive={false} prop also helps — the default CSS animations run on every render, not just the initial mount, which doubles the rendering cost during rapid filter changes. Disable animations on the data-driven elements (bars, lines) and keep them only on the initial load if you need them at all.
Why does this matter? Because dashboard users change filters obsessively. They're comparing 30d vs 7d vs 90d, not admiring a single static view. The interaction latency is part of the product experience. Keep it under 16ms per filter change and you'll feel the difference immediately.
FAQ
No. Recharts uses browser APIs (ResizeObserver, SVG DOM) and requires 'use client'. Mark your chart components with 'use client' at the top and import them into Server Components as you normally would — the server renders a shell, the client hydrates the charts.
Always place ResponsiveContainer inside a parent div with an explicit width (width: 100%) and either an explicit height or a height inherited from a flex/grid parent. ResponsiveContainer reads the parent's dimensions — if the parent has no constrained height, it'll keep growing. A fixed height prop on ResponsiveContainer itself (e.g., height={320}) is the most reliable option.
Most commonly it's a CORS issue with external images, or backdrop-filter CSS that html2canvas can't rasterize. Set useCORS: true in the options and replace any backdrop-filter backgrounds on the chart container with a solid background color. Also set scale: 2 to avoid blurry output on retina displays.
Yes. Recharts renders actual SVG, so you can grab the SVG node directly: const svg = cardRef.current.querySelector('svg'); then serialize it with new XMLSerializer().serializeToString(svg) and create a Blob with type 'image/svg+xml'. This avoids the html2canvas dependency entirely and gives you a vector file.
LineChart and BarChart are convenience wrappers that only accept their respective data series types. ComposedChart is the general-purpose container — it accepts Bar, Line, Area, and Scatter children in any combination. Use ComposedChart whenever you're mixing series types (e.g., bars for revenue plus a line for conversion rate).
Render a div with the same dimensions as your chart (e.g., h-[320px]) and apply Tailwind's animate-pulse with a bg-zinc-200 dark:bg-zinc-700 background while the data is loading. Swap it out for the RevenueChart component once data arrives. Avoid using Suspense boundaries directly around Recharts components — the flash of the SVG canvas during hydration looks worse than a skeleton.