Instrument · Free · MIT

Instrument charts

15 chart types that install as source through the shadcn CLI. Each one takes the same shared props, runs on Recharts or Apache ECharts with identical behaviour, and follows your shadcn theme. Start with the guide below, or go straight to a chart's reference.

Install

You need React 19 (React 18 isn't supported), a project set up for shadcn (npx shadcn@latest init), and Recharts 3.10 or later or ECharts 6.1 or later; the CLI installs or updates the engine to that range. Next.js and Vite both work. Tailwind isn't required by the charts: Instrument brings its own stylesheet. Classes you pass to a chart always win; inside the chart, restyle through the --bc-* variables. If your shadcn tokens are HSL channels (the Tailwind 3 style, like 222.2 84% 4.9%), add class="bc-hsl-tokens" to <html>.

npx shadcn@latest add https://beautifulcharts.dev/r/instrument-bar-chart-recharts.json

The CLI copies the chart and everything it imports into your components folder, and installs only the engine that build uses. A Recharts install never brings ECharts, and the reverse. Installing a second chart reuses the shared files.

components/beautiful-charts/
  core/                 engine-free model, formatting, keyboard, sound (MIT)
  themes/instrument/
    styles.tsx          the theme's stylesheet, injected once
    frame.tsx           card, header, legend, table and states
    recharts/bar-chart.tsx
    echarts/bar-chart.tsx   (only if you install the ECharts build)
  LICENSE.txt

Use it

Charts are client components. Import them in a file that starts with "use client" whenever you pass a function (a formatter, a callback or tooltip content), because functions can't cross from a Server Component. The first render happens on the server, and the card reserves its height while the plot measures itself.

"use client";

import { BarChart } from "@/components/beautiful-charts/themes/instrument/recharts/bar-chart";
import type { ChartConfig } from "@/components/beautiful-charts/core/contracts";

const data = [
  { month: "Jan", signups: 186, trials: 80 },
  { month: "Feb", signups: 305, trials: 200 },
  { month: "Mar", signups: 237, trials: 120 },
  { month: "Apr", signups: 73, trials: 190 },
  { month: "May", signups: 209, trials: 130 },
  { month: "Jun", signups: 214, trials: 140 },
];

const config = {
  signups: { label: "Sign-ups", color: "var(--chart-1)" },
  trials: { label: "Trials", color: "var(--chart-2)" },
} satisfies ChartConfig;

export function SignupsByMonth() {
  return (
    <BarChart
      data={data}
      xKey="month"
      config={config}
      title="Sign-ups and trials"
      description="January to June"
      stack="stacked"
      valueFormatter={(value) => value.toLocaleString("en-US")}
      onSelectedChange={(selection) => console.log(selection?.datum.month, selection?.seriesKey)}
    />
  );
}

Every option is a typed prop, with a doc comment your editor shows on hover. Each chart's reference page lists them all, generated from the same source.

Recharts or ECharts

Both builds of a chart draw the same marks from the same model, so the numbers, takeaway, table, keyboard behaviour and selection are identical. Switching is one import path. Choose Recharts for a smaller bundle and native Recharts children. Choose ECharts when you already use it elsewhere, or for many marks: it draws on a canvas, so the page keeps one element instead of one per mark. Both builds still compute every row, so for tens of thousands of rows, aggregate or window the data first (the brush helps).

  • Recharts: pass Recharts children (ReferenceArea, Label, Customized) and they render inside the chart with your original rows. The Sankey is the exception: it draws Instrument's own layout.
  • ECharts: option is deep-merged over the generated option, or pass a function that receives it. onReady hands you the instance.
"use client";

import { BarChart } from "@/components/beautiful-charts/themes/instrument/echarts/bar-chart";

const data = [{ month: "Jan", orders: 186 }, { month: "Feb", orders: 305 }, { month: "Mar", orders: 237 }];

export function EChartsEscapeHatch() {
  return (
    <BarChart
      data={data}
      xKey="month"
      renderer="svg"
      // Deep-merged: this changes one axis setting and keeps everything else Instrument generated.
      option={{ yAxis: { splitNumber: 3 } }}
      onReady={(chart) => chart.on("click", (event) => console.log(event))}
    />
  );
}

Colours

A series takes the first colour that applies:

  1. A colour in config for that key: color, theme (light and dark), or colors (gradient stops). TypeScript accepts only one of the three. shadcn's ChartConfig entries work as they are.
  2. --bc-series-N, if you set it.
  3. Instrument's own palette (the default), checked at 3:1 contrast or better against the surface in light and dark.
  4. Your --chart-1…5, when you opt in with palette="shadcn" or the class bc-use-shadcn-colors on any ancestor.

A series keeps its slot when others are hidden or reordered: its N is its position in config. Everything else (text, hairlines, surface, good and bad tones, focus ring, fonts, radius) follows your shadcn tokens, with a --bc-* override for each. Dark mode applies under .dark or [data-theme="dark"], and a .light section inside a dark page stays light.

import type { ChartConfig } from "@/components/beautiful-charts/core/contracts";

export const config = {
  revenue: { label: "Revenue", color: "var(--chart-1)" }, // one colour
  costs: { label: "Costs", theme: { light: "#b42318", dark: "#f97066" } }, // per theme
  margin: { label: "Margin", colors: { light: ["#c7d2fe", "#4338ca"] } }, // gradient stops
} satisfies ChartConfig;

/* Recolour every Instrument chart in a section, without touching props. */
.billing {
  --bc-series-1: oklch(0.55 0.2 264);
  --bc-series-2: oklch(0.62 0.15 160);
  --bc-radius: 12px;
  --bc-font-mono: "JetBrains Mono", ui-monospace, monospace;
}

/* Use your shadcn --chart-1..5 everywhere under this element. */
<section className="bc-use-shadcn-colors">...</section>

Styling hooks

className goes on the card. Every part carries a data-slot (chart-title, chart-legend, chart-plot, chart-tooltip, chart-table and so on), and the root carries data-state and data-density. Theme rules sit inside :where(), so .my-chart [data-slot="chart-title"] overrides them without !important. For anything deeper, edit the installed source: it's yours.

Missing and odd data

The charts never invent data. Each case below is shown for what it is and named in a note under the chart, and the data table keeps every row.

  • null, undefined and NaN are missing, never zero. Lines break, bars are left out, and a line joins across gaps only if you set connectNulls on that series.
  • Percent stacks split each row by its absolute total, so negative parts are shown below zero rather than cancelled out.
  • Donuts leave out negative and missing values. groupBelow folds small segments into one “Other” segment, which lists its members.
  • Radial rings over their maximum fill the ring, carry a mark and keep their true number. Gauges pin out-of-range readings to the end of the scale the same way.
  • A Sankey with a link to an unknown node, a duplicate id or a cycle shows a plain message instead of a wrong diagram. Negative links and self-links are left out and named.
  • A KPI change from a zero base is shown as an amount, never as an infinite percentage.

Loading, empty, error

loading shows a skeleton the size of the chart. Over existing data it dims the old marks instead of removing them, so a refresh doesn't flash. An empty data array shows the empty state, and error shows the error state. Replace any of them with loadingState, emptyState or errorState. The card keeps its height in every state.

Controlled state

Each piece of interaction state can be left to the chart, given an initial value, or controlled by you: selected (a pinned row), selectedSeries, hidden (legend toggles), activeIndex (hover and focus), and on cartesian charts the brush range. Indexes always point into the data you passed, even when a brush shows a subset. Selections report their source: pointer, keyboard or table.

"use client";

import { useState } from "react";
import { LineChart } from "@/components/beautiful-charts/themes/instrument/recharts/line-chart";
import { BarChart } from "@/components/beautiful-charts/themes/instrument/recharts/bar-chart";

type Row = { day: string; orders: number; returns: number };

// Two charts on one cursor: hovering either moves both, and hiding a series in one legend hides it in both.
export function SyncedCharts({ rows }: { rows: Row[] }) {
  const [active, setActive] = useState<number | null>(null);
  const [hidden, setHidden] = useState<string[]>([]);
  const shared = { data: rows, xKey: "day" as const, activeIndex: active, onActiveChange: setActive, hidden, onHiddenChange: setHidden };
  return (
    <div className="grid gap-4 md:grid-cols-2">
      <LineChart {...shared} title="Orders and returns" />
      <BarChart {...shared} title="Returns" series={["returns"]} />
    </div>
  );
}

Tooltip content

tooltip.content replaces the body and keeps the frame, positioning and accessibility. It receives the row, its index and every visible series with its raw value (null when missing), its share in 100% stacks and its colour. Set position: "fixed" to pin the tooltip to a corner, or tooltip={false} to turn it off.

"use client";

import { BarChart } from "@/components/beautiful-charts/themes/instrument/recharts/bar-chart";

const data = [{ month: "Jan", desktop: 186, mobile: 80 }, { month: "Feb", desktop: 305, mobile: null }];

export function CustomTooltip() {
  return (
    <BarChart
      data={data}
      xKey="month"
      tooltip={{
        indicator: "line",
        content: ({ datum, series }) => (
          <div>
            <strong>{datum.month}</strong>
            {series.map((s) => <div key={s.key}>{s.label}: {s.value ?? "no data"}</div>)}
          </div>
        ),
      }}
    />
  );
}

Keyboard and screen readers

Each chart is one tab stop. Screen readers hear a summary computed from the data (replace it with ariaDescription). As you move, each point is announced in words: its category, series, value and, where it applies, its share.

  • ← → move between categories in the direction they're drawn. Charts keep left-to-right category axes on right-to-left pages too, so ← always moves to the mark on the left.
  • ↑ ↓ move between series when there are several, otherwise between categories.
  • Home / End jump to the first or last category. Page Up / Page Down move by a larger step.
  • Enter or Space pins the current point. Escape clears it.

“Show data table” opens a real table of every value, and its rows select points too. Colour is never the only signal: forecasts are hatched or dashed, and deltas say “up” or “down” in words.

Motion and sound

Marks rise in with a short stagger. motion="subtle" shortens it and "off" removes it. A reduced-motion setting in the operating system always wins. motionOptions.trigger="in-view" waits until the chart scrolls into view.

Sound is off by default, and it takes two switches. A chart plays soft cues on hover, scrub and select only when it has sound and the visitor has turned sound on for the page. That preference is saved in their browser.

"use client";

import { useSoundPreference } from "@/components/beautiful-charts/core/sound";

// Sound plays only when the page preference is on AND the chart has sound={true}.
export function SoundToggle() {
  const [on, setOn] = useSoundPreference();
  return <button type="button" onClick={() => setOn(!on)} aria-pressed={on}>Chart sounds</button>;
}

Recipes

Each recipe is a complete client module, type-checked against the current source on every build.

Currency and locale

"use client";

import { AreaChart } from "@/components/beautiful-charts/themes/instrument/recharts/area-chart";

const data = [{ month: "2026-01", revenue: 18250 }, { month: "2026-02", revenue: 21400 }, { month: "2026-03", revenue: 19800 }];
const euros = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });

