Skip to content

Chart

A bar, line, area or donut chart drawn in plain SVG without a chart library. It fits its container, can be explored with a pointer or the arrow keys, and always comes with a written summary and a View as table switch that shows the same numbers.

Datav0.1.0experimentalWCAG 2.2 AAView spec
Revenue by monthJanuary to SeptemberRevenue grew in most months, reaching $57.5K in September.
import { Chart } from "@rdloom/react";

export default function ChartBarExample() {
  return (
    <div className="w-[40rem] max-w-full">
      <Chart
        title="Revenue by month"
        description="January to September"
        summary="Revenue grew in most months, reaching $57.5K in September."
        data={{
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"],
          series: [{ name: "Revenue", values: [21000, 26500, 31000, 29000, 41000, 48000, 46500, 52000, 57500] }],
          unit: "$",
        }}
      />
    </div>
  );
}

Installation

npx rdloom add chart

Copies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes.

Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/chart.json

Works, but without upgrade tracking.

Usage

import { Chart } from "@rdloom/react";

<div className="w-[40rem] max-w-full">
  <Chart
    title="Revenue by month"
    description="January to September"
    summary="Revenue grew in most months, reaching $57.5K in September."
    data={{
      labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"],
      series: [{ name: "Revenue", values: [21000, 26500, 31000, 29000, 41000, 48000, 46500, 52000, 57500] }],
      unit: "$",
    }}
  />
</div>

Stacked

Tickets by channelSupport tickets opened each monthStacked bar chart of Email, Chat, Phone across 8 points. Email is highest at 182 (Aug) and lowest at 120 (Jan).
  • Email
  • Chat
  • Phone
import { Chart } from "@rdloom/react";

export default function ChartStackedExample() {
  return (
    <div className="w-[40rem] max-w-full">
      <Chart
        stacked
        title="Tickets by channel"
        description="Support tickets opened each month"
        data={{
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"],
          series: [
            { name: "Email", values: [120, 140, 135, 160, 150, 170, 165, 182] },
            { name: "Chat", values: [80, 95, 110, 105, 130, 140, 152, 148] },
            { name: "Phone", values: [40, 35, 30, 38, 25, 22, 28, 24] },
          ],
        }}
      />
    </div>
  );
}

Line

Revenue and costMonthly totals for the yearLine chart of Revenue, Cost across 12 points. Revenue is highest at $71,000 (Dec) and lowest at $21,000 (Jan).
  • Revenue
  • Cost
import { Chart } from "@rdloom/react";

export default function ChartLineExample() {
  return (
    <div className="w-[40rem] max-w-full">
      <Chart
        type="line"
        title="Revenue and cost"
        description="Monthly totals for the year"
        data={{
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
          series: [
            { name: "Revenue", values: [21000, 26500, 31000, 29000, 41000, 48000, 46500, 52000, 57500, 55000, 63000, 71000] },
            { name: "Cost", values: [14000, 12000, 15500, 16000, 18000, 19500, 21000, 22500, 24000, 25500, 27000, 29500] },
          ],
          unit: "$",
        }}
      />
    </div>
  );
}

Area

VisitorsVisitors for the last 3 monthsArea chart of Desktop, Mobile across 90 points. Desktop is highest at 486 (Apr 17) and lowest at 186 (Jun 7).
  • Desktop
  • Mobile
import { useState } from "react";
import { Chart, SegmentedControl, SegmentedControlItem } from "@rdloom/react";

const DAYS = 90;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// Deterministic data: two sine waves plus a seeded random walk.
function makeSeries(base: number, swing: number, seed: number) {
  let state = seed;
  const next = () => {
    state = (state * 1664525 + 1013904223) % 4294967296;
    return state / 4294967296;
  };
  let walk = 0;
  return Array.from({ length: DAYS }, (_, i) => {
    walk += (next() - 0.5) * swing * 0.6;
    return Math.max(10, Math.round(base + Math.sin(i / 6) * swing + Math.sin(i / 17) * swing * 0.8 + walk));
  });
}

const start = new Date(2024, 3, 1);
const labels = Array.from({ length: DAYS }, (_, i) => {
  const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
  return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
});
const desktop = makeSeries(320, 90, 7);
const mobile = makeSeries(210, 70, 21);

const ranges = { "90": "Last 3 months", "30": "Last 30 days", "7": "Last 7 days" } as const;

