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 , 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

bash
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:

app/dashboard/revenue-card.tsxtsx
"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>
  )
}
  • highlight is 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 a justify-between row that drifts in the middle.
  • tone picks 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) to max(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:

tsx
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:

tsx
<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.

tsx
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 },
  ]}
/>
  • PieChart hides the outside labels below 320 pixels of width (it watches its own box with a ResizeObserver) and moves the legend to one column, so it survives a phone without props.
  • RadialChart with a single item renders a full progress ring with the value in the centre; with several it draws three quarter arcs. Pass periods instead of data to 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.

tsx
<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:

tsx
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.
  • PieChart slices 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 #22c55e for a series; var(--success) keeps the WCAG contrast the kit was built for in dark mode. See semantic tokens.
  • Grid lines are stroke-border at one pixel; they are decoration and intentionally low contrast.

Limits, and when to reach for a library

ChartWhat it doesWhat it does not do
AreaChartOne series, smoothing, highlight index, toneNo axis ticks, no tooltip of its own, no multiple series
LineChartSeveral series, shared scale, end dotsNo legend, no hover, series must share a length
BarChartHover value pill, peak marker, formatNo negative values, no stacking (see StackedBarChart), no grouped bars
DonutChartOne value against a maxNo segments; use PieChart for shares
PieChartLabels, legend, keyboard, readout, sr tableNot a donut; no half pie
RadialChartRings per goal, periods, rolling centreNo 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-set
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

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-chartDonut ChartuiSingle value donut chart in SVG that fills an accent ring to a percentage of max and prints the percent in the middle. Labelled as an image for screen readers.npx shadcn@latest add @minidev/donut-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-setChart LegenduiWrapping chart legend with small color swatches and labels. Pass colors per series or fall back to accent, success, warning and danger theme tokens.npx shadcn@latest add @minidev/chart-legend

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.

More guides