Chart

A dependency-free plotting primitive for trends, funnels and pies. Compose the named parts into one measured SVG; the legend focuses and toggles series, and every hue re-themes.

Weekly signups by platform
weekWebiOSAndroid
Jul 71,8401,210760
Jul 141,9201,260790
Jul 211,8801,300780
Jul 282,0501,280820
Aug 42,0101,350850
Aug 112,1801,410840
Aug 182,1401,390880
Aug 252,4601,5801,010
Sep 12,5201,6201,040
Sep 82,4801,6501,020
Sep 152,6101,7001,080
Sep 222,6901,7601,110
Sep 291,120740470

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add chart

Usage#

Chart is composed from named parts, like Card. The root takes the data, an optional index key for category labels, and a config that names each series’ label and color. The parts (ChartGrid, ChartArea, ChartLine, ChartTooltip) read it from context and draw into one shared, pixel-measured SVG.

revenue-chart.tsxTSX
import {  Chart,  ChartGrid,  ChartArea,  ChartLine,  ChartXAxis,  ChartTooltip,} from "@/components/ui/chart"const revenue = [  { month: "Jan", revenue: 4200 },  { month: "Feb", revenue: 4600 },  // …]export function RevenueChart() {  return (    <Chart      data={revenue}      index="month"      config={{ revenue: { label: "Revenue", color: "blue" } }}      padding={{ bottom: 28, top: 12 }}      className="h-72"    >      <ChartGrid />      <ChartArea dataKey="revenue" />      <ChartLine dataKey="revenue" />      <ChartXAxis />      <ChartTooltip />    </Chart>  )}

Load animation#

Every chart plays a one-shot reveal the first time it mounts: the ChartLine self-draws left to right, the ChartArea fill rises from the baseline, and each ChartBar grows up from the zero line in a left-to-right cascade. It fires once, never on hover or re-sort, and honors prefers-reduced-motion (every layer simply appears drawn). Pass animate={false} on the Chart root to opt out. Press Replay to watch it again.

monthRevenue
Jan4,200
Feb3,800
Mar5,100
Apr4,600
May6,200
Jun5,400
Jul7,100
Aug6,500
monthOrders
Jan240
Feb198
Mar312
Apr277
May388
Jun305
Jul372
Aug410

Line#

Layer a ChartLine per series; each resolves its hue from config, or takes an explicit color. The shared ChartTooltip lists every series at the hovered category, and a crosshair tracks the active point.

Desktop vs. mobile visitors by month
monthDesktopMobile
Jan2,4001,800
Feb2,9002,100
Mar2,2002,600
Apr3,4002,300
May3,1003,200
Jun4,2002,900
Jul3,8003,600
Aug4,6004,100

Area#

ChartArea fills the space under the trend with a currentColor gradient that fades to the baseline, so the wash is just the series hue at low opacity: a blue line yields a soft blue-100/200 fill. Pair it with a ChartLine for a crisp edge.

Monthly revenue
monthRevenue
Jan4,200
Feb3,800
Mar5,100
Apr4,600
May6,200
Jun5,400
Jul7,100
Aug6,500

Smooth curves#

Pass curve="monotone" to ChartLine, ChartArea or ChartAreas to join the points with a monotone cubic instead of straight segments. It is smooth, yet it still passes through every point and never swings above a peak or below a trough the data does not have: a plateau stays flat and an extreme sits exactly on its point, so the curve never invents a value. Gaps, stacking, dashed, markEnd, the draw-in and the hover dot all follow the curve. Set the same curve on the area and the line drawn over it.

linear (default)

Sessions by month, linear curve
monthSessions
Jan420
Feb480
Mar480
Apr610
May440
Jun520
Jul520
Aug700

monotone

Sessions by month, monotone curve
monthSessions
Jan420
Feb480
Mar480
Apr610
May440
Jun520
Jul520
Aug700

Missing data#

A point with a non-numeric (or absent) value is a gap. By default the ChartLine and ChartArea break at the gap, so a dropout reads honestly. Pass connectNulls to bridge across it instead.

Default: line breaks at gaps

daySignal
Mon40
Tue52
Wed
Thu48
Fri61
Sat
Sun70

connectNulls: bridged

daySignal
Mon40
Tue52
Wed
Thu48
Fri61
Sat
Sun70

Bar#

ChartBar draws a bar per category from a 0-anchored baseline. Each bar stands square on the baseline and rounds only at its value end (the bottom, for a negative bar), and ChartGrid draws the zero line a step stronger, so the bars stand on a floor. Hovering a column brings it forward and dims the rest, so the active value reads at a glance.

Orders by month
monthOrders
Jan240
Feb198
Mar312
Apr277
May388
Jun305
Jul372
Aug410

Value labels & highlight#

Pass label to ChartBar to print each value just past its bar end. It returns inline content, so a figure can carry a glyph. To single one datum out, draw the series in neutral (the foreground at 10% on every theme) and pass its index to highlight, so that one bar takes the brand. Hovering a column also lifts its x-axis tick to the foreground.

Revenue by week
weekRevenue
Week 118,484
Week 221,489
Week 324,941
Week 419,994

Highlight & active bar#

highlight on ChartBar paints chosen bars in highlightColor (the brand by default) and leaves the rest in the series hue: an index for today, a list, a predicate over the row, or "active" for the bar under the pointer. The chart reports that bar through onActiveIndexChange (and takes a controlled activeIndex), so a legend or a headline can follow it. Here the latest month is lit until another is hovered. Swapping the highlight recolors the bars in place, so the grow-in never replays, and the tooltip swatch takes the hue the bar is wearing. While a column is hovered the others dim, except neutral bars: already faint, they hold their ink and the hovered one steps up instead, so no bar ever disappears.

Today in the brand

Orders this week
dayOrders
Mon42
Tue51
Wed47
Thu58
Fri63
Sat38
Sun45

Follows the pointer

Aug:$6,500
Revenue by month
monthRevenue
Jan4,200
Feb3,800
Mar5,100
Apr4,600
May6,200
Jun5,400
Jul7,100
Aug6,500

Grouped & stacked bars#

ChartBars draws several bar series in one band. By default they render grouped side by side; set stack on Chart to stack them, and the y-axis grows to the per-category total automatically. Grouped series rotate distinct hues; stacked ones take shades of one blue, since their order already tells them apart. A color in config always wins.

Grouped

Desktop vs. mobile visitors (grouped)
monthDesktopMobile
Jan2,4001,800
Feb2,9002,100
Mar2,2002,600
Apr3,4002,300
May3,1003,200
Jun4,2002,900
Jul3,8003,600
Aug4,6004,100

Stacked

Desktop vs. mobile visitors (stacked)
monthDesktopMobile
Jan2,4001,800
Feb2,9002,100
Mar2,2002,600
Apr3,4002,300
May3,1003,200
Jun4,2002,900
Jul3,8003,600
Aug4,6004,100

Stacked area#

ChartAreas stacks several area series into a band chart when stack is set on Chart: each series sits on the cumulative total below it as a flat band with its own edge line. A stack reads by its order, so series that name no color take shades of the house blue, dark at the floor to light at the top (palette="tonal", the default whenever stack is set). Without stack the series overlap from the baseline.

Desktop vs. mobile visitors, stacked
monthDesktopMobile
Jan2,4001,800
Feb2,9002,100
Mar2,2002,600
Apr3,4002,300
May3,1003,200
Jun4,2002,900
Jul3,8003,600
Aug4,6004,100

Legend#

Put ChartLegend inside the Chart and it keys the chart’s own series, sits above the plot (or below it, with placement="bottom") and becomes the chart’s control surface, as in Mixpanel. Hover a key and its series comes forward while the rest recede; click it to hide or show the series, and the scale and the stack refit around what is left; Alt+click shows it alone. The last series showing never hides. Past max keys, the rest fold into a +N more menu with the same controls.

New revenue by channel
monthOrganicPaid searchSocialEmailReferralPartners
Mar4231181496
Apr45292115107
May51341917127
Jun48382416119
Jul563627191310
Aug614125211512
Sep674430221713

Outside a chart, ChartLegend is a plain key: feed it the same config (or explicit items) and each swatch resolves its hue exactly like the chart, palette fallback included.

DesktopMobile
Desktop vs. mobile visitors by month
monthDesktopMobile
Jan2,4001,800
Feb2,9002,100
Mar2,2002,600
Apr3,4002,300
May3,1003,200
Jun4,2002,900
Jul3,8003,600
Aug4,6004,100

Give an item a value and it prints after the label in the foreground, so the key doubles as a summary of each series. Inside a chart, value="sum", "average" or "last" summarises each series from the data, in its own format.

Travel:$456,478Health:$234,567Food:$123,456Technology:$789,012

Driving it from outside#

The series state is controllable, so a table under the chart can run it, the way Mixpanel pairs every insight with its breakdown. hiddenSeries with onHiddenSeriesChange takes the checkboxes, and focusedSeries with onFocusedSeriesChange lights the plan under the pointer. A series and the period it is compared against always stay lit together.

Monthly recurring revenue by plan
monthEnterpriseProBusinessStarter
Apr51,20042,10036,80018,400
May51,20044,60038,90019,100
Jun58,90047,30041,20018,800
Jul58,90049,80042,60020,300
Aug64,40053,90045,10021,200
Sep71,30057,40047,80021,900
ShownPlanSep MRRSince Apr
Enterprise$71,300+39.3%
Pro$57,400+36.3%
Business$47,800+29.9%
Starter$21,900+19.0%

Dual axis & per-series format#

Give a series axis: "right" in config to scale it against a secondary y-axis (so two metrics at different magnitudes share one chart), and render a second ChartYAxis with side="right". Each series can also carry its own format, so the tooltip shows, say, currency for revenue and a plain count for orders.

Revenue and orders by month
monthRevenueOrders
Jan4,200240
Feb3,800198
Mar5,100312
Apr4,600277
May6,200388
Jun5,400305
Jul7,100372
Aug6,500410

Forecast & end marker#

ChartLine takes dashed for projected data and markEnd to pin a filled dot on the last point (a “current value” marker). Split actuals and projection into two series that share the seam point, and the solid line meets its dashed continuation cleanly.

Revenue with projection
monthActualProjected
Jan4,200
Feb4,600
Mar5,100
Apr4,800
May5,600
Jun6,1006,100
Jul6,700
Aug7,200

Incomplete period#

Today’s count is still climbing, so drawn like a finished day it reads as a collapse. Pass partial on Chart (or a number, for several trailing periods) and the open period draws as provisional, Mixpanel’s treatment: a line dashes from the last complete point on the exact same curve, its area fades, a bar hatches, and the tooltip marks the period with partialLabel.

Daily active users
dayActive users
Sep 163,120
Sep 173,260
Sep 183,190
Sep 193,380
Sep 202,840
Sep 212,610
Sep 223,290
Sep 233,440
Sep 243,510
Sep 253,470
Sep 263,620
Sep 273,010
Sep 282,790
Sep 291,460
Orders per day
dayOrders
Sep 16212
Sep 17238
Sep 18224
Sep 19251
Sep 20188
Sep 21171
Sep 22243
Sep 23262
Sep 24259
Sep 25271
Sep 26284
Sep 27203
Sep 28186
Sep 2997

Compare to previous period#

Name the earlier period in a series’ config with compare and the pair reads as one story. The earlier series becomes a ghost: it borrows the hue, takes no palette slot, and its ChartLine draws thin, faint and dashed with no prop set. The tooltip prints the change beside the current value, green up and red down; give a down-is-good metric (churn, refunds, latency) inverted and the colors flip. In ChartBars the ghost stands beside its partner, faint.

Weekly revenue, this quarter against last
weekThis quarterLast quarter
W148,20041,800
W251,60044,100
W349,30045,600
W455,80043,900
W558,10047,200
W654,70049,800
W761,20048,600
W863,90051,300
W960,40053,700
W1066,80052,100
W1169,50055,400
W1272,30057,900

Reference lines#

ChartReferenceLine draws a horizontal threshold (y) or vertical marker (x) across the plot, with an optional label. Compose it after the data so it annotates on top, for a target, limit, or average. It defaults to a muted dashed line; pass a color to emphasize it.

Monthly revenue against target
monthRevenue
Jan4,200
Feb3,800
Mar5,100
Apr4,600
May6,200
Jun5,400
Jul7,100
Aug6,500

Annotations#

ChartAnnotation pins a note to a category: a launch, an outage, a release, so the jump in the line explains itself. Its marker sits in a lane above the plot (the plot steps down to make room on its own) with a faint rule down to the baseline, and hovering it opens the note. Mixpanel numbers its markers; pass any short label, or none for a dot, and a color for a note that is bad news. The text also reaches screen readers beside the data table.

Daily signups in September
daySignups
Sep 1420
Sep 2443
Sep 3462
Sep 4475
Sep 5481
Sep 6479
Sep 7471
Sep 8460
Sep 9450
Sep 10803
Sep 11613
Sep 12450
Sep 13464
Sep 14484
Sep 15507
Sep 16529
Sep 17408
Sep 18560
Sep 19564
Sep 20561
Sep 21552
Sep 22541
Sep 23601
Sep 24595
Sep 25596
Sep 26605
Sep 27620
Sep 28641
Sep 29664

Tooltip rows#

footer on ChartTooltip adds rows under the series, below a hairline, for a figure the chart does not draw: here the net of income and expenses. Build each row with ChartTooltipItem (a swatch, a label, a value) so it lines up with the rows above and its swatch keeps its hue in the portaled bubble. labelFormatter receives the whole row and its index after the label, so the heading can say more than the axis (a full month name).

Income against expenses by month
monthIncomeExpenses
Mar51,20038,400
Apr48,60041,200
May56,30039,800
Jun53,90044,100
Jul61,50042,700
Aug58,80045,300

Tooltip order & totals#

With several series, the tooltip reads best as a ranking. sort="desc" orders the rows by their value at the hovered category, so the leader comes first however the lines cross (a comparison row stays under its series). total adds the column’s sum below a hairline (a string names it), and maxItems folds the tail into a +N more line. While a legend key holds the focus, the other rows recede with their series.

Support tickets by region
weekNorth AmericaEMEAAPACLATAMOther
Aug 441235624118862
Aug 1143837125917658
Aug 1840139224820171
Aug 2546641827721466
Sep 145244129022974
Sep 848943731123669
Sep 1550346230524881
Sep 2252148833225977

Axis labels that fit#

interval="auto" on ChartXAxis measures its labels against the plot’s width and skips just enough of them that none collide, re-thinning as the chart resizes. It counts back from the last category, so the latest day or month always carries its name. minTickGap sets the clear space between labels (12px), and minInterval keeps a month of days reading by the week however wide the plot. A number still labels every Nth category from the first.

Wide

Visits per day, wide plot
dayVisits
Aug 291,200
Aug 301,297
Aug 311,385
Sep 11,455
Sep 21,501
Sep 31,519
Sep 41,508
Sep 51,472
Sep 61,415
Sep 71,345
Sep 81,270
Sep 91,202
Sep 101,147
Sep 111,114
Sep 121,108
Sep 131,131
Sep 141,181
Sep 151,254
Sep 161,343
Sep 171,441
Sep 181,537
Sep 191,623
Sep 201,690
Sep 211,731
Sep 221,745
Sep 231,731
Sep 241,691
Sep 251,631
Sep 261,560
Sep 271,486

Narrow

Visits per day, narrow plot
dayVisits
Aug 291,200
Aug 301,297
Aug 311,385
Sep 11,455
Sep 21,501
Sep 31,519
Sep 41,508
Sep 51,472
Sep 61,415
Sep 71,345
Sep 81,270
Sep 91,202
Sep 101,147
Sep 111,114
Sep 121,108
Sep 131,131
Sep 141,181
Sep 151,254
Sep 161,343
Sep 171,441
Sep 181,537
Sep 191,623
Sep 201,690
Sep 211,731
Sep 221,745
Sep 231,731
Sep 241,691
Sep 251,631
Sep 261,560
Sep 271,486

Axis titles & compact figures#

ChartYAxis prints compact figures by default (12K, 1.4M), the way Mixpanel and Attio label an axis; pass tickFormatter for currency or units. Both axes take a label: the y title turns to run along the outer edge, the x title centers under the ticks, as in Attio’s report builder. Give the chart the room for them in padding.

Companies by primary location
countryCompanies
United States128
United Kingdom64
Germany41
France33
Spain27
Netherlands19
Sweden12

Negative values#

The domain anchors to zero, and bars and areas grow from the value baseline (the y-zero line), so positive values rise above it and negative values drop below. No extra config: pass data that crosses zero and it reads correctly.

Net change by month
monthNet change
Jan12
Feb-8
Mar15
Apr-4
May22
Jun-11
Jul18
Aug9

Fitted domain#

A full chart starts its y-axis at zero, which is right for bars and totals. A balance or a rate moves in a band far from zero, and a zero floor flattens it into a line. Pass domain="fit" and the axis wraps the data’s own extent in nice rounded bounds, with ticks on a nice step, so the grid and the labels still land on round values. It never crosses zero when the data does not. A [min, max] pair still fixes the domain exactly.

auto (default): from zero

Balance at each week's close, auto domain
weekBalance
Jul 51,674,200
Jul 121,681,900
Jul 191,690,400
Jul 261,697,800
Aug 21,709,600
Aug 91,722,300
Aug 161,738,900
Aug 231,731,400
Aug 301,768,200
Sep 61,790,600
Sep 131,805,300
Sep 201,821,500
Sep 271,842,300

fit: around the data

Balance at each week's close, fit domain
weekBalance
Jul 51,674,200
Jul 121,681,900
Jul 191,690,400
Jul 261,697,800
Aug 21,709,600
Aug 91,722,300
Aug 161,738,900
Aug 231,731,400
Aug 301,768,200
Sep 61,790,600
Sep 131,805,300
Sep 201,821,500
Sep 271,842,300

Funnel#

ChartFunnel draws conversion through ordered steps, as Mixpanel and Attio do. Each step is a column, and above it a faint ghost rises to the step before, so what dropped off reads as the empty part of the column. Each step carries its conversion from the first, and ChartTooltip adds the conversion and drop-off rows by itself. Give the chart about 32px of padding.top for the first label.

Signup funnel, last 30 days
stepUsers
Visited pricing12,480
Started signup5,920
Verified email4,310
Created workspace3,180
Invited a teammate1,240

Pie & donut#

ChartPie draws each row as a slice, clockwise from twelve o’clock, with a clean gap between slices. innerRadius sets the hole (0.62 by default, a donut; 0 for a pie), and its children center in the hole. Hovering a slice lifts it out a step while the rest recede, with its value and share in the tooltip. A ChartLegend in the same chart keys the slices in their hues and lights them from the key.

Visits by source
sourceVisits
Organic search18,420
Direct9,310
Referral5,870
Social4,120
Email2,280
Open deals by stage
stageDeals
Lead42
Qualified27
Proposal16
Negotiation9
Won6

Category bar#

CategoryBar breaks one whole into its parts in a single line: credits by tool, storage by file type, time by project. It takes the same rows as a pie (each one a category, named by index, sized by dataKey), so where a donut spends a square, this sits under a figure or across a card. Each segment’s width is its share, with a clean gap between segments, and CategoryBarLegend keys them underneath with the share after each name. Hovering a segment or a key lights it and recedes the rest, with its amount and share in the tooltip. Give the long tail’s Other the gray hue.

size sets the thickness (6, 8 or 12px), palette="tonal" paints shades of the house blue instead of distinct hues, and the legend’s value prints the share, the amount (through valueFormatter), both, or nothing.

size="sm"

size="md"

size="lg", palette="tonal", value="both"

Sparkline#

Pass sparkline for a chromeless trend: zero padding (it bleeds to the edge), points spanning the full width, and currentColor so a single text-* utility sets the hue. This is exactly what StatSparkline renders under the hood.

Revenue

Sessions

Refunds

Sparkline bars#

A sparkline can draw bars too, for a week of orders beside a figure. Once a ChartBar is in it, the categories sit in bands (the first and last bars keep their full width) and the scale runs from zero, so the shortest bar still reads as a bar. Add ChartTooltip for a hover readout, and highlight to light today.

Orders

344

Refunds

9

Colors#

Every series takes a semantic hue: blue (the default), teal, purple, orange, pink, the status roles, or current to inherit the ambient color. Series without a color follow the chart’s palette: categorical rotates distinct hues led by blue, for lines that cross and must be told apart; tonal spreads the blue ramp (blue-800 to blue-200) as far apart as the count allows, and is the default for a stack.

blue

teal

purple

orange

pink

Loading & empty#

Pass loading to render a Skeleton in place of the plot (it also sets aria-busy). When data is empty, the chart shows a muted message; override it with the empty prop.

With data

monthRevenue
Jan4,200
Feb3,800
Mar5,100
Apr4,600
May6,200
Jun5,400
Jul7,100
Aug6,500

Loading

Empty

No data to display

Accessibility#

The SVG is decorative (aria-hidden): pixels do not read well to a screen reader. Instead, every full chart renders a visually-hidden data table as its accessible equivalent, so assistive tech gets the real numbers, row by row. Pass label to name the chart, it becomes the table’s caption. loading sets aria-busy. Sparklines stay decorative (no table), since they are labelled by their surrounding context, like a Stat’s value.

accessible-chart.tsxTSX
// "label" names the chart and captions the visually-hidden data table// that screen readers read in place of the (aria-hidden) SVG.<Chart data={revenue} index="month" label="Monthly revenue"  config={{ revenue: { label: "Revenue", color: "blue" } }} className="h-72">  <ChartGrid />  <ChartArea dataKey="revenue" />  <ChartLine dataKey="revenue" />  <ChartXAxis />  <ChartTooltip valueFormatter={(v) => `$${v.toLocaleString("en-US")}`} /></Chart>

API reference#

Chart takes data (an array of rows, or a bare number[]), index, config, domain ("auto", the default, from zero; "fit" around the data; or a fixed [min, max]), sparkline, stack (stack multi-series bars and size the domain to the total), palette ("categorical" hues or "tonal" shades of blue for series with no color; tonal by default on a stack), padding, animate (the load reveal, on by default), crosshair (the hover tracking line, which paints behind the data so the trend stays on top; defaults on for full charts, off for sparklines), activeIndex and onActiveIndexChange (the hovered category, controlled or reported), hiddenSeries, defaultHiddenSeries and onHiddenSeriesChange (the series switched off), focusedSeries and onFocusedSeriesChange (the series in focus), partial (trailing periods still in progress), loading, empty, and label (accessible name + data-table caption), and forwards div props, a ref included (merged with the one it measures through). The series parts ( ChartLine, ChartArea, ChartBar) take a dataKey and optional color; ChartBar also takes label (a value label per bar) and highlight with highlightColor (bars painted in another hue); ChartLine and ChartArea also take connectNulls and curve ("linear" or "monotone"); ChartLine adds dashed, markEnd and variant ("comparison" draws the faint dashed ghost, the default for a series another one compares against). ChartBars and ChartAreas draw several bar / area series (grouped, or stacked when stack is set) from a keys list, and ChartAreas takes a curve too. ChartGrid, ChartXAxis (with interval, a number or "auto", plus minTickGap and minInterval), and ChartYAxis (with side="right" for the secondary axis and compact figures by default) are the chrome, both axes with an optional label title, and ChartGrid takes baseline (the stronger zero line, on by default); per-series config entries take format, axis, compare (the key of the period it is measured against) and inverted (down is good); ChartAnnotation pins a note (index, label, title, description, color) to a category; ChartFunnel draws steps with their drop-off (dataKey, barRatio, radius, label); ChartPie draws a pie or a donut (dataKey, innerRadius, gap, colors, valueFormatter, tooltip, and children for the hole); CategoryBar splits one whole in a single bar (data, index, dataKey, colors, palette, size, valueFormatter, tooltip, animate, label) with CategoryBarLegend under it (value: share, value, both or none); ChartReferenceLine adds a y/x threshold with an optional label; ChartTooltip adds the hover bubble with valueFormatter, labelFormatter (label, row, index), footer, sort, total, maxItems and partialLabel, whose rows are ChartTooltipItems (color, label, value, change, inverted, ghost, dimmed); and ChartLegend is the key: inside a chart it lists the series (or a pie’s slices) and focuses, hides and isolates them, with placement, max, interactive and value; outside it is a plain key fed the same config, or items with an optional value. Every part accepts className, merged last.

FAQ#

Each series part takes a dataKey that names the field in your data rows, for example ChartLine dataKey="revenue". The part resolves its hue from the matching config entry, or you can pass an explicit color.