export default function ChartAreaExample() {
  const [range, setRange] = useState<keyof typeof ranges>("90");
  const count = Number(range);
  return (
    <div className="w-[44rem] max-w-full rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)] p-5">
      <Chart
        type="area"
        title="Visitors"
        description={`Visitors for the ${ranges[range].toLowerCase()}`}
        height={260}
        actions={
          <SegmentedControl label="Time range" selectedKey={range} onChange={(key) => setRange(key as keyof typeof ranges)}>
            <SegmentedControlItem id="90">3 months</SegmentedControlItem>
            <SegmentedControlItem id="30">30 days</SegmentedControlItem>
            <SegmentedControlItem id="7">7 days</SegmentedControlItem>
          </SegmentedControl>
        }
        data={{
          labels: labels.slice(-count),
          series: [
            { name: "Desktop", values: desktop.slice(-count) },
            { name: "Mobile", values: mobile.slice(-count) },
          ],
        }}
      />
    </div>
  );
}

Donut

Traffic by sourceDonut chart of Visits across 4 parts, 10,600 in all. Search is the largest at 49%.
  • Search 49%
  • Direct 29%
  • Referral 13%
  • Social 9%
import { Chart } from "@rdloom/react";

export default function ChartDonutExample() {
  return (
    <div className="w-[26rem] max-w-full">
      <Chart
        type="donut"
        title="Traffic by source"
        data={{
          labels: ["Search", "Direct", "Referral", "Social"],
          series: [{ name: "Visits", values: [5200, 3100, 1400, 900] }],
        }}
      />
    </div>
  );
}

Interactive

Signups by monthBar chart of Signups across 6 points. Signups is highest at 610 (May) and lowest at 320 (Jan).

Nothing picked yet.

import { useState } from "react";
import { Chart } from "@rdloom/react";

// Click a bar, or move with the arrow keys and press Enter.
export default function ChartInteractiveExample() {
  const [picked, setPicked] = useState<string>("Nothing picked yet.");
  return (
    <div className="flex w-[36rem] max-w-full flex-col gap-3">
      <Chart
        title="Signups by month"
        data={{
          labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"],
          series: [{ name: "Signups", values: [320, 410, 380, 520, 610, 580] }],
        }}
        onSelect={(point) => setPicked(`${point.label}: ${point.series} ${point.value}`)}
      />
      <p role="status" className="text-sm text-[var(--rd-color-text-muted)]">
        {picked}
      </p>
    </div>
  );
}

With filters

Revenue and costMonthly totals for the current yearLine chart of Revenue, Cost across 12 points. Revenue is highest at $71,000 (Dec) and lowest at $21,000 (Jan).
  • Revenue
  • Cost
import { useState } from "react";
import { Chart, SegmentedControl, SegmentedControlItem } from "@rdloom/react";

const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const revenue = [21000, 26500, 31000, 29000, 41000, 48000, 46500, 52000, 57500, 55000, 63000, 71000];
const cost = [14000, 12000, 15500, 16000, 18000, 19500, 21000, 22500, 24000, 25500, 27000, 29500];

// The filter lives outside the chart: it just changes the data the chart is given.
export default function ChartWithFiltersExample() {
  const [months_, setMonths] = useState("12");
  const count = Number(months_);
  return (
    <div className="w-[44rem] max-w-full rounded-[var(--rd-radius-overlay)] border border-[var(--rd-color-border-default)] p-5">
      <Chart
        type="line"
        title="Revenue and cost"
        description="Monthly totals for the current year"
        height={260}
        actions={
          <SegmentedControl label="Months shown" size="sm" selectedKey={months_} onChange={(key) => setMonths(String(key))}>
            <SegmentedControlItem id="12">12 months</SegmentedControlItem>
            <SegmentedControlItem id="6">6 months</SegmentedControlItem>
            <SegmentedControlItem id="3">3 months</SegmentedControlItem>
          </SegmentedControl>
        }
        data={{
          labels: months.slice(-count),
          series: [
            { name: "Revenue", values: revenue.slice(-count) },
            { name: "Cost", values: cost.slice(-count) },
          ],
          unit: "$",
        }}
      />
    </div>
  );
}

Loading and empty

Revenue by month
Loading chart
Revenue by month

No sales yet this year.

import { Chart } from "@rdloom/react";

