RadarChart

Several measures of one thing, drawn as one shape.

Several measures of one thing, on one shape.

A radar answers a question a bar chart cannot: not which of these is biggest but what shape is this. Five scores read as five bars are five facts; read as a polygon they are a profile, and two profiles laid over each other are comparable at a glance in a way two groups of bars never are.

That is also its limit, and worth knowing before you reach for it: the order of the axes changes the outline, and the outline is what people read. Two datasets are only comparable on one radar if their axes are in the same order, and a radar is the wrong chart entirely for data whose axes have no natural order.

Installation

RadarChart ships with the library — no separate install.

import { RadarChart, type RadarChartDatum, Frame, Tabs } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add radar-chart

Usage

<RadarChart data={scores} domain={[0, 100]} size={180}>
  <RadarChart.Grid />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="score" colorIndex={1} showDots />
</RadarChart>

Composition

<RadarChart data={…} axisKey="axis" domain={[0, 100]}>
  <RadarChart.Header title="…" value="…" />   {/* the strip above */}
  <RadarChart.Grid rings={4} />               {/* the scale */}
  <RadarChart.Axis />                         {/* the names round it */}
  <RadarChart.Series dataKey="you" />         {/* one profile */}
  <RadarChart.Series dataKey="team" fillOpacity={0} />
  <RadarChart.Legend />
</RadarChart>
  • RadarChart.Header — The strip above the rings — a caption, a headline figure, and optionally the legend on the trailing end.
  • RadarChart.Grid — The scale, as rings. Polygonal by default; circular draws them as circles.
  • RadarChart.Axis — The axis names, placed around the outside and anchored by which side of the circle they are on.
  • RadarChart.Series — One profile. Filled by default; drop fillOpacity to 0 on the second and subsequent ones.
  • RadarChart.Legend — The series, named and coloured, in the bottom-left corner of the plot — which on a radar is empty by construction.

Examples

The data

One row per axis, in the order they go round, with one key per series. axisKey names the column holding each row’s label; it defaults to axis.

const profile: RadarChartDatum[] = [
  { axis: 'Speed', you: 82, team: 64 },
  { axis: 'Accuracy', you: 71, team: 78 },
  { axis: 'Coverage', you: 55, team: 83 },
  { axis: 'Uptime', you: 94, team: 91 },
  { axis: 'Cost', you: 48, team: 62 },
];

Fix the scale

Pass domain and mean it. A radar derives its outer ring from the data if you do not, and a scale that moves with the data makes two charts incomparable and a small value look like a large one — which is the failure this chart is most prone to. Where there is a known maximum, say so.

{/* The rings are 20, 40, 60, 80, 100 — the same on every chart. */}
<RadarChart data={profile} domain={[0, 100]}>
  <RadarChart.Grid rings={5} />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="you" />
</RadarChart>

Switching what it draws

Change data and the outline travels to the new one a vertex at a time rather than jumping. That movement is the point of putting a radar behind a switch — it is what says which axes moved and by how much, which a shape that simply appeared would not. Keep domain fixed across the switch or the rings move too and nothing is comparable.

const [period, setPeriod] = useState('q3');

<RadarChart data={quarters[period]} domain={[0, 100]}>
  <RadarChart.Grid />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="score" showDots />
</RadarChart>

<Tabs value={period} onValueChange={setPeriod} defaultValue="q3">
  <Tabs.List>
    <Tabs.Trigger value="q1">Q1</Tabs.Trigger>
    <Tabs.Trigger value="q2">Q2</Tabs.Trigger>
    <Tabs.Trigger value="q3">Q3</Tabs.Trigger>
  </Tabs.List>
</Tabs>

Two profiles over each other

Add a second Series. Fill only the first — two translucent fills over each other make a third colour that means nothing, and the reader has to work out which of the three regions is which.

<RadarChart data={profile} domain={[0, 100]}>
  <RadarChart.Grid />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="you" colorIndex={1} />
  <RadarChart.Series dataKey="team" colorIndex={2} fillOpacity={0} />
  <RadarChart.Legend />
