Build a Pure CSS Radar Chart — Lightweight, Max Performance

Build a Pure CSS Radar Chart — Lightweight, Max Performance

●1 ●11 ●54
calendar_today ago • schedule4 min read
— Originally published at dev.to

Most radar charts pull in SVG path math or a JavaScript charting library. You can skip both.

With st-core.fscss, a spider (radar) chart is:

  • an FSCSS array of scores (0–100)
  • polar coordinates stored as CSS variables (--st-sxN, --st-syN)
  • shapes drawn with clip-path: polygon()
  • rings and spokes from gradients

No canvas. No SVG. No chart runtime on the critical path.

Source: github.com/fscss-ttr/st-core.fscss


Here’s how it works

1. Data is an array

@arr skills[80, 65, 90, 55, 70, 85]

Length can be 5, 6, 8 — whatever you need. Geometry (number of polygon vertices) is fixed when FSCSS expands the mixins.

2. One mixin writes coordinates

.spider {
  @st-spider-points(skills, 42)  /* array, max radius % of the box */
  position: relative;
  width: 280px;
  height: 280px;
  border-radius: 50%;
}

For each index i:

  • angle = (i - 1) × 360° / N (0° at the top)
  • radius = (value / 100) × rmax
  • CSS gets:
--st-sxN: calc(50% + (r * 1%) * sin(θ));
--st-syN: calc(50% - (r * 1%) * cos(θ));

Trig stays in the browser’s calc(). Updating values later is rewriting those variables.

3. Renderers only read variables

@st-spider-fill(.spider-fill, skills)
@st-spider-line(.spider-line, skills)
@st-spider-dots(.spider-dot-, skills, 9px)
@st-spider-grid(.spider-grid)
@st-spider-spokes(.spider-spokes, skills)
Layer Role
fill Closed polygon through every --st-sx/sy pair
line Dual-pass polygon (outer path + path scaled toward center) so the stroke has real thickness
dots Absolutely positioned markers at each vertex
grid Concentric rings (radial-gradient)
spokes Thin radial axes (conic-gradient)

Rule: call @st-spider-points on the host. Children inherit. Same model as linear @st-chart-points / fill / line in st-core.

4. Markup is plain HTML

<div class="spider">
  <div class="spider-grid"></div>
  <div class="spider-spokes"></div>
  <div class="spider-fill"></div>
  <div class="spider-line"></div>
  <div class="spider-dot-1"></div>
  <div class="spider-dot-2"></div>
  <div class="spider-dot-3"></div>
  <div class="spider-dot-4"></div>
  <div class="spider-dot-5"></div>
  <div class="spider-dot-6"></div>
</div>

Dots need real nodes — the mixin emits .spider-dot-1 … .spider-dot-N rules; it does not invent elements.


Here’s how values can be displayed on the dot nodes

Markers are normal DOM nodes, so you can show the score next to (or inside) each dot without a chart API.

Option A — data-value + CSS ::after

<div class="spider-dot-1" data-value="80"></div>
<div class="spider-dot-2" data-value="65"></div>
<!-- … -->
.spider-dot-1,
.spider-dot-2,
.spider-dot-3,
.spider-dot-4,
.spider-dot-5,
.spider-dot-6 {
  /* st-core already sets left/top from --st-sx / --st-sy */
}

[data-value]::after {
  content: attr(data-value);
  position: absolute;
  left: 50%;
  top: -1.4em;
  transform: translateX(-50%);
  font-size: 0.65rem;
  font-variant-numeric: tabular-nums;
  color: var(--st-text);
  white-space: nowrap;
  pointer-events: none;
}

Push labels slightly outward per axis if they collide with the polygon (different top / left offsets per index).

Option B — nested label element

<div class="spider-dot-1">
  <span class="dot-label">80</span>
</div>
.dot-label {
  position: absolute;
  left: 50%;
  bottom: calc(100% + 4px);
  transform: translateX(-50%);
  font-size: 0.65rem;
  color: var(--st-muted);
}

Option C — update labels from JS with the series

function setSeries(host, values, rmax = 42) {
  const n = values.length;
  let css = "";
  values.forEach((v, i) => {
    const ang = (i * 360) / n;
    const r = (v / 100) * rmax;
    css += `--st-sx${i + 1}: calc(50% + (${r} * 1%) * sin(${ang} * 1deg));`;
    css += `--st-sy${i + 1}: calc(50% - (${r} * 1%) * cos(${ang} * 1deg));`;
    const dot = host.querySelector(`.spider-dot-${i + 1}`);
    if (dot) {
      dot.dataset.value = v;
      // or: dot.querySelector(".dot-label").textContent = v;
    }
  });
  host.style.cssText = css;
}

Geometry and labels stay in sync because both come from the same array pass.

Axis names (Speed, Quality, …) stay outside the dots — absolute labels around the circle — so vertices stay readable at small sizes.


Here’s why it’s lighter

Traditional radar st-core radar
Chart.js / D3 / Recharts bundle Mixins → CSS
SVG path string updates Custom properties + clip-path
Layout + paint on every redraw Often compositor-only if you transition clip-path
Library hydration before first paint CSS paints as soon as the stylesheet applies

Compile time: FSCSS expands loops into concrete polygon() stops and selectors. Array length is fixed in the stylesheet.