export default function ChartLoadingAndEmptyExample() {
  return (
    <div className="flex w-[36rem] max-w-full flex-col gap-6">
      <Chart isLoading title="Revenue by month" height={160} data={{ labels: [], series: [] }} />
      <Chart title="Revenue by month" height={160} data={{ labels: [], series: [] }} emptyMessage="No sales yet this year." />
    </div>
  );
}

API Reference

Defined by the spec. Components also accept the props of the React Aria component they wrap.

PropTypeDefault
datarequired

The labels along the bottom and one or more named series of values. A donut draws the first series only.

ChartDatanone
titlerequired

The name of the figure.

stringnone
summary

A sentence that tells the story of the chart. Defaults to a sentence written from the data.

stringnone
description

A line of muted text under the title. When given it is shown instead of the visible summary, which stays available to assistive technology.

stringnone
actions

Controls placed at the right of the header, for example a SegmentedControl that switches the time range. They wrap under the title on narrow widths.

nodenone
curve

How a line or area joins its points: a smooth curve that never overshoots the data, or straight segments. Ignored for bar and donut.

"smooth" | "linear""smooth"
type

Bars, a line, a filled area or a donut.

"bar" | "line" | "area" | "donut""bar"
stacked

Add the series up instead of drawing them side by side. Applies to bar and area.

booleanfalse
height

Height of the drawing in pixels. The width follows the container.

number280
showLegend

Name each series (or each donut slice) below the drawing. Hidden for a single series except in a donut.

booleantrue
showGrid

Draw grid lines behind bar, line and area charts.

booleantrue
valueFormat

Turns a value into text for the axis, the tooltip, the labels and the table. Defaults to compact numbers with the data unit.

(value: number) => stringnone
onSelect

Called when a point is clicked, or when Enter or Space is pressed on the active point.

(point: { index: number; label: string; series: string; value: number }) => voidnone
isLoading

Show a placeholder the size of the chart instead of the drawing.

booleanfalse
emptyMessage

Shown when there is nothing to chart.

string"No data to chart."

Accessibility

Role figure, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.

Keyboard

  • Tab: moves to the plot, which is one tab stop
  • ArrowLeft / ArrowRight: move to the previous or next value (ArrowUp / ArrowDown on a donut)
  • Home / End: jump to the first or last value
  • Escape: clears the active value
  • Enter / Space: selects the active value (calls onSelect)

Screen readers announce

  • Announced as a figure named by the title, then the summary text
  • "Revenue by month. Use the left and right arrow keys to move between values, group"
  • Each arrow key press reads the active value, such as "Feb: Revenue $26,500, Cost $12,000"
  • "View as table, toggle button, not pressed"
  • The table reads every value under its row and column headers

What your code must do

  • The drawing is decoration; the title, the written summary and the data table carry the meaning
  • The plot is a single focusable group named by the title, described by the summary, with a visible focus ring
  • Moving with the keyboard announces the active value as text, for example "Feb: Revenue $26,500, Cost $12,000"
  • View as table swaps in the same data as an accessible table (aria-pressed on the switch)
  • Series differ by marker shape or label as well as color; the legend repeats the same marks
  • A donut names each slice with its percentage; slices are separated by a gap
  • Colors come only from the chart color tokens and text stays at 4.5:1
  • Loading is marked with aria-busy and shows no invented numbers
  • Empty data shows a message instead of an empty frame

Guidelines

Use it when

  • Showing trends, comparisons or shares of a whole in a product screen
  • Letting people point at a value to read it exactly

Avoid it when

  • Dashboards with many linked, zoomable charts: use a charting library
  • Exact numbers matter most: show a table
  • A single number with a trend: use Stat with a Sparkline

Don't

  • A chart with no summary
  • Telling series apart by color only
  • A donut with many slices or with several series (it draws the first series only)
  • Hard-coding series colors instead of the chart color tokens

Design tokens

The semantic tokens this component uses. Change them once and every component follows; see Design tokens.

  • --rd-color-text-default
  • --rd-color-text-muted
  • --rd-color-chart-1
  • --rd-color-chart-2
  • --rd-color-chart-3
  • --rd-color-chart-4
  • --rd-color-chart-5
  • --rd-color-chart-6
  • --rd-color-border-default
  • --rd-color-border-strong
  • --rd-color-surface-default
  • --rd-color-surface-subtle
  • --rd-color-surface-raised
  • --rd-color-focus-ring
  • --rd-radius-control
  • --rd-elevation-floating