</RadarChart>

Circular rings

Rings are polygons by default, drawn through the spokes, because they are read against the shape laid over them — a round ring behind an angular polygon gives every axis a different apparent distance to the edge. Use circular when the chart is for reading a value off one spoke rather than for comparing outlines.

<RadarChart data={profile} domain={[0, 100]}>
  <RadarChart.Grid circular rings={5} />
  <RadarChart.Axis fontSize={10} />
  <RadarChart.Series dataKey="you" fillOpacity={0} showDots />
</RadarChart>

Replay the reveal

Take a ref and call replay() to grow the shape out of the centre again — the reveal scales the values, not the group, so the stroke stays the width it will end at rather than growing with it.

const chart = useRef<RadarChartHandle>(null);

<Button onPress={() => chart.current?.replay()}>Replay</Button>

<RadarChart ref={chart} data={profile} domain={[0, 100]}>
  <RadarChart.Grid />
  <RadarChart.Series dataKey="you" />
</RadarChart>

Versions

One profile

A filled polygon on polygonal rings, with a dot at each vertex.

<Frame>
  <Frame.Header>
    <Frame.Title>Skills</Frame.Title>
    <Frame.Action>Self-assessed</Frame.Action>
  </Frame.Header>
  <Frame.Panel>
    <View className="px-4 pb-4 pt-2">
      <RadarChart data={skills} domain={[0, 100]}>
        <RadarChart.Grid />
        <RadarChart.Axis />
        <RadarChart.Series dataKey="score" colorIndex={1} showDots />
      </RadarChart>
    </View>
  </Frame.Panel>
</Frame>

Two compared

Two profiles over each other, only the first of them filled.

<RadarChart data={profile} domain={[0, 100]}>
  <RadarChart.Grid />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="you" colorIndex={1} />
  <RadarChart.Series dataKey="team" colorIndex={2} fillOpacity={0} />
  <RadarChart.Legend />
</RadarChart>

Outline on circles

Circular rings and no fill, for reading a value off a spoke.

<RadarChart data={profile} domain={[0, 100]}>
  <RadarChart.Header title="Weakest axis" value="Cost" caption="48 of 100" />
  <RadarChart.Grid circular rings={5} />
  <RadarChart.Axis fontSize={10} />
  <RadarChart.Series dataKey="you" colorIndex={3} fillOpacity={0} showDots />
</RadarChart>

Switching the data

Tabs under the chart change what it draws, and the outline travels.

<RadarChart data={quarters[period]} domain={[0, 100]}>
  <RadarChart.Grid />
  <RadarChart.Axis />
  <RadarChart.Series dataKey="score" colorIndex={1} showDots />
</RadarChart>

<Frame.Section className="px-4 py-3">
  <Tabs value={period} onValueChange={setPeriod} defaultValue="q3">
    <Tabs.List>
      {PERIODS.map((entry) => (
        <Tabs.Trigger key={entry.value} value={entry.value}>
          {entry.label}
        </Tabs.Trigger>
      ))}
    </Tabs.List>
  </Tabs>
</Frame.Section>

API Reference

RadarChart

PropTypeDefaultDescription
classNamestring
dataRadarChartDatum[]One row per axis, in the order they go round.
axisKeystring'axis'Key holding each row's axis label.
statusRadarChartStatus'ready'loading holds the shape at the centre until the data arrives, then grows it — one component throughout rather than a spinner swapped for a chart, because swapping loses the transition.
sizenumberDiameter of the outermost ring, in points. The view is that plus the room the axis labels need around it, and centres itself. The ring rather than the box, because the ring is the thing being sized — a box measurement would mean "bigger" also meant "labels further from the shape", and the chart would grow without the drawing growing with it. Pass size={undefined} with an aspectRatio to fill the container the way the other charts do.
aspectRationumber1Width ÷ height when size is not given. 1 is the square a radar wants; the rings stay circular whatever it is set to.
domain[number, number]Fix the scale instead of deriving it from the data. A radar almost always wants this: the shape only means something against a known maximum, and a scale that moves with the data makes two charts incomparable.
animationDurationnumberREVEAL_DURATIONMilliseconds for the reveal on mount.
compactbooleanfalseDrop the room reserved for axis labels, for a radar with none.