// Currency and locale: one formatter drives the axis, tooltip, table, readout and screen-reader text.
export function RevenueInEuros() {
  return (
    <AreaChart
      data={data}
      xKey="month"
      locale="de-DE"
      title="Umsatz"
      valueFormatter={(value) => euros.format(value)}
      xAxis={{ tickFormatter: (month) => String(month).slice(5) }}
    />
  );
}

A quiet dashboard tile

"use client";

import { LineChart } from "@/components/beautiful-charts/themes/instrument/recharts/line-chart";

const data = [{ day: "Mon", users: 120 }, { day: "Tue", users: 132 }, { day: "Wed", users: 128 }, { day: "Thu", users: 151 }];

// A quiet dashboard tile: compact, no axes or grid, no legend or table disclosure, subtle motion.
export function UsersTile() {
  return (
    <LineChart
      data={data}
      xKey="day"
      title="Daily users"
      density="compact"
      height={120}
      grid={false}
      xAxis={{ show: false }}
      yAxis={{ show: false }}
      legend={false}
      table={false}
      motion="subtle"
    />
  );
}

A 100% stack with a forecast

"use client";

import { BarChart } from "@/components/beautiful-charts/themes/instrument/recharts/bar-chart";

const data = [
  { quarter: "Q1", new: 40, expansion: 25 }, { quarter: "Q2", new: 44, expansion: 31 },
  { quarter: "Q3", new: 39, expansion: 38 }, { quarter: "Q4", new: 47, expansion: 41 },
];

