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.
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 chartCopies 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.jsonWorks, 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
- 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
- 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
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
- 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
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
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
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.
| Prop | Type | Default |
|---|---|---|
datarequiredThe labels along the bottom and one or more named series of values. A donut draws the first series only. | ChartData | none |
titlerequiredThe name of the figure. | string | none |
summaryA sentence that tells the story of the chart. Defaults to a sentence written from the data. | string | none |
descriptionA line of muted text under the title. When given it is shown instead of the visible summary, which stays available to assistive technology. | string | none |
actionsControls placed at the right of the header, for example a SegmentedControl that switches the time range. They wrap under the title on narrow widths. | node | none |
curveHow 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" |
typeBars, a line, a filled area or a donut. | "bar" | "line" | "area" | "donut" | "bar" |
stackedAdd the series up instead of drawing them side by side. Applies to bar and area. | boolean | false |
heightHeight of the drawing in pixels. The width follows the container. | number | 280 |
showLegendName each series (or each donut slice) below the drawing. Hidden for a single series except in a donut. | boolean | true |
showGridDraw grid lines behind bar, line and area charts. | boolean | true |
valueFormatTurns a value into text for the axis, the tooltip, the labels and the table. Defaults to compact numbers with the data unit. | (value: number) => string | none |
onSelectCalled 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 }) => void | none |
isLoadingShow a placeholder the size of the chart instead of the drawing. | boolean | false |
emptyMessageShown 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