- 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
Sessions
1,002+8.4%
Search108
Direct164
Email205
Social238
Referral287
Sessions
1,002+8.4%
Sessions
1,002+8.4%
Tasks completed
720+8.4%
Tasks completed
720+8.4%
Tasks by team
1,150+8.4%
Usage
Provide a title and an array of { label, value, color? } items. Set max to control the scale, and use variant to choose rings, labels, grid, gauge, solid, or stacked. The tiles option adds an interactive breakdown. Supply ranges for the period menu and popupClassName when the popup needs a scoped theme.
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 { 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 RadialChartItem {
label: string;
value: number;
color?: string;
}
export interface RadialChartRange {
label: string;
data: RadialChartItem[];
max?: number;
delta?: string;
}
export type RadialChartVariant =
| "rings"
| "labels"
| "grid"
| "gauge"
| "solid"
| "stacked";
const colors = [
"var(--radial-chart-1, var(--chart-1))",
"var(--radial-chart-2, var(--chart-2))",
"var(--radial-chart-3, var(--chart-3))",
"var(--radial-chart-4, var(--chart-4))",
"var(--radial-chart-5, var(--chart-5))",
];
const clamp = (n: number) => Math.max(0, Math.min(1, n));
const percent = (value: number) => `${value}%`;
export function RadialChartCard({
title,
data,
max,
delta,
ranges,
format = (value) => value.toLocaleString(),
variant = "rings",
tiles = false,
className,
popupClassName,
}: {
title: string;
data: RadialChartItem[];
max?: number;
delta?: string;
ranges?: RadialChartRange[];
/** Formats the headline and per-item values, for example as currency. */
format?: (value: number) => string;
variant?: RadialChartVariant;
tiles?: boolean;
className?: string;
popupClassName?: string;
}) {
const [rangeIndex, setRangeIndex] = React.useState(0);
const [active, setActive] = React.useState<number | null>(null);
const selected = ranges?.[rangeIndex];
const items = selected?.data ?? data;
const total = items.reduce((sum, item) => sum + Math.max(0, item.value), 0);
const limit = Math.max(
selected?.max ??
max ??
Math.max(...items.map((item) => item.value), 1) * 1.1,
1
);
const change = selected?.delta ?? delta;
const gauge = variant === "gauge" || variant === "solid";
const percentage = Math.round(clamp((items[0]?.value ?? 0) / limit) * 100);
const shown = active === null ? total : (items[active]?.value ?? total);
const shownRef = useCountUp<HTMLSpanElement>(shown, { format });
const center =
variant === "stacked"
? total
? Math.round(
(Math.max(0, items[active ?? 0]?.value ?? 0) / total) * 100
)
: 0
: percentage;
const centerRef = useCountUp<SVGTextElement>(center, {
format: percent,
fade: false,
});
let stackedOffset = 0;
// The stacked gauge sweeps 190 degrees of a radius-100 circle, open below.
const gaugeArc = (190 / 360) * 2 * Math.PI * 88;
return (
<Reveal
variant="card"
render={
<Card
variant="muted"
className={cn(
"w-full max-w-[400px]",
variant === "grid" && "self-start",
className
)}
aria-label={title}
/>
}
>
<CardHeader className="flex min-w-0 items-start justify-between">
<div className="min-w-0">
<p className="truncate text-sm leading-5 font-medium text-muted-foreground">
{active === null ? title : items[active]?.label}
</p>
<div className="flex items-center gap-2">
<span
ref={shownRef}
className="text-2xl leading-8.5 font-medium tabular-nums"
>
{format(shown)}
</span>
{change && (
<Badge variant="caption" tone="lime">
{change}
</Badge>
)}
</div>
</div>
{ranges && ranges.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="sm" />}
>
<RiCalendarLine data-icon="inline-start" />
{selected?.label}
<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((range, index) => (
<DropdownMenuItem
key={range.label}
onClick={() => {
setRangeIndex(index);
setActive(null);
}}
>
{range.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</CardHeader>
<CardContent className="-mt-4 gap-0">
<div
className={cn(
"relative mx-auto flex w-full max-w-[240px] items-center justify-center",
variant === "stacked"
? "h-[210px] max-w-[260px] items-start"
: variant === "grid"
? "h-[200px]"
: "h-[220px]"
)}
>
<svg
key={rangeIndex}
viewBox="0 0 200 200"
className={cn(
"size-[200px] max-w-full overflow-visible",
variant === "stacked" && "size-[250px]"
)}
role="img"
aria-label={`${title}: ${items.map((item) => `${item.label} ${item.value}`).join(", ")}`}
>
{variant === "stacked" ? (
<g transform="rotate(175 100 100)">
<circle
cx="100"
cy="100"
r="88"
fill="none"
className="stroke-surface-strong"
strokeWidth="18"
strokeLinecap="round"
strokeDasharray={`${gaugeArc} ${2 * Math.PI * 88}`}
pointerEvents="none"
/>
{items.map((item, i) => {
const fraction = total ? Math.max(0, item.value) / total : 0;
const length = fraction * gaugeArc;
const offset = stackedOffset;
stackedOffset += length;
return (
<circle
key={item.label}
cx="100"
cy="100"
r="88"
fill="none"
stroke={item.color ?? colors[i % colors.length]}
strokeWidth="18"
strokeLinecap="round"
strokeDasharray={`${Math.max(0, length - 12)} ${2 * Math.PI * 88}`}
strokeDashoffset={-offset - 6}
className="radial-chart-card-01-draw transition-opacity duration-150 motion-reduce:transition-none"
opacity={active === null || active === i ? 1 : 0.3}
onMouseEnter={() => setActive(i)}
onMouseLeave={() => setActive(null)}
/>
);
})}
</g>
) : gauge ? (
<g transform="rotate(-90 100 100)">
<circle
cx="100"
cy="100"
r="75"
fill="none"
className="stroke-(color:--border-subtle,var(--border)) dark:stroke-(color:--border-strong,var(--border))"
strokeWidth={variant === "solid" ? 14 : 6}
/>
<circle
cx="100"
cy="100"
r="75"
fill="none"
stroke={items[0]?.color ?? colors[0]}
strokeWidth={variant === "solid" ? 14 : 6}
strokeLinecap="round"
strokeDasharray={`${clamp((items[0]?.value ?? 0) / limit) * 2 * Math.PI * 75} ${2 * Math.PI * 75}`}
className="radial-chart-card-01-draw transition-all duration-300 motion-reduce:transition-none"
/>
</g>
) : (
<g transform="rotate(-90 100 100)">
{variant === "grid" &&
[30, 45, 60, 75, 90].map((r) => (
<circle
key={r}
cx="100"
cy="100"
r={r}
fill="none"
className="stroke-(color:--border-subtle,var(--border)) dark:stroke-(color:--border-strong,var(--border))"
strokeWidth="1"
pointerEvents="none"
/>
))}
{items.map((item, i) => {
const radius =
46 + i * Math.min(12.5, 50 / Math.max(items.length - 1, 1));
const circumference = 2 * Math.PI * radius;
return (
<g key={item.label}>
{variant !== "grid" && (
<circle
cx="100"
cy="100"
r={radius}
fill="none"
className="stroke-surface-strong"
strokeWidth="7"
pointerEvents="none"
/>
)}
<circle
cx="100"
cy="100"
r={radius}
fill="none"
stroke={item.color ?? colors[i % colors.length]}
strokeWidth={variant === "grid" ? 5 : 6}
strokeLinecap="round"
strokeDasharray={`${clamp(Math.max(0, item.value) / limit) * circumference} ${circumference}`}
opacity={active === null || active === i ? 1 : 0.3}
className="cursor-pointer radial-chart-card-01-draw transition-all duration-150 motion-reduce:transition-none"
pointerEvents="none"
/>
<circle
cx="100"
cy="100"
r={radius}
fill="none"
stroke="transparent"
strokeWidth="11"
strokeLinecap="round"
strokeDasharray={`${clamp(Math.max(0, item.value) / limit) * circumference} ${circumference}`}
pointerEvents="stroke"
className="cursor-pointer"
onMouseEnter={() => setActive(i)}
onMouseLeave={() => setActive(null)}
/>
</g>
);
})}
</g>
)}
{(gauge || variant === "stacked") && (
<g textAnchor="middle" fill="currentColor">
<text
ref={centerRef}
x="100"
y={variant === "stacked" ? "106" : "101"}
fontSize={variant === "stacked" ? "32" : "26"}
fontWeight={variant === "stacked" ? "500" : "600"}
>
{percent(center)}
</text>
{(gauge || variant === "stacked") && (
<text
x="100"
y={variant === "stacked" ? "124" : "117"}
fontSize={variant === "stacked" ? "12" : "10"}
fill="var(--muted-foreground)"
>
{variant === "stacked"
? (items[active ?? 0]?.label ?? "")
: "of goal"}
</text>
)}
</g>
)}
</svg>
</div>
{(tiles || variant === "labels" || variant === "stacked") && (
<div
className={cn(
tiles
? "grid grid-cols-6 gap-1"
: "flex flex-wrap justify-center gap-x-3 gap-y-1"
)}
>
{items.map((item, i) => {
const dot = (
<span
className="size-2.5 shrink-0 rounded-full bg-(--dot)"
style={
{
"--dot": item.color ?? colors[i % colors.length],
} as React.CSSProperties
}
/>
);
const dim = active !== null && active !== i;
return tiles ? (
<div
key={item.label}
className={cn(
"col-span-2 min-w-0 transition-opacity duration-150 last:grow motion-reduce:transition-none",
items.length % 3 === 2 &&
i >= items.length - 2 &&
"col-span-3",
dim && "opacity-40"
)}
>
<Card
variant="flat"
size="sm"
interactive
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="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground">
{dot}
<span className="min-w-0 break-all">{item.label}</span>
</span>
<span className="text-xs font-medium tabular-nums">
{format(item.value)}
</span>
</CardContent>
</Card>
</div>
) : (
<span
key={item.label}
className={cn(
"transition-opacity duration-150 motion-reduce:transition-none",
dim && "opacity-40"
)}
>
<Button
type="button"
variant="ghost"
size="xs"
onMouseEnter={() => setActive(i)}
onMouseLeave={() => setActive(null)}
onFocus={() => setActive(i)}
onBlur={() => setActive(null)}
>
<span
className={cn(
"flex min-w-0 items-center gap-1 text-muted-foreground",
variant === "stacked" && "text-sm"
)}
>
{dot}
<span className="min-w-0 break-all">{item.label}</span>
<span className="font-medium text-foreground">
{format(item.value)}
</span>
</span>
</Button>
</span>
);
})}
</div>
)}
</CardContent>
</Reveal>
);
}