Plot
A chart you assemble out of its marks.
Charts for the shapes this library does not already ship.
Every other chart here answers one question completely, and decides everything else for you. Plot decides nothing: it measures a box, resolves one scale, and hands both to whatever marks you put inside it. Columns with a line over them, a series against a shaded band, a mark you wrote yourself — all of them are children, drawn in the order they are written.
Reach for a named chart first. LineChart, BarChart and AreaChart carry decisions this one leaves to you — where the baseline belongs, what a gap in a series means, how a loading state should read — and a chart assembled here has to make each of them again.
It is marked alpha. The parts are settled; the names of the geometry usePlot hands out are the part most likely to move.
Installation
Plot ships with the library — no separate install.
import { Plot, usePlot, yOf, Frame, Text, type PlotDatum } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add plotUsage
<Plot data={months} xDataKey="month">
<Plot.Grid />
<Plot.Bars dataKey="revenue" colorIndex={2} />
<Plot.Line dataKey="orders" />
<Plot.YAxis />
<Plot.XAxis />
</Plot>Composition
<Plot data={…}>
<Plot.Header /> {/* the row above the drawing */}
<Plot.Legend /> {/* a swatch and a name per mark */}
<Plot.Grid /> {/* the rules the marks arrive into */}
<Plot.Area /> {/* a series as a fill */}
<Plot.Bars /> {/* a series as columns */}
<Plot.Line /> {/* a series as a stroke */}
<Plot.Dots /> {/* a dot per row */}
<Plot.Layer /> {/* marks of your own, in the SVG tree */}
<Plot.Rule /> {/* a reference line, with a caption */}
<Plot.YAxis /> {/* value labels down the side */}
<Plot.XAxis /> {/* labels along the bottom */}
<Plot.Cursor /> {/* the drag, and the line that follows it */}
<Plot.Tooltip /> {/* the readout that rides the cursor */}
<Plot.Overlay /> {/* anything of yours that is text or takes a touch */}
</Plot>Plot.Header— The row above the drawing — what the plot is of, and the one number worth reading. Passchildrento replace it entirely and keep only its place.Plot.Legend— A swatch and a name for every mark that registered.labelsmaps adataKeyto something a reader recognises; without it the key itself is shown.Plot.Grid— Horizontal rules across the plot. Drawn outside the reveal, so the frame is already there when the marks are uncovered into it.Plot.Area— A series as a fill down to the baseline. Write it before the line it belongs under — the order the marks are written is the order they are drawn.Plot.Bars— A series as columns, all of them in one path. Its presence puts the whole plot on a band scale unlessxScalesays otherwise: a bar centred on the plot's edge is a bar half of which is outside it.Plot.Line— A series as a stroked path.curve="monotone"never overshoots between points;linearjoins them straight.dashArrayis for a series that is not real yet — a forecast, a projection.Plot.Dots— A dot per row, ringed in the page colour so it reads on top of the line rather than in it. For a series short enough that its individual points are worth marking.Plot.Rule— A reference line across the plot at a value — a target, a limit, an average — with a caption naming it. A view rather than an SVG line, so the caption is real text; it hides itself when the value falls outside the axis rather than pinning to the edge and claiming a number the chart does not cover.Plot.Layer— Marks of your own, dropped into the SVG tree where they are written. They reach the geometry throughusePlot()rather than being handed it, because a mark that animates holds hooks and a render prop is not a component.Plot.Overlay— Anything of yours that is text or takes a touch, laid over the drawing. SVG text ignores the platform's text scaling and the theme's font, and a gesture handler cannot be attached to an SVG node at all.Plot.XAxis— Labels along the bottom, each centred on the row it names and clamped inside the frame.Plot.YAxis— Value labels down the side, one per grid line. Give it the sameticksas the grid, or the numbers name lines that are not there. It reads the domain the data settles at rather than the tweening one, so the axis holds still enough to read.Plot.Cursor— The drag, and the line that follows it. The hit area is the whole plot — a cursor you have to land on the line to summon is one nobody finds. Split from the readout because a plot whose value is shown in its own header wants this and no label.Plot.Tooltip— The readout that rides the cursor. Needs aPlot.Cursorbeside it, which owns the gesture; on its own it never appears. Pass a function aschildrento draw it yourself.
Examples
Two marks, one scale
The domain is derived from every mark that registered, so two quantities on one plot are drawn against one axis. That is the reason to compose rather than to stack two charts: two scales drawn over each other look like a comparison and are not one.
Both marks have to be in the same unit. Revenue and costs are money and the gap between them is the margin — reading that off the chart is the point. Revenue and order counts are not: hundreds and tens of thousands on one linear axis puts the smaller series flat along the floor, where it says nothing. There is no second axis; draw the second quantity as its own plot.
Order is drawing order. The line is written after the columns, so it is drawn over them.
<Plot data={months} xDataKey="month" aspectRatio={1.7}>
<Plot.Header title="Revenue" value={money(total)} />
<Plot.Legend labels={{ revenue: 'Revenue', costs: 'Costs' }} />
<Plot.Grid />
<Plot.Bars dataKey="revenue" colorIndex={2} />
<Plot.Line dataKey="costs" colorIndex={1} curve="linear" />
<Plot.Dots dataKey="costs" colorIndex={1} />
<Plot.YAxis />
<Plot.XAxis ticks={6} />
</Plot>Pinning one end of the axis
Either end of yDomain takes a number to hold it there or auto to derive it from the data.
[0, 'auto'] is the case a fixed pair cannot express: the baseline stays at zero while the top still follows whatever arrives. Headroom is not applied to a pinned end — a zero with a tenth of the span taken off it is not a zero.
A plot containing Plot.Bars already gets zero in its derived domain, because a bar's length is measured from zero and an axis that skips it draws six near-identical columns for numbers that differ by half. Pin it when you want the axis to say so explicitly, or to hold the top as well.
<Plot data={months} xDataKey="month" yDomain={[0, 'auto']}>
<Plot.Grid />
<Plot.Bars dataKey="revenue" colorIndex={2} />
<Plot.Rule y={28000} label="Target" />
<Plot.YAxis format={(value) => money(Math.round(value))} />
<Plot.XAxis ticks={6} />
</Plot>A mark of your own
Plot.Layer puts its children in the SVG tree; usePlot() gives them the plot box, the tweening domain and the reveal. The scale functions — xOf, bandOf, yOf, linePath, areaPath, barPath — are exported alongside the component and every one of them is a worklet, so a mark you write is rebuilt on the UI thread on the same frames the built-in ones are.
Write it before the series it sits behind.
function BandBetween({ low, high }: { low: number; high: number }) {
const { plot, domainMin, domainMax } = usePlot();
const animatedProps = useAnimatedProps(() => {
const min = domainMin.value;
const max = domainMax.value;
if (max === min) return { d: '' };
const top = yOf(high, plot, min, max);
const bottom = yOf(low, plot, min, max);
return { d: `M${plot.left},${top}H${plot.left + plot.width}V${bottom}H${plot.left}Z` };
});
return <AnimatedPath animatedProps={animatedProps} fill="#34d399" fillOpacity={0.14} />;
}
<Plot data={months} xDataKey="month">
<Plot.Grid />
<Plot.Layer>
<BandBetween low={20000} high={28000} />
</Plot.Layer>
<Plot.Area dataKey="revenue" />
<Plot.Line dataKey="revenue" />
</Plot>Reading the cursor
Plot.Cursor owns the drag and resolves the row under the finger. Only the index crosses back to the JS thread, and only when it changes, so a drag across a hundred rows costs a hundred re-renders at most rather than one per frame.
Inside the plot, read it with usePlotCursor(). Outside it — a readout in the card's header, a figure elsewhere on the screen — use onActiveIndexChange, since a hook cannot reach up out of the subtree it is called in.
const [active, setActive] = useState(-1);
const row = active >= 0 ? months[active] : null;
<Plot data={months} xDataKey="month" onActiveIndexChange={setActive}>
<Plot.Header
title={row ? `${row.orders} orders` : 'Revenue'}
value={money(row ? row.revenue : latest)}
/>
<Plot.Grid />
<Plot.Area dataKey="revenue" />
<Plot.Line dataKey="revenue" />
<Plot.Cursor />
<Plot.Tooltip formatValue={(value) => money(value)} />
</Plot>Which scale the x-axis is on
point puts the first and last rows on the plot's own edges, which is what a series wants — the line should reach the frame. band gives every row an equal slice and centres it, which is what anything with width needs.
It is resolved from the marks by default: a plot containing Plot.Bars is banded, and anything else is on points. Set xScale yourself for a line chart whose points should sit in the middle of a period rather than on its boundary.
{/* Banded, because of the bars. */}
<Plot data={months} xDataKey="month">
<Plot.Bars dataKey="revenue" />
</Plot>
{/* A line, but on slice centres rather than on the edges. */}
<Plot data={months} xDataKey="month" xScale="band">
<Plot.Line dataKey="revenue" />
</Plot>Versions
Combination
Columns and a line over them — revenue and costs, both money, on one shared scale.
<Plot data={months} xDataKey="month" aspectRatio={1.7}>
<Plot.Header title="Revenue" value={money(total)} />
<Plot.Legend labels={{ revenue: 'Revenue', costs: 'Costs' }} />
<Plot.Grid />
<Plot.Bars dataKey="revenue" colorIndex={2} />
<Plot.Line dataKey="costs" colorIndex={1} curve="linear" />
<Plot.Dots dataKey="costs" colorIndex={1} />
<Plot.YAxis />
<Plot.XAxis ticks={6} />
</Plot>A pinned baseline
Zero held at the bottom, the top left to follow the data, and a target drawn across it.
<Plot data={months} xDataKey="month" yDomain={[0, 'auto']} aspectRatio={1.7}>
<Plot.Header title="Revenue by month" />
<Plot.Grid />
<Plot.Bars dataKey="revenue" colorIndex={2} />
<Plot.Rule y={28000} label="Target" />
<Plot.YAxis format={(value) => money(Math.round(value))} />
<Plot.XAxis ticks={6} />
</Plot>A mark of your own
A shaded band nothing here ships, drawn on the chart's own geometry through Plot.Layer and usePlot.
<Plot data={months} xDataKey="month" aspectRatio={1.7}>
<Plot.Header title="Revenue" caption="Shaded where the month was within the plan" />
<Plot.Grid />
<Plot.Layer>
<BandBetween low={20000} high={28000} />
</Plot.Layer>
<Plot.Area dataKey="revenue" />
<Plot.Line dataKey="revenue" />
<Plot.YAxis />
<Plot.XAxis ticks={6} />
</Plot>Cursor and readout
A drag resolves the row under the finger, and the card's header reads it.
const [active, setActive] = useState(-1);
const row = active >= 0 ? months[active] : null;
<Plot data={months} xDataKey="month" aspectRatio={1.7} onActiveIndexChange={setActive}>
<Plot.Header title={row ? `${row.orders} orders` : 'Revenue'} value={money(row ? row.revenue : latest)} />
<Plot.Grid />
<Plot.Area dataKey="revenue" />
<Plot.Line dataKey="revenue" />
<Plot.Dots dataKey="revenue" />
<Plot.YAxis />
<Plot.XAxis ticks={6} />
<Plot.Cursor />
<Plot.Tooltip formatValue={(value) => money(value)} />
</Plot>API Reference
Plot
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
data | PlotDatum[] | — | The rows. Each one is a position along the x-axis. |
xDataKey | string | 'label' | Key holding the x label. Used by the axis and the readout. |
status | PlotStatus | 'ready' | loading draws the frame and nothing in it, and reveals the marks when it turns ready. One component throughout rather than a spinner swapped for a chart — swapping loses the transition. |
aspectRatio | number | 2 | Width ÷ height. 2 is the wide card shape; 1.6 suits a narrow column. |
animationDuration | number | 700 | Milliseconds for the plot to be uncovered on mount. |
domainDuration | number | 500 | Milliseconds for the y-axis to settle after the data changes. |
yDomain | [PlotBound, PlotBound] | — | The y-domain, as [low, high]. Either end may be a number to pin it there or auto to take it from the data. Pinning one end is the case this exists for: [0, 'auto'] keeps the baseline at zero, which a chart of lengths needs — a bar cropped at the bottom is a length that lies — while still letting the top follow whatever arrives. |
xScale | PlotScale | — | How an index becomes an x. Derived from the marks when left out: a plot with bars in it is banded, and anything else is on points. |
curve | PlotCurve | 'monotone' | How series are joined between points, unless a mark overrides it. |
onActiveIndexChange | (index: number, datum: PlotDatum | null) => void | — | The row under the cursor as it moves, and -1/null when the finger lifts. This is how a readout outside the plot gets its value — that header is not inside this provider, so it cannot use usePlotCursor. |
compact | boolean | false | Drop the padding so the marks reach the edges — for a plot with no axis, grid or cursor, where the shape is the whole point. |
Plot.Grid
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | — | Horizontal rules across the plot. |
color | string | — | |
dashArray | string | — | Dash pattern, e.g. "4,6". Omit for a solid rule. |
opacity | number | 0.18 |
Plot.Series
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | — | Column of data this mark draws. |
color | string | — | Overrides the theme token. |
colorIndex | number | 1 | Which --color-chart-* token to take, 1 to 5. |
Plot.Line
| Prop | Type | Default | Description |
|---|---|---|---|
strokeWidth | number | 2.5 | |
curve | PlotCurve | 'monotone' | monotone never overshoots between points; linear joins them straight. |
dashArray | string | — | Dash pattern, e.g. "6,4" — for a forecast, or a series that is not real. |
Plot.Area
| Prop | Type | Default | Description |
|---|---|---|---|
opacity | number | 0.18 | |
curve | PlotCurve | 'monotone' |
Plot.Bars
| Prop | Type | Default | Description |
|---|---|---|---|
gap | number | 0.35 | Fraction of each slice left empty, 0 to 1. |
radius | number | 4 | Rounds the end the bar grows towards, in points. |
opacity | number | 1 |
Plot.Dots
| Prop | Type | Default | Description |
|---|---|---|---|
size | number | 3.5 | Radius, in points. |
ringWidth | number | 2 | Ring around each dot, so it reads on top of the line rather than in it. |
Plot.Overlay
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Plot.Rule
| Prop | Type | Default | Description |
|---|---|---|---|
y | number | — | Where to draw it, in the data's own units. |
label | string | — | A name for what the line means. Nothing is drawn without one. |
color | string | — | |
className | string | — |
Plot.XAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | — | How many labels to show. The rest are dropped, evenly. |
format | (datum: PlotDatum, index: number) => string | — | Turn a row into its label. Defaults to the value at xDataKey. |
className | string | — |
Plot.YAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | — | How many intervals to divide the axis into. Yields ticks + 1 labels. |
format | (value: number) => string | — | Turn a value into its label. Defaults to a compact number. |
className | string | — |
Plot.Cursor
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — | |
showLine | boolean | — | Hide the vertical line and keep only the touch handling. |
Plot.Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
formatValue | (value: number, key: string) => string | — | Format one series' value. Defaults to a compact number. |
formatX | (datum: PlotDatum) => string | — | Format the heading from the row. Defaults to the value at xDataKey. |
className | string | — |
Plot.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | A word for what the plot is of. |
value | string | — | The figure, large. Usually the total, or the row under the cursor. |
caption | string | — | A line under the value. |
Plot.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
labels | Record<string, string> | — | Names for the columns, keyed by dataKey. Falls back to the key itself. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
The reveal is shared
Every mark is drawn inside one clip rectangle that widens on mount, so the columns, the fill and the line arrive as one drawing rather than as three effects starting together. A composed chart is more at risk of that than a fixed one, which is why the clip lives on the root and not on the marks.
It plays once. Sending status back to loading and returning it to ready arms it again, so a refetch is uncovered rather than appearing whole on the frame the data lands.
What registers, and why
Plot.Line, Plot.Area, Plot.Bars and Plot.Dots each register their dataKey and colour with the root. That is what the derived domain measures and what the legend and the readout list. A mark drawn through Plot.Layer registers nothing — it is not reading a column the root knows about — so give the root a yDomain if what you drew has to fit inside the axis.
Colours
colorIndex picks one of the five --color-chart-* tokens, so a plot follows the active theme and is put on brand by overriding those five in your own global.css. color overrides it with a literal, which is right for a mark whose colour carries meaning of its own.
One axis, and only one
There is no secondary axis and no plan for one. Two scales on one drawing is the chart mistake that survives review most often, because it looks like a comparison from across the room and falls apart the moment anyone reads the numbers. Two quantities in different units are two plots.