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.

Alpha — the API is still moving. Expect it to change in a minor release.

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 plot

Usage

<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. Pass children to replace it entirely and keep only its place.
  • Plot.Legend — A swatch and a name for every mark that registered. labels maps a dataKey to 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 unless xScale says 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; linear joins them straight. dashArray is 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 through usePlot() 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 same ticks as 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 a Plot.Cursor beside it, which owns the gesture; on its own it never appears. Pass a function as children to 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

PropTypeDefaultDescription
classNamestring
dataPlotDatum[]The rows. Each one is a position along the x-axis.
xDataKeystring'label'Key holding the x label. Used by the axis and the readout.
statusPlotStatus'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.
aspectRationumber2Width ÷ height. 2 is the wide card shape; 1.6 suits a narrow column.
animationDurationnumber700Milliseconds for the plot to be uncovered on mount.
domainDurationnumber500Milliseconds 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.
xScalePlotScaleHow 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.
curvePlotCurve'monotone'How series are joined between points, unless a mark overrides it.
onActiveIndexChange(index: number, datum: PlotDatum | null) => voidThe 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.
compactbooleanfalseDrop 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

PropTypeDefaultDescription
rowsnumberHorizontal rules across the plot.
colorstring
dashArraystringDash pattern, e.g. "4,6". Omit for a solid rule.
opacitynumber0.18

Plot.Series

PropTypeDefaultDescription
dataKeystringColumn of data this mark draws.
colorstringOverrides the theme token.
colorIndexnumber1Which --color-chart-* token to take, 1 to 5.

Plot.Line

PropTypeDefaultDescription
strokeWidthnumber2.5
curvePlotCurve'monotone'monotone never overshoots between points; linear joins them straight.
dashArraystringDash pattern, e.g. "6,4" — for a forecast, or a series that is not real.

Plot.Area

PropTypeDefaultDescription
opacitynumber0.18
curvePlotCurve'monotone'

Plot.Bars

PropTypeDefaultDescription
gapnumber0.35Fraction of each slice left empty, 0 to 1.
radiusnumber4Rounds the end the bar grows towards, in points.
opacitynumber1

Plot.Dots

PropTypeDefaultDescription
sizenumber3.5Radius, in points.
ringWidthnumber2Ring around each dot, so it reads on top of the line rather than in it.

Plot.Overlay

PropTypeDefaultDescription
classNamestring

Plot.Rule

PropTypeDefaultDescription
ynumberWhere to draw it, in the data's own units.
labelstringA name for what the line means. Nothing is drawn without one.
colorstring
classNamestring

Plot.XAxis

PropTypeDefaultDescription
ticksnumberHow many labels to show. The rest are dropped, evenly.
format(datum: PlotDatum, index: number) => stringTurn a row into its label. Defaults to the value at xDataKey.
classNamestring

Plot.YAxis

PropTypeDefaultDescription
ticksnumberHow many intervals to divide the axis into. Yields ticks + 1 labels.
format(value: number) => stringTurn a value into its label. Defaults to a compact number.
classNamestring

Plot.Cursor

PropTypeDefaultDescription
colorstring
showLinebooleanHide the vertical line and keep only the touch handling.

Plot.Tooltip

PropTypeDefaultDescription
formatValue(value: number, key: string) => stringFormat one series' value. Defaults to a compact number.
formatX(datum: PlotDatum) => stringFormat the heading from the row. Defaults to the value at xDataKey.
classNamestring

Plot.Header

PropTypeDefaultDescription
classNamestring
titlestringA word for what the plot is of.
valuestringThe figure, large. Usually the total, or the row under the cursor.
captionstringA line under the value.

Plot.Legend

PropTypeDefaultDescription
classNamestring
labelsRecord<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.

On this page