
Creating Interactive Charts in React with Recharts
Most dashboards start with a request like "show revenue over the last 30 days." Drawing that with raw SVG means calculating scales, axes, ticks, and hover positions by hand. Pulling in a canvas-based charting library means working around an imperative API that doesn't fit React's model of rendering from state.
Recharts sits in the middle. It's a charting library built on React and D3's math utilities, where every part of a chart, the axes, the grid, the tooltip, each data series, is a React component you compose declaratively. If you can write JSX, you can build a chart, and because it's all components, the chart updates whenever your state does.
This guide covers the essentials and the interactive parts that make charts useful: responsive line, bar, area, and pie charts, formatted axes, custom tooltips, toggleable series, click-to-filter bars, zooming with a brush, reference lines, live data, and the performance and accessibility details to watch for.
Installing Recharts
npm install recharts
Recharts renders SVG, so charts are crisp at any size and can be styled with CSS. It ships with TypeScript types. The examples here use the current major version, and the core components shown work the same way in version 2.
Your First Chart: A Responsive Line Chart
Every Recharts chart follows the same structure: a chart container component that receives the data, and child components that describe what to draw.
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const data = [
{ day: "Mon", visitors: 1200 },
{ day: "Tue", visitors: 1850 },
{ day: "Wed", visitors: 1640 },
{ day: "Thu", visitors: 2100 },
{ day: "Fri", visitors: 2480 },
{ day: "Sat", visitors: 1720 },
{ day: "Sun", visitors: 1390 },
];
export function VisitorsChart() {
return (
<div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer>
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="day" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="visitors" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
How it fits together:
datais an array of plain objects. Each object is one point on the x-axis.dataKeytells each component which field to read.XAxisreadsdayfor labels, andLinereadsvisitorsfor values.ResponsiveContainermeasures its parent and passes the size to the chart. The parent must have a defined height, or the chart renders at zero height, which is the most common "my chart is invisible" bug.Tooltipadds hover interaction with no extra code.
Formatting Axes and Tooltips
Raw numbers like 24800 and ISO dates like 2026-09-14 make charts hard to read. Use tickFormatter on axes and formatter on the tooltip.
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
type RevenuePoint = { date: string; revenue: number };
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
});
const fullCurrency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const shortDate = (iso: string) =>
new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
export function RevenueChart({ data }: { data: RevenuePoint[] }) {
return (
<div style={{ width: "100%", height: 320 }}>
<ResponsiveContainer>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tickFormatter={shortDate} minTickGap={24} />
<YAxis tickFormatter={(v: number) => currency.format(v)} width={64} />
<Tooltip
labelFormatter={(label) => shortDate(String(label))}
formatter={(value) => [fullCurrency.format(Number(value)), "Revenue"]}
/>
<Line type="monotone" dataKey="revenue" stroke="#16a34a" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
Intl.NumberFormat with notation: "compact" turns 24800 into $25K, which keeps the y-axis narrow. minTickGap stops x-axis labels from overlapping when there are many data points, and dot={false} removes the circles on each point, which get cluttered on dense time series.
Bar, Area, and Pie Charts
Switching chart types mostly means switching components. The axes, grid, and tooltip stay the same.
Grouped and Stacked Bars
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const sales = [
{ month: "Jan", online: 4200, retail: 2400 },
{ month: "Feb", online: 3800, retail: 2210 },
{ month: "Mar", online: 5100, retail: 2900 },
{ month: "Apr", online: 4700, retail: 3100 },
];
export function SalesBarChart({ stacked = false }: { stacked?: boolean }) {
return (
<div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer>
<BarChart data={sales}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" />
<YAxis />
<Tooltip cursor={{ fill: "rgba(0, 0, 0, 0.04)" }} />
<Legend />
<Bar dataKey="online" fill="#2563eb" stackId={stacked ? "a" : undefined} radius={[4, 4, 0, 0]} />
<Bar dataKey="retail" fill="#f59e0b" stackId={stacked ? "a" : undefined} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
Bars with the same stackId stack on top of each other. Without it, they're grouped side by side. When stacking, you'll usually want the top-left and top-right radius only on the topmost series.
Area Charts With Gradients
Area charts work well for showing volume over time. Because Recharts outputs SVG, you can define a gradient in defs and reference it by ID.
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export function TrafficAreaChart({ data }: { data: { day: string; visits: number }[] }) {
return (
<div style={{ width: "100%", height: 260 }}>
<ResponsiveContainer>
<AreaChart data={data}>
<defs>
<linearGradient id="visitsFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7c3aed" stopOpacity={0.4} />
<stop offset="100%" stopColor="#7c3aed" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="day" />
<YAxis />
<Tooltip />
<Area type="monotone" dataKey="visits" stroke="#7c3aed" fill="url(#visitsFill)" />
</AreaChart>
</ResponsiveContainer>
</div>
);
}
If you render the same chart more than once on a page, make the gradient ID unique per instance, for example with useId, so the charts don't share one definition.
Pie and Donut Charts
import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
const traffic = [
{ source: "Organic", value: 540 },
{ source: "Direct", value: 310 },
{ source: "Social", value: 180 },
{ source: "Referral", value: 95 },
];
const COLORS = ["#2563eb", "#16a34a", "#f59e0b", "#db2777"];
export function TrafficSourcesChart() {
return (
<div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer>
<PieChart>
<Pie
data={traffic}
dataKey="value"
nameKey="source"
innerRadius={70}
outerRadius={110}
paddingAngle={2}
>
{traffic.map((entry, i) => (
<Cell key={entry.source} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
Setting innerRadius turns a pie into a donut. Pie charts are best for a handful of categories that add up to a whole. Past five or six slices, a sorted bar chart is easier to read.
A Custom Tooltip
The default tooltip is functional but plain. Pass a component to the content prop to render anything you like. Recharts gives it active, payload, and label.
type TooltipEntry = { name?: string | number; value?: unknown; color?: string };
type ChartTooltipProps = {
active?: boolean;
payload?: TooltipEntry[];
label?: string | number;
};
export function ChartTooltip({ active, payload, label }: ChartTooltipProps) {
if (!active || !payload?.length) return null;
const total = payload.reduce((sum, entry) => sum + Number(entry.value ?? 0), 0);
return (
<div className="chart-tooltip">
<p className="chart-tooltip__label">{label}</p>
<ul>
{payload.map((entry) => (
<li key={String(entry.name)}>
<span className="swatch" style={{ background: entry.color }} />
{entry.name}: {Number(entry.value).toLocaleString()}
</li>
))}
</ul>
<p className="chart-tooltip__total">Total: {total.toLocaleString()}</p>
</div>
);
}
// Usage inside any chart:
// <Tooltip content={ChartTooltip} />
Always check active and payload first. The component renders even when nothing is hovered, and returning null hides it. Adding computed values like a total or a percentage change is where custom tooltips earn their place.
Toggling Series On and Off
When a chart has several series, letting users hide some of them makes comparisons easier. Keep the hidden set in state and use the hide prop, which keeps the series in the legend but removes it from the plot and the axis domain.
import { useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
const SERIES = [
{ key: "desktop", label: "Desktop", color: "#2563eb" },
{ key: "mobile", label: "Mobile", color: "#16a34a" },
{ key: "tablet", label: "Tablet", color: "#f59e0b" },
] as const;
type SeriesKey = (typeof SERIES)[number]["key"];
type DevicePoint = { week: string } & Record<SeriesKey, number>;
export function DeviceChart({ data }: { data: DevicePoint[] }) {
const [hidden, setHidden] = useState<Set<SeriesKey>>(new Set());
function toggle(key: SeriesKey) {
setHidden((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return (
<div>
<div role="group" aria-label="Toggle series" className="series-toggles">
{SERIES.map((s) => (
<button
key={s.key}
type="button"
aria-pressed={!hidden.has(s.key)}
onClick={() => toggle(s.key)}
style={{ borderColor: s.color, opacity: hidden.has(s.key) ? 0.4 : 1 }}
>
{s.label}
</button>
))}
</div>
<div style={{ width: "100%", height: 300 }}>
<ResponsiveContainer>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="week" />
<YAxis />
<Tooltip />
{SERIES.map((s) => (
<Line
key={s.key}
dataKey={s.key}
name={s.label}
stroke={s.color}
strokeWidth={2}
dot={false}
hide={hidden.has(s.key)}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
Using real buttons with aria-pressed makes the toggles keyboard-accessible and announces their state to screen readers, which is more reliable than relying on clicks inside the SVG legend. Notice that the y-axis rescales automatically when a large series is hidden, because hidden series are excluded from the domain.
Click to Filter With Bars
Charts become genuinely interactive when they drive the rest of the UI. Here, clicking a bar selects a category, highlights it, and could filter a table below.
import { useState } from "react";
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
type CategoryTotal = { category: string; orders: number };
export function CategoryChart({
data,
onSelect,
}: {
data: CategoryTotal[];
onSelect: (category: string | null) => void;
}) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
function handleClick(_: unknown, index: number) {
const next = index === activeIndex ? null : index;
setActiveIndex(next);
onSelect(next === null ? null : data[next].category);
}
return (
<div style={{ width: "100%", height: 280 }}>
<ResponsiveContainer>
<BarChart data={data}>
<XAxis dataKey="category" />
<YAxis allowDecimals={false} />
<Tooltip cursor={{ fill: "rgba(0, 0, 0, 0.04)" }} />
<Bar dataKey="orders" onClick={handleClick} style={{ cursor: "pointer" }}>
{data.map((entry, i) => (
<Cell
key={entry.category}
fill={activeIndex === null || activeIndex === i ? "#2563eb" : "#cbd5e1"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
Bar passes the clicked bar's data and its index to onClick. Clicking the same bar again clears the selection. Lifting the selected category up through onSelect lets a parent component filter a table or another chart with the same value.
Zooming With a Brush
For long time series, a Brush adds a small range selector under the chart. Dragging its handles zooms the main chart into that window.
import { Brush, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
type Point = { date: string; latency: number };
export function LatencyChart({ data, slo }: { data: Point[]; slo: number }) {
return (
<div style={{ width: "100%", height: 340 }}>
<ResponsiveContainer>
<LineChart data={data}>
<XAxis dataKey="date" minTickGap={32} />
<YAxis unit="ms" width={60} />
<Tooltip />
<ReferenceLine
y={slo}
stroke="#dc2626"
strokeDasharray="4 4"
label={{ value: `SLO ${slo}ms`, position: "insideTopRight", fill: "#dc2626" }}
/>
<Line type="monotone" dataKey="latency" stroke="#0891b2" dot={false} isAnimationActive={false} />
<Brush dataKey="date" height={28} travellerWidth={8} startIndex={Math.max(0, data.length - 60)} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
startIndex makes the brush open on the most recent 60 points instead of the whole range. ReferenceLine draws a horizontal threshold, which is a simple way to show targets, limits, or averages. You can also use ReferenceArea to shade a range, such as an incident window.
Charts With Live and Fetched Data
In a real dashboard, data comes from an API. Fetch it with your usual tools, and the chart re-renders when the data changes. With TanStack Query, polling for live data is a single option:
import { useQuery } from "@tanstack/react-query";
import { RevenueChart } from "./RevenueChart";
type RevenuePoint = { date: string; revenue: number };
async function fetchRevenue(): Promise<RevenuePoint[]> {
const res = await fetch("/api/metrics/revenue?days=30");
if (!res.ok) throw new Error("Failed to load revenue");
return res.json();
}
export function RevenuePanel() {
const { data, isPending, isError } = useQuery({
queryKey: ["metrics", "revenue", 30],
queryFn: fetchRevenue,
refetchInterval: 30_000,
});
if (isPending) return <div className="chart-skeleton" style={{ height: 320 }} />;
if (isError) return <p role="alert">Could not load revenue data.</p>;
return <RevenueChart data={data} />;
}
Reserve the chart's height in the loading skeleton so the layout doesn't jump when data arrives. For updates that arrive every second or two, disable animation with isAnimationActive={false}, or every refresh replays the line-drawing animation. If you push data over a socket instead of polling, the patterns in real-time updates in React with WebSockets apply directly.
Performance Tips
Recharts renders one SVG element per point, bar, and tick, so very large datasets get slow.
- Memoize derived data. Transforming API results into chart data on every render creates new arrays, which forces the chart to recompute. Wrap transformations in
useMemo. The useMemo and useCallback guide explains when this matters. - Aggregate before rendering. A line chart that's 800 pixels wide can't show 50,000 points meaningfully. Downsample to a few hundred points, or bucket by hour or day on the server.
- Remove dots on dense lines with
dot={false}, and keepactiveDotfor hover feedback. - Disable animations for large or frequently updated datasets.
- Keep formatters stable. Define formatters outside the component or with
useCallbackso they don't change identity every render.
If you genuinely need tens of thousands of interactive points, a canvas or WebGL-based library is a better fit than SVG.
Accessibility
Charts are visual by nature, so plan for users who can't see them:
- Provide a text alternative. A short summary near the chart ("Revenue rose 18% this month, peaking on the 14th") helps everyone, not just screen reader users.
- Offer the data as a table, either visible or behind a "Show data" toggle.
- Use the accessibility layer. Recharts 3 enables
accessibilityLayerby default, which makes charts focusable and lets users move through data points with arrow keys while the tooltip follows. On version 2, add theaccessibilityLayerprop to the chart component. - Don't rely on color alone. Use distinct dash patterns or direct labels for series, and check contrast against your background.
Common Mistakes With Recharts
- Parent without a height.
ResponsiveContainerneeds a parent with a defined height, or the chart is invisible. - Wrong
dataKey. A typo renders an empty series with no error. Type your data and double-check keys. - Numbers as strings. API values like
"1200"sort and scale incorrectly. Convert them to numbers before charting. - Unformatted ticks. Long numbers and ISO dates crowd the axes. Use
tickFormatterandminTickGap. - Too many points. Thousands of SVG nodes make hover laggy. Aggregate or downsample.
- Animations on live data. Replaying the entry animation on every update is distracting. Turn it off.
- Duplicate gradient IDs. Multiple charts sharing a
linearGradientID can render with the wrong fill.
Frequently Asked Questions (FAQ) About Recharts
The most common cause is a ResponsiveContainer inside a parent that has no height. Give the wrapper an explicit height, such as 300 pixels. Also check that each dataKey matches a field in your data and that values are numbers rather than strings.
Wrap the chart in ResponsiveContainer and give its parent a width, usually 100 percent, and a fixed height. The container measures the parent and resizes the chart when the window or layout changes.
Yes. Pass a component to the content prop of Tooltip. It receives active, payload, and label, so you can render any markup, add totals or percentages, and style it to match your design system. Return null when it isn't active.
Aggregate or downsample the data before rendering, since each point becomes an SVG element. Disable dots and animations, memoize transformed data, and use a Brush to let users zoom into ranges. For tens of thousands of interactive points, consider a canvas-based library.
Recharts relies on browser measurements for responsive sizing, so render charts on the client. In frameworks with Server Components, place charts in a client component. Reserve the chart height during loading to avoid layout shift.
Recharts 3 includes an accessibility layer by default that makes charts keyboard-navigable with tooltips that follow focus. You should still add a text summary and offer the underlying data as a table, and avoid relying on color alone to distinguish series.
Conclusion
Recharts turns charts into React components. A chart container takes your data, and children like XAxis, Tooltip, Line, and Bar describe what to draw, so charts update naturally whenever your state changes. Wrap them in a ResponsiveContainer with a sized parent, format axes and tooltips with Intl, and you have readable, responsive charts with very little code.
Interactivity comes from treating the chart like any other component. Keep hidden series and selected bars in state, lift selections up to filter the rest of the page, add a Brush for long time series, and feed live data from TanStack Query. Finish with memoized data, sensible point counts, and a text alternative, and your dashboards will be fast, interactive, and usable by everyone.


