Charts in React with Tailwind and no charting library
Build area, line, bar, donut, pie and radial charts in React with plain SVG and Tailwind v4: data shapes, legends, hover values and screen reader tables.
By Aman Kaushik, MiniDev15 min read
You can draw most dashboard charts in React with an inline <svg>, a viewBox, and a few lines that map values to coordinates, styled with the same Tailwind tokens as the rest of the page. A charting library earns its place when you need axes with ticks, zooming, brushing or thousands of points; for a revenue trend, a bar per month and a share of plans, it is 40 kilobytes you do not need.
This guide uses the free MiniDev UI chart components, which are exactly that: one file each, plain SVG and HTML. It covers AreaChart, LineChart, BarChart, DonutChart, PieChart, RadialChart, SparklineSet and ChartLegend, with the real props from the source, the places where they stop, and how to add what is missing.
When SVG and Tailwind are enough
- The data is already in memory and has fewer than a few hundred points per series.
- Readers need the shape and one or two values on hover, not a full coordinate readout.
- The chart must match the design system: same colours, same hairline grid, same dark mode, without a theme object.
- The bundle matters, as in a marketing page or an email preview.
If you need a time axis with ticks that respect month boundaries, stacked series with a shared tooltip, or pan and zoom, pick Recharts or visx and style them with the same tokens. The last section lists where each kit chart stops so you can decide early.
Install
npx shadcn@latest add @minidev/area-chart
npx shadcn@latest add @minidev/line-chart
npx shadcn@latest add @minidev/bar-chart
npx shadcn@latest add @minidev/pie-chart
npx shadcn@latest add @minidev/radial-chart
npx shadcn@latest add @minidev/sparkline-set
npx shadcn@latest add @minidev/chart-legend
npx shadcn@latest add @minidev/chart-card
LineChart imports smoothPath from area-chart, and SparklineSet imports AreaChart and MetricDelta, so the CLI pulls those in. PieChart and RadialChart animate with motion/react; the others have no dependencies. The token stylesheet (styles.css) defines --chart-1 to --chart-5, which the multi series charts cycle through.
Area chart: one series, a trend
AreaChart takes data as a plain array of numbers, left to right, and draws a monotone cubic curve (no overshoot between points) with a gradient wash and three dashed grid lines. Wrap it in ChartCard for the title, and add the x axis labels yourself in HTML:
"use client"
import * as React from "react"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"
const MONTHS = ["Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct"]
const REVENUE = [31.2, 36.4, 28.9, 33.1, 39.8, 42.6, 38.2, 47.5, 51.3, 44.7, 56.1, 61.4] // USD thousands
export function RevenueCard() {
const [hi, setHi] = React.useState<number | undefined>()
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect()
setHi(Math.round(((e.clientX - r.left) / r.width) * (REVENUE.length - 1)))
}
const cur = hi ?? REVENUE.length - 1
return (
<ChartCard title="Revenue" description={MONTHS[cur] + ": $" + REVENUE[cur].toFixed(1) + "k"}>
<div onPointerMove={onMove} onPointerLeave={() => setHi(undefined)}>
<AreaChart data={REVENUE} highlight={cur} label="Monthly revenue, last twelve months" className="h-40" />
</div>
<div aria-hidden className="relative mt-2 h-4 font-mono text-[10px] text-fg-subtle">
{MONTHS.map((m, i) => (
<span key={m} className="absolute -translate-x-1/2 first:translate-x-0 last:-translate-x-full" style={{ left: (i / (MONTHS.length - 1)) * 100 + "%" }}>
{m}
</span>
))}
</div>
</ChartCard>
)
}
highlightis an index. The component draws a dot and a hairline crosshair at that point; the pointer handler above turns it into a hover readout, with the last point shown at rest.- Points sit at
i / (n - 1)of the width, which is why the labels are positioned absolutely with the same formula instead of ajustify-betweenrow that drifts in the middle. tonepicks the colour (accent,success,warning,danger,info,neutral);smooth={false}gives straight segments;grid={false}removes the dashed lines.- The y scale runs from
min(data, 0)tomax(data). Positive data always starts at zero, which is the honest baseline for an area fill. Negative values work, but the fill then covers the area below the line as well.
If you want the crosshair, a comparison line and a delta badge without writing the handler, the kit's InteractiveAreaChart already does this with { label, value, compare } points.
Line chart with several series and a legend
LineChart takes series as an array of number arrays and draws one path per series on a shared y scale, coloured var(--chart-1) through var(--chart-5) in order, with a dot at each line's end. It renders no legend, so pair it with ChartLegend and pass the same colours explicitly:
const MRR = {
"Total MRR": [12, 14, 13, 17, 19, 18, 23, 26, 25, 30, 33, 36],
Expansion: [4, 5, 6, 6, 8, 9, 9, 11, 12, 14, 15, 17],
Churn: [6, 6, 5, 6, 5, 5, 4, 5, 4, 4, 3, 3],
}
<ChartCard title="MRR movement" description="USD thousands, by month">
<LineChart series={Object.values(MRR)} label="Total MRR, expansion and churn by month" className="h-44" />
<ChartLegend
className="mt-3"
items={Object.keys(MRR).map((label, i) => ({ label, color: `var(--chart-${i + 1})` }))}
/>
</ChartCard>
ChartLegend without color falls back to accent, success, warning and danger, which is not the order LineChart uses (--chart-1 is the accent, but --chart-2 is a cyan and --chart-3 the warning). Always pass color so the swatches match the lines.
Every series is spread across the full width by its own length, so give them the same number of points or the x positions will not line up. The first series is drawn slightly heavier (1.75px against 1.5px) so a total reads as the primary line.
Bar chart
BarChart takes { label, value } objects and renders HTML bars in a flex row, not SVG, so the labels are real text. Hovering a bar shows its value in an ink pill and dims the others; at rest the peak is marked. format controls the pill:
<BarChart
data={MONTHS.map((label, i) => ({ label, value: REVENUE[i] * 1000 }))}
label="Monthly revenue"
format={(n) => "$" + Math.round(n / 1000) + "k"}
className="h-48"
/>
Bars are sized as a percentage of the maximum with a 2 percent floor so zero months stay visible. There is no negative axis: a loss month needs a different chart. Twelve bars fit comfortably at card width; past about twenty the labels start to truncate (they have truncate), so aggregate or switch to the horizontal variant in the charts category.
Parts of a whole: donut, pie and radial
Three components cover three different questions. DonutChart answers "how far along is one number": a single ring with the percentage in the middle, sized by className (size-24 by default). PieChart answers "how is the total split": slices with leader line labels, a legend, a readout that shows the hovered slice and a spring pull out. RadialChart answers "how are several goals doing": concentric rings, one per metric, with its own max.
const money = (n: number) => "$" + Math.round(n / 1000) + "k"
<DonutChart value={62} max={70} label="Activation" />
<PieChart
title="Revenue by plan"
caption="October, monthly recurring"
format={money}
data={[
{ label: "Enterprise", value: 48200 },
{ label: "Business", value: 31600 },
{ label: "Pro", value: 22400 },
{ label: "Starter", value: 9800 },
]}
/>
<RadialChart
title="October goals"
data={[
{ label: "MRR", value: 84200, max: 100000, format: money },
{ label: "Activation", value: 62, max: 70, format: (n) => n + "%" },
{ label: "NPS", value: 47, max: 60 },
]}
/>
PieCharthides the outside labels below 320 pixels of width (it watches its own box with aResizeObserver) and moves the legend to one column, so it survives a phone without props.RadialChartwith a single item renders a full progress ring with the value in the centre; with several it draws three quarter arcs. Passperiodsinstead ofdatato get a segmented control that switches datasets.- Both animate value changes on a spring and go instant under prefers-reduced-motion.
- Keep pies to five or six slices. Past that the leader lines stack and the shares are too close to compare; a bar chart sorted by value is the better answer.
A KPI row with SparklineSet
SparklineSet renders a responsive grid of cards, each with a label, a formatted value, a delta badge and a 64 pixel AreaChart. The value is a string so you format it once; the delta is a number in percent.
<SparklineSet
items={[
{ label: "Revenue", value: "$128,400", delta: 12.4, data: [62, 68, 66, 74, 79, 77, 85, 90, 96, 101, 108, 118] },
{ label: "Active customers", value: "1,284", delta: 3.1, data: [920, 948, 970, 1004, 1012, 1060, 1088, 1120, 1150, 1210, 1246, 1284] },
{ label: "Open invoices", value: "312", delta: -4.6, data: [180, 196, 210, 205, 228, 240, 236, 255, 270, 284, 327, 312] },
]}
/>
MetricDelta colours by sign: positive is green, negative is red. For a metric where down is good, such as churn or time to first response, that reads backwards. Either negate the delta and change the label ("Churn improvement") or add a goodDirection prop in your copy of metric-delta.tsx.
Sizing and responsiveness
AreaChart and LineChart are a relative box with the SVG stretched over it, viewBox 320 by 120 and preserveAspectRatio="none". That means the drawing fills whatever size the box has: width from the parent, height from your className (h-32 by default). Strokes use vectorEffect="non-scaling-stroke" so they stay 1.5 pixels however much the box is stretched. Two consequences follow:
- Never put text inside these SVGs; it would stretch with the box. Labels, legends and readouts live in HTML around the chart, as in every example above.
- The curve's apparent slope changes with the aspect ratio. Keep heights consistent across a dashboard row so trends are comparable by eye.
PieChart and RadialChart keep their aspect ratio (the SVG has h-auto w-full with a max width), and BarChart is a flex layout that takes any size. In a CSS grid, give chart cards min-w-0 so a long legend cannot force the column wider than the viewport.
Accessibility
A chart is an image to assistive technology, so every kit chart sets role="img" and takes a label. Make that label the sentence you would say to a colleague: "Monthly revenue, last twelve months, rising from 31 to 61 thousand", not "Area chart". For charts where the numbers matter, add the data as a visually hidden table, which is what PieChart does internally:
function ChartTable({ caption, rows }: { caption: string; rows: [string, string][] }) {
return (
<table className="sr-only">
<caption>{caption}</caption>
<tbody>
{rows.map(([k, v]) => (
<tr key={k}>
<th scope="row">{k}</th>
<td>{v}</td>
</tr>
))}
</tbody>
</table>
)
}
<AreaChart data={REVENUE} label="Monthly revenue, last twelve months" />
<ChartTable caption="Monthly revenue in USD thousands" rows={MONTHS.map((m, i) => [m, REVENUE[i].toFixed(1)])} />
- The hover readout in the area example is visible text in the card description, so the value is on the page for everyone, not only in a pointer tooltip.
PieChartslices are focusable and move with the arrow keys, Home and End. The other charts have no interactive parts, so there is nothing to tab to; the table carries the data.- The chart tokens are tuned for both themes. Do not reach for raw colours like
#22c55efor a series;var(--success)keeps the WCAG contrast the kit was built for in dark mode. See semantic tokens. - Grid lines are
stroke-borderat one pixel; they are decoration and intentionally low contrast.
Limits, and when to reach for a library
| Chart | What it does | What it does not do |
|---|---|---|
AreaChart | One series, smoothing, highlight index, tone | No axis ticks, no tooltip of its own, no multiple series |
LineChart | Several series, shared scale, end dots | No legend, no hover, series must share a length |
BarChart | Hover value pill, peak marker, format | No negative values, no stacking (see StackedBarChart), no grouped bars |
DonutChart | One value against a max | No segments; use PieChart for shares |
PieChart | Labels, legend, keyboard, readout, sr table | Not a donut; no half pie |
RadialChart | Rings per goal, periods, rolling centre | No more than about five rings before labels overlap |
The missing pieces are deliberate: axis ticks and tooltips are where a chart stops being one file. If a product needs them on every chart, pick Recharts (declarative, React native, easy to theme with these tokens through className and CSS variables) or visx (lower level, more control) and keep the kit charts for the summary cards. Downsample before rendering either way; nobody can read 5,000 points in 320 pixels, and smoothPath builds a path string proportional to the point count on every render.
Components used in this guide
All of these are free and MIT licensed, and the rest (stacked bars, horizontal bars, gauges, funnels, heatmap cells) are on the charts category page. The cards drop into the layout from the Next.js SaaS dashboard guide, and the OKLCH guide explains the colour space the chart tokens are defined in. If you want the whole analytics product built, the MiniDev studio builds on this kit.
Area ChartuiDependency free SVG area chart for React with a smoothed line, dashed grid, gradient fill and an optional highlighted point. Strokes stay crisp at any size.npx shadcn@latest add @minidev/area-chartLine ChartuiA multi series SVG line chart with smooth curves, a dashed hairline grid and dots on the last point of each line, colored from chart tokens. No library.npx shadcn@latest add @minidev/line-chartBar ChartuiLightweight React bar chart with rounded bars, dashed gridlines and labels. Hovering a bar dims the rest and shows its value; the peak is highlighted first.npx shadcn@latest add @minidev/bar-chartPie ChartuiA React SVG pie chart with outside labels on leader lines, a legend and slices that pull out on hover or focus to show value and share, plus a hidden data table.npx shadcn@latest add @minidev/pie-chartRadial ChartuiA React radial bar chart of concentric progress rings with ring labels, a legend and a rolling center total that animate on data change, or a single radial progress ring.npx shadcn@latest add @minidev/radial-chartSparkline SetuiResponsive grid of metric cards, each with a label, value, delta chip and a small area chart sparkline. Good for a KPI overview at the top of a React dashboard.npx shadcn@latest add @minidev/sparkline-setnpx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit
Components used in this guide
Frequently asked questions
Can I build charts in React without a library?
Yes. An inline SVG with a viewBox, a function that maps each value to x and y, and a path element is a working line chart. The kit's AreaChart is about a hundred lines including the smoothing and the gradient. Reach for a library when you need axes with ticks, tooltips on every chart or more than a few hundred points.
How do I make an SVG chart responsive with Tailwind?
Give the SVG a viewBox and let CSS size it: w-full with a height class on the wrapper. For line and area shapes use preserveAspectRatio="none" so the drawing fills the box, with vectorEffect="non-scaling-stroke" on paths so strokes do not stretch, and keep all text outside the SVG.
Are these charts accessible?
Each one has role="img" and a label you write, which is the minimum. PieChart also renders a visually hidden table and keyboard focusable slices. For the other charts, add a small sr-only table with the values next to the chart when the numbers matter.