Skip to main content
Gremorie
Data

Radar Chart

Compare series across multiple axes that share a single radial domain.

Overview

RadarChart is the styled wrapper over recharts' RadarChart, composed on the shadcn chart primitive (ChartContainer). Each data row (xKey) becomes a spoke on the polar grid; each entry in config becomes a closed polygon that shares one radial maximum. Use it when you have three to seven dimensions that should be evaluated together - skill profiles, satisfaction scores, capability gap analysis.

Reach for RadarChart when no single dimension is the headline and the shape of the polygon carries meaning across the whole set. For comparing one ordered dimension, LineChart reads faster. For ranking categories on a single metric, BarChart is more legible.

The radial scale is pinned at 100 by default. That is deliberate: comparison is the chart's usual job, and a scale derived from the data makes someone whose best score is 70 fill the plot exactly like someone who scored 100. Pass a number for a different ceiling, or max="auto" when the range is not known ahead of time.

The React edition (@gremorie/rx-data) renders with recharts inside the shadcn chart primitive; the Angular edition (@gremorie/ng-data) renders headless d3 geometry inside a styled SVG. The rendering engine differs; the public surface is identical input-for-input.

Anatomy

<radar-chart>
└─ figure (role="img")                  card surface, aspect-square
   ├─ svg
   │  ├─ polar grid (gridType: polygon | circle, ring count from ticks)
   │  ├─ angle axis (spokes from xKey, wrapped to labelWidth)
   │  ├─ radius axis ticks (optional via radiusAxis)
   │  ├─ radar polygons, one per config key (fill: auto | on | off)
   │  ├─ vertex dots, one per row per series (optional via dots)
   │  └─ active dot, one per series, on the hovered spoke
   ├─ tooltip overlay (optional via tooltip)
   ├─ legend (when config has >1 series)
   └─ sr-only data table

The React edition composes this surface through the shadcn chart primitive:

RadarChart
└─ ChartContainer            responsive square frame + injects --color-<key>
   └─ RadarChart (recharts)  polar plot area over the angular axis (xKey)
      ├─ ChartTooltip        hover card (ChartTooltipContent)
      ├─ PolarAngleAxis      spoke labels (xKey, wrapped to labelWidth)
      ├─ PolarRadiusAxis     the scale: max, ticks, radiusAxis
      ├─ PolarGrid           polygon or circular grid (gridType)
      └─ Radar               one polygon per series, with dot / activeDot

Preview