// A 100% stack where the last quarter is a forecast: its bars are hatched, and the table says so.
export function RevenueMix() {
  return <BarChart data={data} xKey="quarter" title="Revenue mix" stack="percent" forecastFrom={3} />;
}

Refreshing without a flash

"use client";

import { BarChart } from "@/components/beautiful-charts/themes/instrument/recharts/bar-chart";

type Row = { region: string; orders: number };

// Data from your own fetching: during a refresh the old bars stay, dimmed, instead of flashing empty.
export function OrdersByRegion({ rows, isFetching, error }: { rows: Row[]; isFetching: boolean; error: Error | null }) {
  return (
    <BarChart
      data={rows}
      xKey="region"
      orientation="horizontal"
      title="Orders by region"
      loading={isFetching}
      error={error ? "Orders couldn't be loaded. Try again in a moment." : null}
    />
  );
}

For more combinations, each chart's Customizer shows every option live and copies the matching code.

Updating

The installed files are your source, so updating is a reviewed change, not a silent package bump. Commit first, then preview and apply:

git add -A && git commit -m "before chart update"
npx shadcn@latest add https://beautifulcharts.dev/r/instrument-bar-chart-recharts.json --dry-run
npx shadcn@latest add https://beautifulcharts.dev/r/instrument-bar-chart-recharts.json --diff components/beautiful-charts/core/cartesian.ts
npx shadcn@latest add https://beautifulcharts.dev/r/instrument-bar-chart-recharts.json --overwrite
git diff

