UI Components Medium
Radar Chart
A multi-axis spider/radar chart rendered in SVG. Supports multiple data series, a shaded polygon overlay, axis labels, concentric grid rings, and hover tooltips per axis.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 700px;
margin: 0 auto;
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.78rem;
color: var(--text-muted);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 50%;
}
.chart-wrap {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 16px;
position: relative;
display: flex;
justify-content: center;
}
.chart-svg {
display: block;
}
.radar-ring {
fill: none;
stroke: var(--border);
stroke-width: 1;
}
.radar-axis {
stroke: var(--border);
stroke-width: 1;
}
.radar-polygon {
fill-opacity: 0.18;
stroke-width: 2;
}
.radar-dot {
r: 4;
}
.radar-label {
fill: var(--text-muted);
font-size: 11px;
font-family: inherit;
}
.chart-tooltip {
position: absolute;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}const AXES = ["Design", "Frontend", "Backend", "DevOps", "Testing", "Communication"];
const SERIES = [
{ label: "Alice", color: "#818cf8", data: [85, 90, 65, 70, 80, 92] },
{ label: "Bob", color: "#34d399", data: [70, 75, 88, 82, 60, 75] },
];
const SIZE = 360,
CX = SIZE / 2,
CY = SIZE / 2 + 10,
R = 130,
RINGS = 5;
const svg = document.getElementById("radarSvg");
const legend = document.getElementById("legend");
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("chartWrap");
SERIES.forEach((s) => {
const item = document.createElement("div");
item.className = "legend-item";
item.innerHTML = `<span class="legend-swatch" style="background:${s.color}"></span><span>${s.label}</span>`;
legend.appendChild(item);
});
function polarXY(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * (Math.PI / 180);
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
function draw() {
svg.setAttribute("viewBox", `0 0 ${SIZE} ${SIZE}`);
svg.setAttribute("width", Math.min(wrap.clientWidth - 32, SIZE));
svg.setAttribute("height", Math.min(wrap.clientWidth - 32, SIZE));
svg.innerHTML = "";
const n = AXES.length;
const step = 360 / n;
// Rings
for (let r = 1; r <= RINGS; r++) {
const pts = AXES.map((_, i) => polarXY(CX, CY, (R / RINGS) * r, i * step).join(",")).join(" ");
svg.appendChild(elSvg("polygon", { points: pts, class: "radar-ring" }));
// tick label
const [lx, ly] = polarXY(CX, CY, (R / RINGS) * r, 0);
const lbl = elSvg("text", { x: lx + 3, y: ly, class: "radar-label", "font-size": "9" });
lbl.textContent = Math.round((100 / RINGS) * r);
svg.appendChild(lbl);
}
// Axes + labels
AXES.forEach((axis, i) => {
const [ex, ey] = polarXY(CX, CY, R, i * step);
svg.appendChild(elSvg("line", { class: "radar-axis", x1: CX, y1: CY, x2: ex, y2: ey }));
const [lx, ly] = polarXY(CX, CY, R + 22, i * step);
const anchor = lx < CX - 5 ? "end" : lx > CX + 5 ? "start" : "middle";
const lbl = elSvg("text", { x: lx, y: ly + 4, class: "radar-label", "text-anchor": anchor });
lbl.textContent = axis;
svg.appendChild(lbl);
});
// Series
SERIES.forEach((s, si) => {
const pts = s.data.map((v, i) => polarXY(CX, CY, (v / 100) * R, i * step).join(",")).join(" ");
const poly = elSvg("polygon", {
points: pts,
class: "radar-polygon",
fill: s.color,
stroke: s.color,
style: `animation:fadeIn .5s ${si * 0.15}s ease both`,
});
svg.appendChild(poly);
s.data.forEach((v, i) => {
const [dx, dy] = polarXY(CX, CY, (v / 100) * R, i * step);
const dot = elSvg("circle", {
class: "radar-dot",
cx: dx,
cy: dy,
fill: s.color,
stroke: "var(--surface)",
"stroke-width": 2,
"data-si": si,
"data-i": i,
});
dot.addEventListener("mouseenter", (e) => {
tooltip.innerHTML = `<strong>${AXES[i]}</strong><br/>${s.label}: <strong>${v}</strong>`;
tooltip.hidden = false;
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
dot.addEventListener("mouseleave", () => {
tooltip.hidden = true;
});
svg.appendChild(dot);
});
});
}
function elSvg(tag, attrs = {}) {
const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
Object.entries(attrs).forEach(([k, v]) => e.setAttribute(k, v));
return e;
}
const style = document.createElement("style");
style.textContent = `@keyframes fadeIn{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:none}}`;
document.head.appendChild(style);
const ro = new ResizeObserver(draw);
ro.observe(wrap);
draw();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Radar Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<div>
<h1 class="chart-title">Skills Assessment</h1>
<p class="chart-sub">Team comparison</p>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="chart-wrap" id="chartWrap">
<svg id="radarSvg" class="chart-svg" aria-label="Radar chart"></svg>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
const AXES = ["Design", "Frontend", "Backend", "DevOps", "Testing", "Communication"];
const SERIES = [
{ label: "Alice", color: "#818cf8", data: [85, 90, 65, 70, 80, 92] },
{ label: "Bob", color: "#34d399", data: [70, 75, 88, 82, 60, 75] },
];
const SIZE = 320,
CX = SIZE / 2,
CY = SIZE / 2 + 10,
R = 120,
RINGS = 5;
function polarXY(r: number, angleDeg: number): [number, number] {
const a = (angleDeg - 90) * (Math.PI / 180);
return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
}
function points(data: number[], r = R) {
const step = 360 / AXES.length;
return data.map((v, i) => polarXY((v / 100) * r, i * step));
}
function ptsStr(pts: [number, number][]) {
return pts.map((p) => p.join(",")).join(" ");
}
type Tooltip = { axis: string; label: string; value: number; x: number; y: number } | null;
export default function ChartRadarRC() {
const [tooltip, setTooltip] = useState<Tooltip>(null);
const [hidden, setHidden] = useState<Set<number>>(new Set());
const step = 360 / AXES.length;
const toggleHidden = (i: number) =>
setHidden((prev) => {
const n = new Set(prev);
n.has(i) ? n.delete(i) : n.add(i);
return n;
});
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[600px]">
<div className="flex gap-3 mb-4 justify-center">
{SERIES.map((s, i) => (
<button
key={s.label}
onClick={() => toggleHidden(i)}
className={`flex items-center gap-1.5 text-[12px] px-3 py-1 rounded border transition-opacity ${hidden.has(i) ? "opacity-30" : "opacity-100"} border-[#30363d] hover:border-[#8b949e]`}
>
<span className="w-2.5 h-2.5 rounded-full" style={{ background: s.color }} />
<span className="text-[#8b949e]">{s.label}</span>
</button>
))}
</div>
<div className="flex justify-center">
<svg width={SIZE} height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`}>
{/* Rings */}
{Array.from({ length: RINGS }, (_, r) => {
const pts = AXES.map((_, i) => polarXY((R / RINGS) * (r + 1), i * step));
return (
<g key={r}>
<polygon points={ptsStr(pts)} fill="none" stroke="#21262d" strokeWidth={1} />
<text
x={polarXY((R / RINGS) * (r + 1), 0)[0] + 3}
y={polarXY((R / RINGS) * (r + 1), 0)[1]}
fill="#484f58"
fontSize={9}
>
{Math.round((100 / RINGS) * (r + 1))}
</text>
</g>
);
})}
{/* Axes + labels */}
{AXES.map((axis, i) => {
const [ex, ey] = polarXY(R, i * step);
const [lx, ly] = polarXY(R + 22, i * step);
const anchor = lx < CX - 5 ? "end" : lx > CX + 5 ? "start" : "middle";
return (
<g key={axis}>
<line x1={CX} y1={CY} x2={ex} y2={ey} stroke="#30363d" strokeWidth={1} />
<text x={lx} y={ly + 4} textAnchor={anchor} fill="#8b949e" fontSize={11}>
{axis}
</text>
</g>
);
})}
{/* Series */}
{SERIES.map((s, si) => {
if (hidden.has(si)) return null;
const pts = points(s.data);
return (
<g key={s.label}>
<polygon
points={ptsStr(pts)}
fill={s.color}
fillOpacity={0.15}
stroke={s.color}
strokeWidth={2}
/>
{pts.map(([px, py], i) => (
<circle
key={i}
cx={px}
cy={py}
r={4}
fill={s.color}
stroke="#0d1117"
strokeWidth={2}
style={{ cursor: "pointer" }}
onMouseEnter={(e) =>
setTooltip({
axis: AXES[i],
label: s.label,
value: s.data[i],
x: e.clientX,
y: e.clientY,
})
}
onMouseLeave={() => setTooltip(null)}
/>
))}
</g>
);
})}
</svg>
</div>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<div className="font-semibold text-[#e6edf3]">{tooltip.axis}</div>
<div className="text-[#8b949e]">
{tooltip.label}: <strong className="text-[#e6edf3]">{tooltip.value}</strong>
</div>
</div>
)}
</div>
);
}<script setup>
import { ref, computed } from "vue";
const AXES = ["Design", "Frontend", "Backend", "DevOps", "Testing", "Communication"];
const SERIES = [
{ label: "Alice", color: "#818cf8", data: [85, 90, 65, 70, 80, 92] },
{ label: "Bob", color: "#34d399", data: [70, 75, 88, 82, 60, 75] },
];
const SIZE = 320;
const CX = SIZE / 2;
const CY = SIZE / 2 + 10;
const R = 120;
const RINGS = 5;
const step = 360 / AXES.length;
const hidden = ref(new Set());
const tooltip = ref(null);
function toggleHidden(i) {
const n = new Set(hidden.value);
n.has(i) ? n.delete(i) : n.add(i);
hidden.value = n;
}
function polarXY(r, angleDeg) {
const a = (angleDeg - 90) * (Math.PI / 180);
return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
}
function points(data) {
return data.map((v, i) => polarXY((v / 100) * R, i * step));
}
function ptsStr(pts) {
return pts.map((p) => p.join(",")).join(" ");
}
const rings = computed(() => {
return Array.from({ length: RINGS }, (_, r) => {
const pts = AXES.map((_, i) => polarXY((R / RINGS) * (r + 1), i * step));
const labelPos = polarXY((R / RINGS) * (r + 1), 0);
return {
pts: ptsStr(pts),
label: Math.round((100 / RINGS) * (r + 1)),
lx: labelPos[0] + 3,
ly: labelPos[1],
};
});
});
const axes = computed(() => {
return AXES.map((axis, i) => {
const [ex, ey] = polarXY(R, i * step);
const [lx, ly] = polarXY(R + 22, i * step);
const anchor = lx < CX - 5 ? "end" : lx > CX + 5 ? "start" : "middle";
return { axis, ex, ey, lx, ly, anchor };
});
});
const seriesData = computed(() => {
return SERIES.map((s, si) => {
const pts = points(s.data);
return { ...s, si, pts, ptsStr: ptsStr(pts) };
});
});
function handleEnter(e, si, i) {
tooltip.value = {
axis: AXES[i],
label: SERIES[si].label,
value: SERIES[si].data[i],
x: e.clientX,
y: e.clientY,
};
}
</script>
<template>
<div class="page">
<div class="wrap">
<div class="legend">
<button v-for="(s, i) in SERIES" :key="s.label" class="legend-btn"
:style="{ opacity: hidden.has(i) ? 0.3 : 1 }" @click="toggleHidden(i)">
<span class="legend-dot" :style="{ background: s.color }"></span>
<span class="legend-label">{{ s.label }}</span>
</button>
</div>
<div class="chart-center">
<svg :width="SIZE" :height="SIZE" :viewBox="`0 0 ${SIZE} ${SIZE}`">
<!-- Rings -->
<g v-for="ring in rings" :key="ring.label">
<polygon :points="ring.pts" fill="none" stroke="#21262d" stroke-width="1"/>
<text :x="ring.lx" :y="ring.ly" fill="#484f58" font-size="9">{{ ring.label }}</text>
</g>
<!-- Axes + labels -->
<g v-for="a in axes" :key="a.axis">
<line :x1="CX" :y1="CY" :x2="a.ex" :y2="a.ey" stroke="#30363d" stroke-width="1"/>
<text :x="a.lx" :y="a.ly + 4" :text-anchor="a.anchor" fill="#8b949e" font-size="11">{{ a.axis }}</text>
</g>
<!-- Series -->
<template v-for="s in seriesData" :key="s.label">
<g v-if="!hidden.has(s.si)">
<polygon :points="s.ptsStr" :fill="s.color" fill-opacity="0.15" :stroke="s.color" stroke-width="2"/>
<circle v-for="(pt, i) in s.pts" :key="i"
:cx="pt[0]" :cy="pt[1]" r="4" :fill="s.color" stroke="#0d1117" stroke-width="2"
style="cursor: pointer;"
@mouseenter="handleEnter($event, s.si, i)"
@mouseleave="tooltip = null"/>
</g>
</template>
</svg>
</div>
</div>
<div v-if="tooltip" class="tooltip-fixed"
:style="{ left: tooltip.x + 12 + 'px', top: tooltip.y - 40 + 'px' }">
<div class="tooltip-title">{{ tooltip.axis }}</div>
<div class="tooltip-sub">{{ tooltip.label }}: <strong>{{ tooltip.value }}</strong></div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 600px; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 1rem; justify-content: center; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 12px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.chart-center { display: flex; justify-content: center; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-sub { color: #8b949e; }
.tooltip-sub strong { color: #e6edf3; }
</style><script>
const AXES = ["Design", "Frontend", "Backend", "DevOps", "Testing", "Communication"];
const SERIES = [
{ label: "Alice", color: "#818cf8", data: [85, 90, 65, 70, 80, 92] },
{ label: "Bob", color: "#34d399", data: [70, 75, 88, 82, 60, 75] },
];
const SIZE = 320;
const CX = SIZE / 2;
const CY = SIZE / 2 + 10;
const R = 120;
const RINGS = 5;
const step = 360 / AXES.length;
let hidden = new Set();
let tooltip = null;
function toggleHidden(i) {
const n = new Set(hidden);
n.has(i) ? n.delete(i) : n.add(i);
hidden = n;
}
function polarXY(r, angleDeg) {
const a = (angleDeg - 90) * (Math.PI / 180);
return [CX + r * Math.cos(a), CY + r * Math.sin(a)];
}
function pts(data) {
return data.map((v, i) => polarXY((v / 100) * R, i * step));
}
function ptsStr(points) {
return points.map((p) => p.join(",")).join(" ");
}
$: rings = Array.from({ length: RINGS }, (_, r) => {
const ringPts = AXES.map((_, i) => polarXY((R / RINGS) * (r + 1), i * step));
const labelPos = polarXY((R / RINGS) * (r + 1), 0);
return {
pts: ptsStr(ringPts),
label: Math.round((100 / RINGS) * (r + 1)),
lx: labelPos[0] + 3,
ly: labelPos[1],
};
});
$: axes = AXES.map((axis, i) => {
const [ex, ey] = polarXY(R, i * step);
const [lx, ly] = polarXY(R + 22, i * step);
const anchor = lx < CX - 5 ? "end" : lx > CX + 5 ? "start" : "middle";
return { axis, ex, ey, lx, ly, anchor };
});
$: seriesData = SERIES.map((s, si) => {
const points = pts(s.data);
return { ...s, si, pts: points, ptsStr: ptsStr(points) };
});
function handleEnter(e, si, i) {
tooltip = {
axis: AXES[i],
label: SERIES[si].label,
value: SERIES[si].data[i],
x: e.clientX,
y: e.clientY,
};
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; font-family: system-ui, -apple-system, sans-serif; }
.wrap { width: 100%; max-width: 600px; }
.legend { display: flex; gap: 0.75rem; margin-bottom: 1rem; justify-content: center; }
.legend-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 12px; padding: 4px 12px; border-radius: 4px; border: 1px solid #30363d; background: none; cursor: pointer; transition: opacity 0.2s; }
.legend-btn:hover { border-color: #8b949e; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.legend-label { color: #8b949e; }
.chart-center { display: flex; justify-content: center; }
.tooltip-fixed { position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d; border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50; }
.tooltip-title { font-weight: 600; color: #e6edf3; }
.tooltip-sub { color: #8b949e; }
.tooltip-sub strong { color: #e6edf3; }
</style>
<div class="page">
<div class="wrap">
<div class="legend">
{#each SERIES as s, i}
<button class="legend-btn" style="opacity:{hidden.has(i) ? 0.3 : 1}" on:click={() => toggleHidden(i)}>
<span class="legend-dot" style="background:{s.color}"></span>
<span class="legend-label">{s.label}</span>
</button>
{/each}
</div>
<div class="chart-center">
<svg width={SIZE} height={SIZE} viewBox="0 0 {SIZE} {SIZE}">
<!-- Rings -->
{#each rings as ring}
<polygon points={ring.pts} fill="none" stroke="#21262d" stroke-width="1"/>
<text x={ring.lx} y={ring.ly} fill="#484f58" font-size="9">{ring.label}</text>
{/each}
<!-- Axes + labels -->
{#each axes as a}
<line x1={CX} y1={CY} x2={a.ex} y2={a.ey} stroke="#30363d" stroke-width="1"/>
<text x={a.lx} y={a.ly + 4} text-anchor={a.anchor} fill="#8b949e" font-size="11">{a.axis}</text>
{/each}
<!-- Series -->
{#each seriesData as s}
{#if !hidden.has(s.si)}
<polygon points={s.ptsStr} fill={s.color} fill-opacity="0.15" stroke={s.color} stroke-width="2"/>
{#each s.pts as pt, i}
<circle cx={pt[0]} cy={pt[1]} r="4" fill={s.color} stroke="#0d1117" stroke-width="2"
style="cursor: pointer;"
on:mouseenter={(e) => handleEnter(e, s.si, i)}
on:mouseleave={() => tooltip = null}/>
{/each}
{/if}
{/each}
</svg>
</div>
</div>
{#if tooltip}
<div class="tooltip-fixed" style="left:{tooltip.x + 12}px; top:{tooltip.y - 40}px;">
<div class="tooltip-title">{tooltip.axis}</div>
<div class="tooltip-sub">{tooltip.label}: <strong>{tooltip.value}</strong></div>
</div>
{/if}
</div>Features
- Multi-series — overlay multiple datasets with distinct colors
- Concentric rings — evenly spaced grid rings for easy reading
- Axis labels — auto-positioned labels at each axis tip
- Shaded polygon — filled area with semi-transparent overlay
- Hover dots — data points highlighted on hover with tooltip
How it works
- N axes are distributed evenly around a circle (360°/N per axis)
- Each data value is normalized 0–1 and projected along its axis direction
- Polygon points are connected via SVG
<polyline>and<polygon>for the fill - Concentric rings are drawn at fractional radii steps