'use client';import {  RadarChart,  type ChartConfig,  type ChartDatum,} from '@gremorie/rx-data';const monthlyData: ChartDatum[] = [  { month: 'Jan', desktop: 186, mobile: 80 },  { month: 'Feb', desktop: 305, mobile: 200 },  { month: 'Mar', desktop: 237, mobile: 120 },  { month: 'Apr', desktop: 173, mobile: 190 },  { month: 'May', desktop: 209, mobile: 130 },  { month: 'Jun', desktop: 214, mobile: 140 },];const monthlyConfig: ChartConfig = {  desktop: { label: 'Desktop', color: 'var(--chart-1)' },  mobile: { label: 'Mobile', color: 'var(--chart-2)' },};export function RadarChartPreview() {  // These counts run to 305, past the 100 the scale is pinned at by default.  // `auto` is the right answer for a range like this one, which has no natural  // ceiling — a hardcoded top would need revisiting every time the data moves.  return (    <RadarChart      data={monthlyData}      config={monthlyConfig}      xKey="month"      max="auto"    />  );}

Installation

bash npx gremorie@latest add rx-radar-chart

bash pnpm dlx gremorie@latest add rx-radar-chart

bash yarn dlx gremorie@latest add rx-radar-chart

bash bunx --bun gremorie@latest add rx-radar-chart

Usage

import { RadarChart } from "@gremorie/rx-data";
import type { ChartConfig, ChartDatum } from "@gremorie/rx-data";

const data: ChartDatum[] = [
{ skill: "Speed", a: 90, b: 60 },
{ skill: "Power", a: 70, b: 80 },
{ skill: "Stamina", a: 85, b: 70 },
{ skill: "Agility", a: 75, b: 90 },
{ skill: "Skill", a: 95, b: 65 },
];

const config: ChartConfig = {
a: { label: "Player A", color: "var(--chart-1)" },
b: { label: "Player B", color: "var(--chart-2)" },
};

export function PlayerComparison() {
  return <RadarChart data={data} config={config} xKey="skill" />;
}
npx gremorie@latest add ng-radar-chart

Prerequisite

Angular components emit Tailwind utility classes and ship no compiled CSS. Without the setup below in src/styles.css the component mounts but renders completely unstyled. Requires Tailwind CSS v4.

@import 'tailwindcss';@import '@gremorie/tokens/theme.css';/* Required. Tailwind v4 skips node_modules by default, so without this line   the components' utility classes never reach your compiled CSS and every   Gremorie component renders unstyled. */@source '../node_modules/@gremorie';

The @source line is required: Tailwind v4 skips node_modules by default. Adjust the relative path if your stylesheet is not at src/styles.css. Full setup in Installation.

import { Component } from '@angular/core';
import { RadarChart } from '@gremorie/ng-data';
import type { ChartConfig, ChartDatum } from '@gremorie/ng-data';

@Component({
  selector: 'app-player-comparison',
  imports: [RadarChart],
  template: `<radar-chart [data]="data" [config]="config" xKey="skill" />`,
})
export class PlayerComparisonComponent {
  readonly data: ChartDatum[] = [
    { skill: 'Speed', a: 90, b: 60 },
    { skill: 'Power', a: 70, b: 80 },
    { skill: 'Stamina', a: 85, b: 70 },
    { skill: 'Agility', a: 75, b: 90 },
    { skill: 'Skill', a: 95, b: 65 },
  ];

  readonly config: ChartConfig = {
    a: { label: 'Player A', color: 'var(--chart-1)' },
    b: { label: 'Player B', color: 'var(--chart-2)' },
  };
}

API

<RadarChart>

The styled wrapper over recharts' RadarChart, composed on ChartContainer. Renders the responsive frame, a PolarGrid, the PolarAngleAxis (spoke labels), an optional tooltip, and one <Radar> per config key colored var(--color-<key>).

PropTypeDefaultDescription
dataChartDatum[]-Tabular data. One row per spoke. Each row carries the spoke label at xKey plus one numeric value per config key.
configChartConfig-Maps each series key to { label, color }. The color is any CSS color or token, typically "var(--chart-1)" through "var(--chart-5)". ChartContainer injects each as a var(--color-<key>) CSS variable.
xKeystring-Name of the spoke (angle) field on each row. Drives the PolarAngleAxis labels around the perimeter.
gridType"polygon" | "circle""polygon"Shape of the PolarGrid rings - straight-edged polygon (default) or concentric circles.
maxnumber | "auto"100Top of the radial scale, or "auto" to follow the largest value in the data. Pinned so two charts are read against one ruler: with a derived scale a top score of 70 fills the plot exactly like a top score of 100. That is right for a percentage or score and wrong past 100 — pass a number for that range, or "auto" when the range is not known ahead of time.
ticksnumber4Number of grid rings, and of ticks on the radius axis.
radiusAxisbooleanfalseLabels each ring with its value, up the vertical axis.
labelWidthnumber-Wraps spoke labels to this width in pixels, on word boundaries. Without it a long label at the side of the circle is drawn as one run, reaching halfway across the plot.
dotsbooleanfalseDraws a dot at every vertex. The hovered spoke gets a larger dot either way.
fill"auto" | "on" | "off""auto"Polygon fill. auto fills a lone series and outlines two or more, so overlapping series stay readable.
tooltipbooleantrueToggles the hover tooltip.
classNamestring-Merged onto the ChartContainer. The bundled style centers the chart with a square aspect.

Composing with the chart primitive

For full control, compose recharts directly inside ChartContainer using the exported primitives. This mirrors the official shadcn radar block.

ExportRole
ChartContainerResponsive frame; injects per-series --color-<key> vars from config.
ChartTooltiprecharts Tooltip re-export; pair with ChartTooltipContent.
ChartTooltipContentStyled tooltip body.
ChartLegendrecharts Legend re-export; pair with ChartLegendContent.
ChartLegendContentStyled legend body.
useChartHook to read the active config inside a custom child.

Types

TypeShape
ChartDatumRecord<string, string | number> & { fill?: string } - one row of chart data.
ChartConfigRecord<string, { label?: ReactNode; color?: string }> - per-series label and color map.
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
  type ChartDatum,
} from '@gremorie/rx-data';
import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from 'recharts';