--dry-run lists what would change, --diff shows one file's changes, and --overwrite writes the new version. Charts from the same release share identical copies of the shared files, so update every Instrument chart you use together. Your git diff then shows any local edits to carry forward.

From the recipe charts

The earlier recipe charts and foundations keep working, but Instrument's contract differs. When you move a chart over:

Recipe chartsInstrument
series={[{ key, label, color, stackId }]}config={{ key: { label, color, stackId } }} (shadcn ChartConfig), plus series={[...keys]} to choose and order them
showLegend, legend on/off booleanslegend={{ position, align, shape, action }} or legend={false}
showBrush and onRangeChange({ startIndex, endIndex })brush={{ range, defaultRange, onRangeChange }}; ranges are indexes into your data on both engines
onSelect({ dataIndex, datum }) (Recharts) or the native ECharts payloadonSelectedChange(selection): the same typed Selection on both engines, with dataIndex, datum, seriesKey and source
optionOverrides (shallow, replaces whole sections)option (deep-merged), or a function of the generated option; onReady for the instance
isLoadingloading, plus error, and emptyState / loadingState / errorState to replace any of them
--chart-1…5 by defaultInstrument's palette by default; palette="shadcn" or the class bc-use-shadcn-colors uses --chart-N

Troubleshooting

  • “Functions cannot be passed directly to Client Components”: move the chart into a file that starts with "use client", as in the examples.
  • The chart has no height: set height (the plot height in px), and give a grid child min-width: 0 so the chart can shrink.
  • All series look the same colour: check that each key in config matches a field in your rows, and that your colours are valid CSS. In development, the console warns about invalid colours.
  • The CLI can't find your alias: finish npx shadcn@latest init first, so components.json exists.
  • The CLI asks to overwrite a shared file: you have edited it locally. Compare before accepting; charts from the same release share identical copies.

Older recipe charts from before Instrument are documented under legacy foundations.