Most radar charts pull in SVG path math or a JavaScript charting library. You can skip both.
With st-core.fscss v2, 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
- Skills / competency matrices — Speed, Quality, UX, Scale, Cost, Support for a team or candidate.
- Product scorecards — Compare features across dimensions (performance, price, ecosystem, docs, support).
- Sprint or OKR health — Delivery, quality, collaboration, predictability on one glanceable shape.
- Security / compliance posture — Control families as axes; one polygon per system or vendor.
- Player or character stats — Games and education UIs where polar layout is the expected metaphor.
- 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.
Top comments (1)
Official Platform Update
Security protocols have been updated for all developer accounts.