const config = {
  a: { label: 'Player A', color: 'var(--chart-1)' },
  b: { label: 'Player B', color: 'var(--chart-2)' },
} satisfies ChartConfig;

export function Example({ data }: { data: ChartDatum[] }) {
  return (
    <ChartContainer
      config={config}
      className="mx-auto aspect-square max-h-[250px]"
    >
      <RadarChart accessibilityLayer data={data}>
        <ChartTooltip cursor={false} content={<ChartTooltipContent />} />
        <PolarAngleAxis dataKey="skill" />
        <PolarGrid gridType="polygon" />
        <Radar
          dataKey="a"
          fill="var(--color-a)"
          fillOpacity={0}
          stroke="var(--color-a)"
          strokeWidth={2}
        />
        <Radar
          dataKey="b"
          fill="var(--color-b)"
          fillOpacity={0}
          stroke="var(--color-b)"
          strokeWidth={2}
        />
      </RadarChart>
    </ChartContainer>
  );
}

Drop down to this level for dashed strokes, gradient fills, marker dots at spoke intersections, or a custom legend via ChartLegend + ChartLegendContent.

Composition

  1. Pick xKey - the spoke label field. Three to seven spokes is the sweet spot; fewer and you get a triangle that hides shape, more and the axes crowd.
  2. Author config with at most three series. Polygons stay legible when stroked; four or more become hard to read.
  3. Order spokes consistently across charts in the same surface so users can compare shapes by muscle memory.
  4. Choose gridType - polygon (default) follows the spoke geometry; circle reads as a target/dartboard when exact spoke alignment matters less.
  5. Decide the scale. Leave max at 100 for a percentage or a score; set a number for a known ceiling; use "auto" when the range comes from somewhere you do not control. A pinned top is not a soft limit - anything above it draws outside the outer ring.
  6. Give long labels a width. Past four or five spokes, labelWidth is what keeps a sentence-long category from reaching back across the plot.

The chart auto-sizes to its parent through ChartContainer's responsive frame, with a square aspect so the polygons stay symmetric.

Variations

Multi-series comparison