RadarChart.Grid

PropTypeDefaultDescription
ringsnumber4How many rings, including the outermost.
colorstringOverrides the themed hairline colour.
circularbooleanfalseDraw the rings as circles rather than as polygons through the spokes.
spokesbooleantrueDraw a line from the centre out to each axis.

RadarChart.Axis

PropTypeDefaultDescription
colorstringOverrides the themed label colour.
fontSizenumberLabel size in points.
offsetnumberHow far outside the rings the labels sit.
formatLabel(label: string, index: number) => stringRewrites a label — to shorten it, or to add a unit.

RadarChart.Series

PropTypeDefaultDescription
dataKeystringKey holding this series' value on each row.
namestringName for the legend. Defaults to dataKey.
colorstringStroke colour. Defaults to the --color-chart-* token at colorIndex, so a series follows the theme without the call site naming a colour.
colorIndexSeriesColorIndex1Which --color-chart-* token to take when color is not given.
strokeWidthnumber2
fillOpacitynumber0.18Opacity of the fill. Two filled polygons over each other make a third colour that means nothing, so drop it towards 0 — or to 0 — on the second and subsequent series.
showDotsbooleanfalseA dot at each vertex. Worth it on a radar with few axes.

RadarChart.Header

PropTypeDefaultDescription
classNamestring
titlestringSmall caption above the value.
valuestringThe headline figure, if there is one.
captionstringA line under the value.
legendbooleanfalseDraw the series legend on the trailing end of the strip.

RadarChart.Legend

PropTypeDefaultDescription
classNamestring
formatName(key: string) => stringRewrites a series' name — the key is rarely what a reader should see.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Three axes is the minimum — two spokes are a line, not a shape — and past about eight the labels start colliding and the outline stops being readable as one form. Five or six is where this chart works.

Changing the data

The shape travels rather than jumping. Each vertex is tweened from where it was to where it is going over 420ms, and a switch made part way through another one carries on from the outline actually on screen rather than snapping back to start again.

Fixing domain matters most here. With a derived scale the rings move at the same moment the shape does, and a profile that got better looks identical to one that got worse.

The reveal

The polygons grow out of the centre rather than sweeping across, because a polar chart has no left-hand edge for a sweep to start from. It scales the values, not the group: a scale transform would grow the stroke and the dots along with the shape and arrive at the wrong stroke width.

It plays once on mount. replay() on the ref runs it again. Under Reduce Motion the shape is simply there.

It sizes itself

Every other chart here fills its container. This one does not, and the reason is that it is square: filling a panel makes a radar as tall as the panel is wide, which is twice the height of the wide chart beside it for the same five or six numbers. It takes a 180pt ring and centres. size moves that; size={undefined} with an aspectRatio goes back to filling the width.

Space for the labels

Axis labels are horizontal text sitting outside a circle, which is why the view is wider than it is tall: the label at three o'clock needs its whole width to the right of the ring, while the one at twelve needs a single line above it. A square box either clips the sides or wastes the top and bottom.

SVG clips at its viewport and does not reflow, so a label with nowhere to go would lose its tail with nothing to show it ever had one. The anchor is pulled back inside the view before drawing, which costs a couple of points of gap and is the cheaper of the two. For labels long enough to still crowd the shape, shorten them with formatLabel — and pass compact to reclaim the room entirely on a radar with no labels at all.

Colour

Series take the --color-chart-* tokens through colorIndex, the same ramp every other chart here uses, so the first series is the same colour as the first series on the chart beside it. The rings read --color-border and the labels --color-muted-foreground.

On this page