PyramidChart
Two series mirrored about a centre, on one shared scale.
Two series drawn as mirrored bars either side of a centre line, one row per category.
Both wings are measured on the same scale, derived from the larger of the two series. That is the whole point of the shape: a bar twice as long as the one facing it means twice as much. Colours come from the --color-chart-1 … --color-chart-5 tokens.
For two series compared side by side rather than back to back, use BarChart.
Installation
PyramidChart ships with the library — no separate install.
import { PyramidChart, Frame, Text } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add pyramid-chartUsage
<PyramidChart data={penguins} xDataKey="species">
<PyramidChart.Grid />
<PyramidChart.Bar dataKey="male" side="start" />
<PyramidChart.Bar dataKey="female" side="end" colorIndex={5} />
<PyramidChart.XAxis />
<PyramidChart.YAxis />
<PyramidChart.Tooltip />
</PyramidChart>Composition
<PyramidChart>
<PyramidChart.Grid /> {/* mirrored lines, and the centre */}
<PyramidChart.Bar dataKey="…" side="start" /> {/* the left wing */}
<PyramidChart.Bar dataKey="…" side="end" /> {/* the right one */}
<PyramidChart.Skeleton /> {/* while status="loading" */}
<PyramidChart.XAxis /> {/* values, mirrored around zero */}
<PyramidChart.YAxis /> {/* the category names */}
<PyramidChart.Legend />
<PyramidChart.Tooltip /> {/* the drag, and the readout */}
</PyramidChart>PyramidChart.Header— See props below.PyramidChart.Grid— Mirrored reference lines, plus the solid line down the middle both wings are measured from.PyramidChart.Bar— One wing.sidedecides which, and the default colour differs per side so two bars declared with nothing but a key and a side are already told apart.PyramidChart.Skeleton— Equal stubs either side of the centre, shown whilestatus="loading".PyramidChart.XAxis— The value labels along the bottom — the same magnitudes twice, either side of a zero in the middle.PyramidChart.YAxis— The category names, one per row. By default each sits on its own line over the pair of bars it belongs to;labelPlacementmoves them into a gutter between the wings or down the left instead.PyramidChart.Tooltip— The drag over the plot, the row it highlights, and the readout that follows it down.PyramidChart.Legend— A swatch and a name per series.
Examples
Two wings on one scale
side puts a series on the left or the right. The scale is shared, so the two halves can be read against each other rather than only each against itself.
<Frame className="w-full">
<Frame.Header>
<Frame.Title>Penguins observed</Frame.Title>
<Frame.Action>Drag to inspect</Frame.Action>
</Frame.Header>
<Frame.Panel>
<PyramidChart data={PENGUINS} xDataKey="species" aspectRatio={1.4}>
<PyramidChart.Header
value="334"
caption="Three species"
labels={{ male: 'Male', female: 'Female' }}
legend
/>
<PyramidChart.Grid />
<PyramidChart.Bar dataKey="male" side="start" />
<PyramidChart.Bar dataKey="female" side="end" colorIndex={5} />
<PyramidChart.XAxis />
<PyramidChart.YAxis />
<PyramidChart.Tooltip />
</PyramidChart>
</Frame.Panel>
</Frame>Names between the wings, or down the left
labelPlacement decides where the category names go. The default gives each row a line of its own over its pair of bars. center puts them in a gutter between the wings instead — taken off the bars rather than off the edges, so both wings stay equal — and start puts them down the left, for a chart with room on that side.
Height is the chart's width over aspectRatio and has nothing to do with how many rows there are. So a stack that is running past the bottom of the screen is shortened by raising aspectRatio, not by dropping rows — dropping rows only makes the bars thicker in a frame exactly as tall as before.
<PyramidChart
data={POPULATION}
xDataKey="band"
labelPlacement="center"
aspectRatio={1.5}
barGap={0.3}
>
<PyramidChart.Grid columns={2} />
<PyramidChart.Bar dataKey="men" side="start" />
<PyramidChart.Bar dataKey="women" side="end" colorIndex={5} />
<PyramidChart.XAxis />
<PyramidChart.YAxis />
<PyramidChart.Tooltip />
</PyramidChart>A readout that follows the finger
onActiveIndexChange fires when the row under the finger changes, not on every frame. The header's value is passed in rather than derived, so one header can show a total when nothing is pressed and a row's figures when something is.
const [active, setActive] = useState(null);
return (
<PyramidChart
data={PENGUINS}
xDataKey="species"
onActiveIndexChange={(_index, datum) => setActive(datum)}
>
<PyramidChart.Header
value={String(active ? active.male + active.female : 334)}
caption={active ? `${active.species} · both wings` : 'Three species'}
legend
/>
<PyramidChart.Grid />
<PyramidChart.Bar dataKey="male" side="start" />
<PyramidChart.Bar dataKey="female" side="end" colorIndex={5} />
<PyramidChart.Tooltip />
</PyramidChart>
);Loading
status="loading" holds the bars at the centre and grows them out into the real ones when it turns ready. The placeholder wings are equal on purpose — unequal ones are a distribution the reader cannot tell from the real one until it changes under them.
<PyramidChart data={PENGUINS} xDataKey="species" status={status}>
<PyramidChart.Grid />
<PyramidChart.Skeleton />
<PyramidChart.Bar dataKey="male" side="start" />
<PyramidChart.Bar dataKey="female" side="end" colorIndex={5} />
<PyramidChart.XAxis />
<PyramidChart.YAxis />
</PyramidChart>API Reference
PyramidChart
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
data | PyramidChartDatum[] | — | The rows. Each one is a band across the chart, with a wing either side. |
xDataKey | string | 'name' | Key holding the category label. Used by the axis and the readout. |
status | PyramidChartStatus | 'ready' | loading holds the bars at the centre and grows them out into the real ones when it turns ready. One component throughout, rather than a spinner swapped for a chart — swapping loses the transition. Add a PyramidChart.Skeleton for something to stand in the plot meanwhile. |
aspectRatio | number | 1.2 | Width ÷ height. 1.2 suits three or four rows in a card. |
animationDuration | number | 700 | Milliseconds for the bars to grow out on mount. |
domainDuration | number | 500 | Milliseconds for the scale to settle after the data changes. |
maxValue | number | — | Fix the far end of the shared scale instead of deriving it. The near end is zero either way — a pyramid measures outward from its centre. |
labelPlacement | PyramidChartLabelPlacement | 'above' | Where the category names sit. above, the default, gives each row a line of its own over its pair of bars, so the two wings meet in the middle with nothing standing between them. center puts the names in a gutter between the wings instead, and start down the left edge. |
barGap | number | 0.25 | Fraction of each band left empty, 0 to 1. A fraction rather than a pixel gap so the proportions hold at any height. |
barWidth | number | — | Fixed bar thickness in points. Derived from the band when omitted. |
cornerRadius | number | 4 | Corner radius on the outward end of a bar. |
minBarLength | number | 0 | Smallest length a non-zero bar is drawn at, in points. A value that rounds to nothing still happened, and a bar of zero length says it did not. |
fadedOpacity | number | 0.3 | Opacity of the rows that are not under the finger. |
onActiveIndexChange | (index: number, datum: PyramidChartDatum | null) => void | — | The row under the finger as it moves, and -1/null when it lifts. Fires when the index changes, not per frame. |
PyramidChart.Grid
| Prop | Type | Default | Description |
|---|---|---|---|
columns | number | 2 | How many lines to draw per wing, not counting the centre. |
color | string | — | |
dashArray | string | — | |
opacity | number | 1 | |
centreLine | boolean | true | Draw the solid line down the middle the wings are measured from. |
PyramidChart.Bar
| Prop | Type | Default | Description |
|---|---|---|---|
dataKey | string | — | Column in the data holding this series' values. |
side | PyramidChartSide | 'end' | Which wing it grows into. |
color | string | — | Explicit colour. Defaults to the --color-chart-* token for colorIndex. |
colorIndex | SeriesColorIndex | — | Which of the five chart tokens to take. Defaults to a different one per side, so two bars declared with nothing but a dataKey and a side are already told apart. |
cornerRadius | number | — | Corner radius, overriding the chart's. |
PyramidChart.Skeleton
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | — | How many placeholder rows to draw. Defaults to one per row of data, and to five when the data has not arrived — the count is the one thing a loading chart can be honest about only if it already has the rows. |
duration | number | — | Milliseconds for one pass of the sweep. |
color | string | — |
PyramidChart.XAxis
| Prop | Type | Default | Description |
|---|---|---|---|
ticks | number | — | How many labels per wing, not counting the zero in the middle. |
format | (value: number) => string | — | Format a value for its label. Defaults to a compact number. |
className | string | — |
PyramidChart.YAxis
| Prop | Type | Default | Description |
|---|---|---|---|
format | (datum: PyramidChartDatum, index: number) => string | — | Turn a row into its label. Defaults to the value at xDataKey. |
className | string | — |
PyramidChart.Tooltip
| Prop | Type | Default | Description |
|---|---|---|---|
formatValue | (value: number, key: string) => string | — | Format one series' value. Defaults to a compact number. |
formatX | (datum: PyramidChartDatum) => string | — | Format the readout's heading from the row. Defaults to the value at xDataKey. |
className | string | — |
PyramidChart.Legend
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
labels | Record<string, string> | — | Prettier names for the series keys. |
PyramidChart.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | Small line above the value — what the chart is of. |
value | string | — | The readout. The largest thing on the card, and the first thing read. |
caption | string | — | One muted line under the value — a period, a comparison, a total. |
labels | Record<string, string> | — | Prettier names for the series keys, as the legend takes. |
legend | boolean | false | Draw a swatch and a name per series along the trailing edge. Prefer this to PyramidChart.Legend on a chart that has a header: the legend floats over the plot, where it competes with the bars for the same corner. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
One scale, and where it comes from
The far end of the scale is the largest value in either series, with a little headroom. The near end is zero and cannot be moved — a wing cropped at its base is a length that lies. maxValue fixes the far end when several charts have to be comparable with each other.
Which side a series is on comes from side, not from the sign of its numbers, so a value is a distance outward from the centre. A negative one has no direction left to grow in and is drawn as nothing; it still appears in the readout, so a data error shows up as a gap rather than as a bar pointing the wrong way.
Where the names go
labelPlacement="above", the default, gives each row a line of its own over its pair of bars, so the two wings meet in the middle with nothing standing between them and the name is read before the lengths it belongs to. "center" puts the names in a gutter between the wings instead — taken off the bars rather than off the edges, so both wings stay equal — and "start" puts them down the left.
The value labels along the bottom are held inside the chart: the outermost tick of each wing sits on the plot's own edge, and a label centred there would hang half its width off the side.
Drawing
Each wing is one animated path split in two — the row under the finger, and everything else — so a chart of thirty rows costs two animated props a frame rather than thirty. The corners are rounded on the outward end only, which is why they are drawn as a path rather than with rx.
The axis labels are React Native text over the plot rather than SVG text, so they follow the theme's font and the platform's text scaling.
Accessible data
The chart exposes one screen-reader summary and one semantic entry per data row; its SVG paths, axes and markers stay decorative. Set accessibilityLabel for the summary, accessibilityLabelForDatum to phrase a row in your own words, and onAccessibilityDatumPress to make each row activatable. Pass accessible={false} to drop the semantic layer entirely.
Public exports
Values: PyramidChart, usePyramidChart
Types: PyramidChartProps, PyramidChartHandle, PyramidChartHeaderProps, PyramidChartGridProps, PyramidChartBarProps, PyramidChartSkeletonProps, PyramidChartXAxisProps, PyramidChartYAxisProps, PyramidChartTooltipProps, PyramidChartLegendProps, PyramidChartDatum, PyramidChartStatus, PyramidChartSide, PyramidChartLabelPlacement