- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Project activity
453
+7.4%Explore72
Plan88
Build64
Review91
Share58
Learn80
Project activity
453
+7.4%Project activity
453
+7.4%New 453
Returning 374
Team profile
Usage
Provide an array of labeled data points and series keys. Choose filled, dots, lines, or score with the variant prop. Set tiles to show values beneath the chart. Pass ranges and onRangeChange to enable the period menu; use popupClassName when the menu needs a scoped theme.
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/radar-chart-card-01
Source
"use client";
import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { useCountUp } from "@/components/hooks/use-count-up";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";
export interface RadarPoint {
label: string;
[key: string]: string | number;
}
export interface RadarSeries {
key: string;
label: string;
color?: string;
}
export interface RadarRange {
id: string;
label: string;
data: RadarPoint[];
headline?: number;
delta?: number;
}
const value = (point: RadarPoint, key: string) =>
typeof point[key] === "number" ? Math.max(0, point[key] as number) : 0;
const DEFAULT_SERIES: RadarSeries[] = [{ key: "value", label: "Total" }];
const COLORS = [
"var(--revenue-chart-active, var(--chart-1))",
"var(--primary)",
"var(--chart-3)",
];
const coord = (count: number, index: number, radius: number) => {
const angle = (index * 2 * Math.PI) / count - Math.PI / 2;
return [
150 + Math.cos(angle) * radius,
150 + Math.sin(angle) * radius,
] as const;
};
const easeOut = (t: number) => 1 - (1 - t) ** 3;
// Tweens every vertex radius toward its target when the data changes
// (period switch), over 450ms. The first render and reduced motion show the
// target as is.
function useTween(target: number[], duration = 450) {
const reduced = useReducedMotion();
const key = target.join(",");
const values = React.useMemo(
() => (key ? key.split(",").map(Number) : []),
[key]
);
const [display, setDisplay] = React.useState(values);
const displayRef = React.useRef(values);
React.useEffect(() => {
const from = displayRef.current;
const still = from.length !== values.length || reduced;
if (still || from.every((v, i) => v === values[i])) {
displayRef.current = values;
setDisplay(values);
return;
}
const start = performance.now();
let frame = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const e = easeOut(t);
const next = values.map((v, i) => from[i] + (v - from[i]) * e);
displayRef.current = next;
setDisplay(next);
if (t < 1) {
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [values, duration, reduced]);
return display.length === values.length ? display : values;
}
/**
* The data polygons. The period-switch tween re-renders this component every
* frame, so it is kept apart from the card: only these shapes update, not the
* header, menu and tiles around them.
*/
const RadarShapes = React.memo(function RadarShapes({
points,
series,
ceiling,
variant,
active,
}: {
points: RadarPoint[];
series: RadarSeries[];
ceiling: number;
variant: "filled" | "dots" | "lines" | "score";
active: number | null;
}) {
const count = points.length;
const radii = useTween(
series.flatMap((item) =>
points.map((point) => Math.min(1, value(point, item.key) / ceiling))
)
);
return (
<g className="radar-chart-card-01-grow origin-center [transform-box:fill-box]">
{/* Invisible full-size ring so the grow starts at the chart centre. */}
<circle cx="150" cy="150" r="82" fill="none" />
{series.map((item, s) => {
const color = item.color ?? COLORS[s % COLORS.length];
const positions = points.map((_, i) =>
coord(count, i, 82 * (radii[s * count + i] ?? 0))
);
const peak = active === null ? undefined : positions[active];
return (
<g key={item.key}>
<polygon
points={positions.map((p) => p.join(",")).join(" ")}
fill={variant === "filled" ? color : "none"}
fillOpacity={0.18}
stroke={color}
strokeWidth="2"
strokeLinejoin="round"
className="[vector-effect:non-scaling-stroke]"
/>
{variant === "dots" &&
positions.map(([x, y], i) => (
<circle
key={points[i].label}
cx={x}
cy={y}
r="3"
fill={color}
/>
))}
{peak && (
<g key={active}>
<circle
cx={peak[0]}
cy={peak[1]}
r="4"
fill={color}
className="radar-chart-card-01-pulse origin-center [transform-box:fill-box]"
/>
<circle
cx={peak[0]}
cy={peak[1]}
r="4"
fill={color}
strokeWidth="2"
className="stroke-card"
/>
</g>
)}
</g>
);
})}
</g>
);
});
export function RadarChartCard({
title = "Activity",
data,
series = DEFAULT_SERIES,
variant = "filled",
tiles = false,
max,
headline,
delta,
range,
ranges,
defaultRange,
onRangeChange,
alertBelow = 30,
popupClassName,
className,
}: {
title?: string;
data: RadarPoint[];
series?: RadarSeries[];
variant?: "filled" | "dots" | "lines" | "score";
tiles?: boolean;
max?: number;
headline?: number;
delta?: number;
range?: string;
ranges?: RadarRange[];
defaultRange?: string;
onRangeChange?: (id: string) => void;
alertBelow?: number;
popupClassName?: string;
className?: string;
}) {
const [selected, setSelected] = React.useState(
defaultRange ?? ranges?.[0]?.id
);
const [active, setActive] = React.useState<number | null>(null);
const current = ranges?.find((item) => item.id === selected);
const points = current?.data ?? data;
const [primary] = series;
const ceiling = Math.max(
1,
max ?? 0,
...points.flatMap((point) => series.map((item) => value(point, item.key)))
);
const total =
current?.headline ??
headline ??
points.reduce(
(sum, point) => sum + (primary ? value(point, primary.key) : 0),
0
);
const change = current?.delta ?? delta;
const shown =
active === null
? total
: primary && points[active]
? value(points[active], primary.key)
: total;
const shownRef = useCountUp<HTMLParagraphElement>(shown);
const count = points.length;
const polygon = (radius: number) =>
points.map((_, i) => coord(count, i, radius).join(",")).join(" ");
const pickAxis = (event: React.MouseEvent<HTMLDivElement>) => {
if (count < 3) {
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 300 - 150;
const y = 30 + ((event.clientY - rect.top) / rect.height) * 240 - 150;
const turn = (Math.atan2(y, x) + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI);
setActive(Math.round(turn / ((2 * Math.PI) / count)) % count);
};
const score =
count && primary
? Math.round(
points.reduce((sum, point) => sum + value(point, primary.key), 0) /
count
)
: 0;
return (
<Reveal
variant="card"
render={
<Card
role="region"
aria-label={title}
variant="muted"
className={cn("w-full min-w-0 overflow-visible", className)}
/>
}
>
<CardHeader className="@container flex min-w-0 items-start justify-between">
<div className="min-w-0">
<p className="text-sm leading-5 font-medium text-muted-foreground">
{active === null ? title : points[active]?.label}
</p>
{variant !== "score" && (
<div className="flex items-center gap-2">
<p
ref={shownRef}
className="text-2xl leading-7.5 font-medium tabular-nums"
>
{shown.toLocaleString("en-US")}
</p>
{change !== undefined && active === null && (
<Badge variant="label" tone={change >= 0 ? "lime" : "rose"}>
{change > 0 ? "+" : ""}
{(change * 100).toFixed(1)}%
</Badge>
)}
</div>
)}
</div>
{ranges?.length ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
aria-label={`Range: ${current?.label ?? range ?? "Select range"}`}
/>
}
>
<RiCalendarLine data-icon="inline-start" />
<span className="hidden @[220px]:inline">
{current?.label ?? range}
</span>
<RiArrowDownSLine
data-icon="inline-end"
className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={popupClassName}>
<DropdownMenuGroup>
{ranges.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
setSelected(item.id);
setActive(null);
onRangeChange?.(item.id);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : range ? (
<Badge variant="outline">
<RiCalendarLine data-icon="inline-start" />
{range}
</Badge>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div
className="relative mx-auto w-full max-w-[300px]"
onMouseMove={pickAxis}
onMouseLeave={() => setActive(null)}
>
<svg
viewBox="0 30 300 240"
className="w-full overflow-visible"
role="img"
aria-label={`${title} radar chart`}
>
{count >= 3 && (
<g fill="none" stroke="var(--border)" strokeWidth="1">
{[0.25, 0.5, 0.75, 1].map((step) => (
<polygon key={step} points={polygon(82 * step)} />
))}
{points.map((point, i) => {
const [x, y] = coord(count, i, 82);
return (
<line key={point.label} x1="150" y1="150" x2={x} y2={y} />
);
})}
</g>
)}
{count >= 3 && (
<RadarShapes
points={points}
series={series}
ceiling={ceiling}
variant={variant}
active={active}
/>
)}
{points.map((point, i) => {
const [x, y] = coord(count, i, 108);
const v = primary ? value(point, primary.key) : 0;
return (
<g
key={point.label}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
tabIndex={0}
role="img"
aria-label={`${point.label}: ${v}`}
className="group outline-none"
onMouseDown={(event) => event.preventDefault()}
>
<circle
cx={x}
cy={y}
r="24"
fill="transparent"
strokeWidth="1.5"
className="group-focus-visible:stroke-ring"
/>
<text
x={x}
y={y - (variant === "score" ? 4 : 0)}
textAnchor="middle"
dominantBaseline="middle"
className={cn(
"text-xs transition-colors duration-100 ease-out motion-reduce:transition-none",
active === i
? "fill-foreground font-medium"
: "fill-muted-foreground"
)}
>
{point.label}
</text>
{variant === "score" && (
<text
x={x}
y={y + 12}
textAnchor="middle"
className={cn(
"text-sm font-semibold",
v < alertBelow ? "fill-destructive" : "fill-foreground"
)}
>
{v}
</text>
)}
</g>
);
})}
{variant === "score" && (
<g>
<circle
cx="150"
cy="150"
r="27"
fill="var(--surface-subtle, var(--muted))"
stroke="var(--border)"
/>
<text
x="150"
y="150"
textAnchor="middle"
className="fill-foreground text-xl font-semibold"
>
{score}
</text>
<text
x="150"
y="164"
textAnchor="middle"
className="fill-muted-foreground"
fontSize={9}
>
Average
</text>
</g>
)}
</svg>
</div>
{variant === "lines" && (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{series.map((item, i) => (
<div
key={item.key}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<span
className="size-2.5 rounded-full bg-(--dot)"
style={
{
"--dot": item.color ?? COLORS[i % COLORS.length],
} as React.CSSProperties
}
/>
{item.label}{" "}
<span className="font-semibold text-foreground tabular-nums">
{points
.reduce((sum, point) => sum + value(point, item.key), 0)
.toLocaleString()}
</span>
</div>
))}
</div>
)}
{tiles && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{points.map((point, i) => (
<div key={point.label} className="min-w-0">
<div
className={cn(
"h-full rounded-[12px] transition-opacity duration-150 ease-out *:outline-none has-focus-visible:ring-2 has-focus-visible:ring-ring motion-reduce:transition-none",
active !== null && active !== i && "opacity-40"
)}
>
<Card
variant="flat"
size="sm"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setActive(i)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
className="h-full"
>
<CardContent className="flex min-w-0 flex-col items-start gap-0">
<span className="block w-full text-xs break-words text-muted-foreground">
{point.label}
</span>
<span className="block text-sm font-semibold tabular-nums">
{primary
? value(point, primary.key).toLocaleString()
: 0}
</span>
</CardContent>
</Card>
</div>
</div>
))}
</div>
)}
</CardContent>
</Reveal>
);
} On this page