'use client';import {  RadarChart,  type ChartConfig,  type ChartDatum,} from '@gremorie/rx-data';const monthlyData: ChartDatum[] = [  { month: 'Jan', desktop: 186, mobile: 80 },  { month: 'Feb', desktop: 305, mobile: 200 },  { month: 'Mar', desktop: 237, mobile: 120 },  { month: 'Apr', desktop: 173, mobile: 190 },  { month: 'May', desktop: 209, mobile: 130 },  { month: 'Jun', desktop: 214, mobile: 140 },];const monthlyConfig: ChartConfig = {  desktop: { label: 'Desktop', color: 'var(--chart-1)' },  mobile: { label: 'Mobile', color: 'var(--chart-2)' },};export function RadarChartPreview() {  // These counts run to 305, past the 100 the scale is pinned at by default.  // `auto` is the right answer for a range like this one, which has no natural  // ceiling — a hardcoded top would need revisiting every time the data moves.  return (    <RadarChart      data={monthlyData}      config={monthlyConfig}      xKey="month"      max="auto"    />  );}
const config: ChartConfig = {
  a: { label: 'Player A', color: 'var(--chart-1)' },
  b: { label: 'Player B', color: 'var(--chart-2)' },
};

<RadarChart data={data} config={config} xKey="skill" />;
<radar-chart [data]="data" [config]="config" xKey="skill" />

The most common shape. Two stroked series let the user compare polygon footprints at a glance. Keep the count at two or three before the overlapping polygons defeat the comparison.

Single series profile

<RadarChart
  data={data}
  config={{ score: { label: 'Score', color: 'var(--chart-1)' } }}
  xKey="dimension"
/>
<radar-chart [data]="data" [config]="config" xKey="dimension" />

For single-subject profiles - one player, one product, one team. A single filled polygon reads cleanly even with five or six spokes.

Scored scale

'use client';import {  RadarChart,  type ChartConfig,  type ChartDatum,} from '@gremorie/rx-data';// A competency review: every value is a 0-100 score, and the same nine// dimensions are rated by three different sources.const reviewData: ChartDatum[] = [  { skill: 'Discovery', self: 82, manager: 74, peers: 68 },  { skill: 'Prototyping', self: 74, manager: 66, peers: 71 },  { skill: 'Visual craft', self: 66, manager: 79, peers: 83 },  { skill: 'Product knowledge', self: 58, manager: 62, peers: 55 },  { skill: 'Problem framing', self: 77, manager: 58, peers: 64 },  { skill: 'Communication', self: 85, manager: 81, peers: 88 },  { skill: 'Collaboration', self: 69, manager: 84, peers: 79 },  { skill: 'Autonomy', self: 61, manager: 70, peers: 58 },  { skill: 'Delivery', self: 73, manager: 67, peers: 76 },];const reviewConfig: ChartConfig = {  self: { label: 'Self', color: 'var(--chart-1)' },  manager: { label: 'Manager', color: 'var(--chart-2)' },  peers: { label: 'Peers', color: 'var(--chart-3)' },};export function RadarChartScoredPreview() {  return (    <RadarChart      data={reviewData}      config={reviewConfig}      xKey="skill"      // Pinned, not derived: every review has to be read against the same      // ruler, or someone whose best score is 70 looks identical to someone      // who scored 100.      max={100}      ticks={10}      radiusAxis      // Nine spokes with two-word labels; without a width the longest is drawn      // as one run and reaches back across the plot.      labelWidth={90}      dots    />  );}

Everything the chart offers for a comparison read, on one surface: a scale pinned to 0-100 with a ring every ten and each ring labelled, spoke labels wrapped so nine of them fit, and a dot on every vertex. This is the shape a competency review wants - three sources rating the same dimensions, read against one ruler.

<radar-chart
  [data]="data"
  [config]="config"
  xKey="skill"
  [max]="100"
  [ticks]="10"
  [radiusAxis]="true"
  [labelWidth]="90"
  [dots]="true"
/>

max={100} is the load-bearing prop. Without it the scale follows the data, and two reviews stop being comparable: a person whose highest score is 70 fills the plot exactly like one who scored 100.

Letting the data set the scale

<RadarChart data={data} config={config} xKey="metric" max="auto" />
<radar-chart [data]="data" [config]="config" xKey="metric" max="auto" />

When the range is not known ahead of time - model output, an API you do not control, counts rather than scores - "auto" measures against the largest value present. The domain is rounded up to a readable bound, so the outermost polygon sits just inside the ring rather than exactly on it.

Filled or outlined

<RadarChart data={data} config={config} xKey="skill" fill="on" />
<radar-chart [data]="data" [config]="config" xKey="skill" fill="on" />

fill defaults to "auto": a lone series is filled, two or more are outlined, because stacked translucent fills turn muddy fast. Force it either way with "on" or "off" - forcing it on past one series lightens the fill so the stack stays readable.

Polygon grid

The default gridType. Straight-edged polygon rings track the spokes, emphasizing the per-spoke geometry.

<RadarChart data={data} config={config} xKey="skill" gridType="polygon" />
<radar-chart [data]="data" [config]="config" xKey="skill" gridType="polygon" />

Circle grid

Set gridType to "circle" for concentric circles that read like a target or dartboard, keeping the rings as a neutral radial scale when exact spoke alignment matters less.

<RadarChart data={data} config={config} xKey="skill" gridType="circle" />
<radar-chart [data]="data" [config]="config" xKey="skill" gridType="circle" />

Accessibility

  • recharts' accessibilityLayer adds keyboard navigation and screen-reader announcements for the plotted series.
  • --chart-1 through --chart-5 ship with WCAG AA contrast against the card background in both light and dark themes.
  • Spoke labels: each PolarAngleAxis tick is rendered as SVG <text> around the polygon, keeping the color-to-series mapping legible alongside the tooltip.
  • Limit series: keep the count low. Outlined polygons take more than filled ones do, but past five series the categorical palette stops assigning a distinct hue - the sixth and beyond fall back to a muted neutral rather than repeating the first colour, which would paint two series identically.
  • Hovering never gates a value: the tooltip reads the whole spoke at once, the hit target is the entire wedge rather than the vertex, and every value is also in the sr-only table.
  • LineChart - reach for a line when one ordered dimension is the headline.
  • BarChart - reach for bars when ranking categories on a single metric.
  • RadialChart - sibling polar layout when categories should be shown as concentric arcs.

On this page