Data
Connect data
Charts take an array of objects. useChartData loads one from a URL or an async function, with loading, error and empty states.
Field accessors
Props such as label, value, x, y, series and size take a field name or a function. Strings like "1,234", "12%" and "₩5,000" are read as numbers.
// A field name (string) or a function both work
<PieChart data={rows} label="device" value="sales" />
<PieChart data={rows} label={(d) => d.device.toUpperCase()} value={(d) => d.sales / 100} />
// Dot paths reach nested fields
<BarChart data={rows} x="meta.region" y="stats.revenue" />
// For a wide table (one series per column), pass y an array
<LineChart data={rows} x="month" y={["Search", "Social", "Email"]} />Loading, error and empty
ChartState shows each state at the chart's height. An error comes with a Try again button.
Loading states
const st = useChartData(url, { refreshMs: 60_000 });
<ChartState loading={st.loading} error={st.error} empty={st.empty} onRetry={st.reload}>
<BarChart data={st.data} x="q" y="v" />
</ChartState>REST JSON
If the response is wrapped, point select at the inner array. Send headers such as auth through init, and refresh on a timer with refreshMs.
Orders by weekday
import { BarChart, ChartCard, ChartState, useChartData } from "ztd-charts";
/**
* Load from a REST JSON API.
* If the response is wrapped like { data: { items: [...] } }, select pulls out the inner array.
*/
export function RestJsonChart({ url = "/data/orders.json" }: { url?: string }) {
const orders = useChartData<{ day: string; orders: number }>(url, {
select: "data.items",
refreshMs: 60_000,
init: { headers: { Accept: "application/json" } },
});
return (
<ChartCard title="Orders by weekday" subtitle="REST JSON, refreshed every minute" takeaway="Friday is the busiest day" level="basic" replay>
<ChartState loading={orders.loading && !orders.data.length} error={orders.error} empty={orders.empty} onRetry={orders.reload}>
<BarChart data={orders.data} x="day" y="orders" unit=" orders" />
</ChartState>
</ChartCard>
);
}
CSV
Pass a CSV URL as is. Quotes, line breaks inside cells, BOMs and thousands separators are handled.
Monthly revenue by channel
import { ChartCard, ChartState, LineChart, parseCSV, useChartData } from "ztd-charts";
/**
* Pass a CSV file URL as is. It is read with parseCSV, based on the content-type or the .csv extension.
* Cells such as "1,234", "12%" and "₩5,000" become numbers.
*/
export function CsvChart({ url = "/data/sales.csv" }: { url?: string }) {
const sales = useChartData(url);
return (
<ChartCard title="Monthly revenue by channel" subtitle="CSV (USD K)" takeaway="Social grows fast" level="basic" replay>
<ChartState loading={sales.loading && !sales.data.length} error={sales.error} empty={sales.empty} onRetry={sales.reload}>
<LineChart data={sales.data} x="month" y="revenue" series="channel" highlight="Social" unit="K" />
</ChartState>
</ChartCard>
);
}
// If you already have the text, parse it right away
export const rows = parseCSV(`month,channel,revenue
2025-01,Search,120
2025-01,Social,64`);
Supabase and Postgres rows
Supabase (PostgREST) and node-postgres return arrays of rows. numeric columns that arrive as strings are read as numbers. Without keys, the demo shows sample rows.
Daily revenue by plan
import { AreaChart, ChartCard, ChartState, useChartData } from "ztd-charts";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string | undefined;
const SUPABASE_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
/**
* Supabase (PostgREST) returns an array of rows, so you can pass it straight to data.
* numeric columns arrive as strings such as "1234.50", and the chart reads them as numbers.
*
* With supabase-js:
* const { data, error } = await supabase.from("daily_revenue").select("day, plan, revenue").order("day");
* if (error) throw error; return data;
*/
export function SupabaseChart() {
const configured = !!(SUPABASE_URL && SUPABASE_KEY);
const revenue = useChartData(
configured
? `${SUPABASE_URL}/rest/v1/daily_revenue?select=day,plan,revenue&order=day`
: // Without keys, show sample rows (Postgres numeric arrives as strings)
async () => [
{ day: "2025-06-01", plan: "Basic", revenue: "1820.50" },
{ day: "2025-06-01", plan: "Pro", revenue: "2410.00" },
{ day: "2025-06-02", plan: "Basic", revenue: "1902.25" },
{ day: "2025-06-02", plan: "Pro", revenue: "2675.75" },
{ day: "2025-06-03", plan: "Basic", revenue: "1876.00" },
{ day: "2025-06-03", plan: "Pro", revenue: "2890.10" },
],
configured ? { init: { headers: { apikey: SUPABASE_KEY!, Authorization: `Bearer ${SUPABASE_KEY}` } } } : {},
);
return (
<ChartCard title="Daily revenue by plan" subtitle={configured ? "Supabase daily_revenue" : "Supabase sample rows"} takeaway="Pro leads every day" level="basic" replay>
<ChartState loading={revenue.loading && !revenue.data.length} error={revenue.error} empty={revenue.empty} onRetry={revenue.reload}>
<AreaChart data={revenue.data} x="day" y="revenue" series="plan" highlight="Pro" unit="K" />
</ChartState>
</ChartCard>
);
}
/**
* An API that sends Postgres (node-postgres) results to a chart as JSON (in Node: Express or a Next.js Route Handler).
* pg returns numeric and bigint as strings, and you can send them as they are. The chart reads them as numbers.
*
* // app/api/signups/route.ts (Next.js)
* import { Pool } from "pg";
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
* export async function GET() {
* const { rows } = await pool.query(
* "select date_trunc('day', created_at)::date as day, count(*) as signups from users group by 1 order by 1",
* );
* return Response.json(rows); // [{ day: "2025-06-01", signups: "128" }, ...]
* }
*
* On the client:
* const s = useChartData("/api/signups");
* <CalendarHeatmap data={s.data} date="day" value="signups" />
*
* Results that bring column names and rows separately (rows + fields, Google Sheets values) go through fromRows.
*/
import { fromRows } from "ztd-charts";
export const fromPgArrayMode = fromRows(
[
["2025-06-01", "128"],
["2025-06-02", "141"],
],
["day", "signups"],
);
Google Sheets CSV
Paste the CSV address of a sheet published to the web. Addresses with format=csv or out:csv are read as CSV.
Budget by channel
import { ChartCard, ChartState, DonutChart, useChartData } from "ztd-charts";
/**
* Google Sheets: choose File > Share > Publish to web > CSV, or use one of these address formats.
* https://docs.google.com/spreadsheets/d/<sheet ID>/export?format=csv&gid=<tab ID>
* https://docs.google.com/spreadsheets/d/<sheet ID>/gviz/tq?tqx=out:csv&sheet=<tab name>
* Addresses with format=csv or out:csv are read as CSV.
*/
export function SheetChart({ url }: { url: string }) {
const sheet = useChartData(url);
return (
<ChartCard title="Budget by channel" subtitle="Google Sheets CSV" takeaway="Search ads take half" level="basic" replay>
<ChartState loading={sheet.loading && !sheet.data.length} error={sheet.error} empty={sheet.empty} onRetry={sheet.reload}>
<DonutChart data={sheet.data} label="channel" value="budget" centerLabel="Budget ($K)" />
</ChartState>
</ChartCard>
);
}
URL in a JSON spec
Put a url in the spec's data and <Chart spec> fetches it. CLI render fetches it first too.
Orders by weekday
{
"type": "bar",
"data": {
"url": "/data/orders.json",
"select": "data.items"
},
"encoding": {
"x": "day",
"y": "orders"
},
"options": {
"unit": " orders"
},
"card": {
"title": "Orders by weekday",
"subtitle": "data.url in the spec",
"takeaway": "Friday is the peak",
"level": "basic"
}
}Reshape helpers
2D arrays, key-value maps and column sets become arrays of objects too.
import { parseCSV, fromRows, fromObjects, normalizeData } from "ztd-charts";
parseCSV('month,revenue\nJan,"1,200"\nFeb,1350');
// [{ month: "Jan", revenue: 1200 }, { month: "Feb", revenue: 1350 }]
fromRows([["month", "revenue"], ["Jan", 1200], ["Feb", 1350]]); // Google Sheets API values
fromRows([["Jan", "1200"]], ["month", "revenue"]); // SQL rows + column names
fromObjects({ Mobile: 72, Desktop: 23, Tablet: 5 });
// [{ label: "Mobile", value: 72 }, …]
fromObjects({ month: ["Jan", "Feb"], revenue: [1200, 1350] }); // column sets
normalizeData(anything); // CSV text, JSON text, { data }, { rows, columns }, { values }parseCSV(...) → [{"month":"Jan","revenue":1200},{"month":"Feb","revenue":1350}]fromObjects(...) → [{"label":"Mobile","value":72},{"label":"Desktop","value":23},{"label":"Tablet","value":5}]