Runtime: Changing scores is assigning --st-sxN / --st-syN (and optional label text). No path serializer, no virtual DOM for the shape.

Size: A compiled spider block is on the order of ~1 kb of CSS for the chart rules — not a 50–200 kb chart dependency.

Performance profile:

  • First paint: no JS required for a static series
  • Updates: style recalc + composite; avoid layout thrash if size is fixed
  • Multi-series: extra layers are extra divs + inherited variables, not extra chart instances

That’s “max performance” in the practical sense: minimum work per frame and minimum bytes on the wire.


Here are some use cases of spider (radar) charts

  1. Skills / competency matrices — Speed, Quality, UX, Scale, Cost, Support for a team or candidate.
  2. Product scorecards — Compare features across dimensions (performance, price, ecosystem, docs, support).
  3. Sprint or OKR health — Delivery, quality, collaboration, predictability on one glanceable shape.
  4. Security / compliance posture — Control families as axes; one polygon per system or vendor.
  5. Player or character stats — Games and education UIs where polar layout is the expected metaphor.
  6. Brand or perception studies — Trust, value, innovation, service — survey averages as radii.

Anywhere humans already think in “balanced vs spiky” profiles, a radar chart communicates faster than six separate bars.


Here’s how to do multi-series radar charts with still pure CSS

Each series is a separate host with its own @st-spider-points. Shared grid and spokes sit underneath.

@import((*) from st-core@v2)
@st-root()
@st-spider-root()

@arr seriesA[70, 55, 40, 85, 90]
@arr seriesB[45, 75, 60, 50, 55]
@arr seriesC[85, 40, 70, 35, 65]

@st-spider-fill(.fill-a, seriesA)
@st-spider-line(.line-a, seriesA)
@st-spider-dots(.dot-a-, seriesA, 8px)

@st-spider-fill(.fill-b, seriesB)
@st-spider-line(.line-b, seriesB)
@st-spider-dots(.dot-b-, seriesB, 8px)

@st-spider-fill(.fill-c, seriesC)
@st-spider-line(.line-c, seriesC)
@st-spider-dots(.dot-c-, seriesC, 8px)

@st-spider-grid(.spider-grid)
@st-spider-spokes-n(.multi-spokes, 5)

.multi-spider {
  position: relative;
  width: 320px;
  height: 320px;
  border-radius: 50%;
  background: var(--st-surface);
}

.series-a {
  @st-spider-points(seriesA, 42)
  position: absolute;
  inset: 0;
  --st-accent: #ff9f43;
  --st-spider-fill-opacity: 20%;
}
.series-b {
  @st-spider-points(seriesB, 42)
  position: absolute;
  inset: 0;
  --st-accent: #54a0ff;
  --st-spider-fill-opacity: 16%;
}
.series-c {
  @st-spider-points(seriesC, 42)
  position: absolute;
  inset: 0;
  --st-accent: #ff6b9d;
  --st-spider-fill-opacity: 18%;
}
<div class="multi-spider">
  <div class="spider-grid"></div>
  <div class="multi-spokes"></div>

  <div class="series-a">
    <div class="fill-a"></div>
    <div class="line-a"></div>
    <div class="dot-a-1"></div>
    <div class="dot-a-2"></div>
    <div class="dot-a-3"></div>
    <div class="dot-a-4"></div>
    <div class="dot-a-5"></div>
  </div>

  <div class="series-b">
    <div class="fill-b"></div>
    <div class="line-b"></div>
    <div class="dot-b-1"></div>
    <!-- … -->
  </div>

  <div class="series-c">
    <div class="fill-c"></div>
    <div class="line-c"></div>
    <div class="dot-c-1"></div>
    <!-- … -->
  </div>
</div>

Why this stays pure CSS:

  • No JS chart instance per series
  • Isolation is DOM + custom properties, not a scene graph
  • Opacity and accent are tokens (--st-spider-fill-opacity, --st-accent)
  • Spoke count can match the axis count via @st-spider-spokes-n(..., 5) without tying spokes to one series’ array

If two series shared one host and only one @st-spider-points call, the last write would win and every series would draw the same shape. Separate hosts fix that — the same multi-series rule as linear st-core charts.


Quick start

FSCSS CLI, Vscode extention, or prototyping via its cdn

@import((*) from st-core@v2)

Full API in the repo: github.com/fscss-ttr/st-core.fscss


Pure CSS radar charts are not a compromise. With st-core they’re a styling problem: arrays in, variables out, polygons on screen — lightweight by construction, fast by default.

Part 1 of 1 in st-core.fscss
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

How Lighthouse Performance Scores Are Recorded and Calculated

ApogeeWatcherverified - Aug 18

Tailwind CSS for Beginners: From Zero to Your First Component

muhammadfarhan.dev - Jul 21

Core Web Vitals Benchmarks for Shopify Stores (2026 Data)

ApogeeWatcherverified - Sep 16

I built pure CSS 3D dice cube module: FSCSS

Figsh - Aug 7
chevron_left
1.6k Points • 66 Badges
22Posts
15Comments
12Connections
A full-stack web developer: Building smart solutions, designing meaningful experiences, and continuo... Show more

Related Jobs

View all jobs →

Commenters (This Week)

4 comments
3 comments

Contribute meaningful comments to climb the leaderboard and earn badges!