Componentcharts5.1 KB gzipZero dependencies
Donut Chart
Interactive SVG donut and pie chart with segment hover highlight, tooltips, center total readout, and custom color legend.
Preview
Live Preview
SECTOR ADMISSIONS// 2,200 CAPACITY MATRIX
№ DNT-401TOTAL SEATS2,2005 SECTORS
TOTAL DISTRIBUTION: 2,200 UNITSHOVER SEGMENT TO INSPECT
Installation
Tokens guide →Add to your project via CLI (zero dependencies, code you own):
npx bigbullui add donut-chartOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { DonutChart } from "@/components/ui/donut-chart";
<DonutChart data={[{ label: "VIP", value: 40 }, { label: "General", value: 60 }]} />Usage guidance
Use Donut Chart when a ticket stub surface needs Interactive SVG donut and pie chart with segment hover highlight, tooltips, center total readout, and custom color legend. Keep props minimal and prefer composition with sibling stubs.
Avoid Donut Chart for long form flows or dense data grids where a dedicated layout block fits better.
Source code
donut-chart.tsxZero dependencies (only React + utils). Copy and paste directly into your project:
"use client";
import * as React from "react";
import { cn } from "./lib/utils";
export interface DonutChartDataItem {
label: string;
value: number;
color?: string;
tone?: "accent" | "primary" | "secondary" | "muted" | "warning" | "success" | "info";
description?: string;
}
export interface DonutChartProps extends React.HTMLAttributes<HTMLDivElement> {
data: DonutChartDataItem[];
size?: number;
innerRadiusRatio?: number;
padAngle?: number;
formatValue?: (value: number) => string;
formatPercent?: (percent: number) => string;
centerLabel?: string;
centerValue?: string | number;
centerSubtext?: string;
showCenterText?: boolean;
showTooltip?: boolean;
showLegend?: boolean;
legendPosition?: "bottom" | "right" | "top" | "none";
interactive?: boolean;
activeIndex?: number | null;
onActiveChange?: (index: number | null, item: DonutChartDataItem | null) => void;
variant?: "donut" | "pie";
frameVariant?: "ticket" | "card" | "bare";
ticketSerial?: string;
title?: string;
subtitle?: string;
className?: string;
}
const DEFAULT_COLORS: string[] = [
"var(--color-accent, #bc3a28)",
"var(--color-foreground, #17130c)",
"#d97706", // warm amber
"#2563eb", // admission blue
"#059669", // emerald gate
"#7c3aed", // violet stamp
"#db2777", // pink stub
"#4b5563", // charcoal gray
];
const TONE_COLORS: Record<string, string> = {
accent: "var(--color-accent, #bc3a28)",
primary: "var(--color-foreground, #17130c)",
secondary: "var(--color-muted-foreground, #6f6350)",
muted: "var(--color-muted-foreground, #6f6350)",
warning: "var(--color-warning)",
success: "var(--color-success)",
info: "var(--color-info)",
};
interface SliceArc {
item: DonutChartDataItem;
index: number;
value: number;
percent: number;
startAngle: number;
endAngle: number;
midAngle: number;
color: string;
pathData: string;
centroidX: number;
centroidY: number;
}
function polarToCartesian(cx: number, cy: number, radius: number, angleInDegrees: number) {
const radians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: cx + radius * Math.cos(radians),
y: cy + radius * Math.sin(radians),
};
}
function buildArcPath(
cx: number,
cy: number,
innerR: number,
outerR: number,
startDeg: number,
endDeg: number
): string {
const span = endDeg - startDeg;
if (span <= 0) return "";
// For 360 or near-360 deg arc, split into two 180 deg halves to prevent SVG arc degenerate loops
if (span >= 359.99) {
const half = startDeg + span / 2;
return `${buildArcPath(cx, cy, innerR, outerR, startDeg, half)} ${buildArcPath(cx, cy, innerR, outerR, half, endDeg)}`;
}
const startRad = ((startDeg - 90) * Math.PI) / 180.0;
const endRad = ((endDeg - 90) * Math.PI) / 180.0;
const largeArcFlag = span > 180 ? 1 : 0;
const x1 = cx + outerR * Math.cos(startRad);
const y1 = cy + outerR * Math.sin(startRad);
const x2 = cx + outerR * Math.cos(endRad);
const y2 = cy + outerR * Math.sin(endRad);
if (innerR <= 0) {
return `M ${cx} ${cy} L ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 ${largeArcFlag} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} Z`;
}
const x3 = cx + innerR * Math.cos(endRad);
const y3 = cy + innerR * Math.sin(endRad);
const x4 = cx + innerR * Math.cos(startRad);
const y4 = cy + innerR * Math.sin(startRad);
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 ${largeArcFlag} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} L ${x3.toFixed(3)} ${y3.toFixed(3)} A ${innerR} ${innerR} 0 ${largeArcFlag} 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`;
}
export function DonutChart({
data,
size = 220,
innerRadiusRatio = 0.62,
padAngle = 2,
formatValue = (val) => val.toLocaleString(),
formatPercent = (pct) => `${pct.toFixed(1)}%`,
centerLabel = "TOTAL",
centerValue,
centerSubtext,
showCenterText = true,
showTooltip = true,
showLegend = true,
legendPosition = "bottom",
interactive = true,
activeIndex: controlledActiveIndex,
onActiveChange,
variant = "donut",
frameVariant = "ticket",
ticketSerial = "№ DNT-804",
title = "ADMISSION BREAKDOWN",
subtitle = "STUB SECTOR ALLOCATION",
className,
...props
}: DonutChartProps) {
const [internalHoveredIndex, setInternalHoveredIndex] = React.useState<number | null>(null);
const [tooltipPos, setTooltipPos] = React.useState<{ x: number; y: number } | null>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const isControlled = controlledActiveIndex !== undefined;
const activeIdx = isControlled ? controlledActiveIndex : internalHoveredIndex;
const total = React.useMemo(() => {
return data.reduce((acc, curr) => acc + Math.max(0, curr.value), 0);
}, [data]);
const padding = 14;
const outerRadius = (size / 2) - padding;
const innerRadius = variant === "pie" ? 0 : outerRadius * innerRadiusRatio;
const centerCoord = size / 2;
const slices = React.useMemo<SliceArc[]>(() => {
if (total <= 0) return [];
let currentAngle = 0;
const count = data.length;
return data.map((item, idx) => {
const val = Math.max(0, item.value);
const percent = (val / total) * 100;
const rawSpan = (val / total) * 360;
// Adjust padAngle for tiny slices or single item
const safePadAngle = count > 1 ? Math.min(padAngle, rawSpan * 0.3) : 0;
const sliceSpan = Math.max(0, rawSpan - safePadAngle);
const startAngle = currentAngle + safePadAngle / 2;
const endAngle = startAngle + sliceSpan;
const midAngle = startAngle + sliceSpan / 2;
currentAngle += rawSpan;
const color =
item.color ||
(item.tone ? TONE_COLORS[item.tone] : null) ||
DEFAULT_COLORS[idx % DEFAULT_COLORS.length];
const pathData = buildArcPath(
centerCoord,
centerCoord,
innerRadius,
outerRadius,
startAngle,
endAngle
);
const midRad = ((midAngle - 90) * Math.PI) / 180;
const centroidR = innerRadius + (outerRadius - innerRadius) / 2;
const centroidX = centerCoord + centroidR * Math.cos(midRad);
const centroidY = centerCoord + centroidR * Math.sin(midRad);
return {
item,
index: idx,
value: val,
percent,
startAngle,
endAngle,
midAngle,
color,
pathData,
centroidX,
centroidY,
};
});
}, [data, total, padAngle, centerCoord, innerRadius, outerRadius]);
const activeSlice = activeIdx !== null && activeIdx !== undefined && activeIdx >= 0 && activeIdx < slices.length
? slices[activeIdx]
: null;
const handleSliceHover = (index: number, e: React.MouseEvent<SVGPathElement>) => {
if (!interactive) return;
setInternalHoveredIndex(index);
onActiveChange?.(index, data[index]);
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setTooltipPos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!interactive || activeIdx === null || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setTooltipPos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleSliceLeave = () => {
if (!interactive) return;
setInternalHoveredIndex(null);
setTooltipPos(null);
onActiveChange?.(null, null);
};
const chartElement = (
<div
ref={containerRef}
className="relative flex items-center justify-center select-none"
style={{ width: size, height: size }}
>
{/* Zero State / Empty Ring */}
{slices.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-full border-2 border-dashed border-border/80 w-44 h-44 text-center p-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
NO ADMISSION DATA
</span>
<span className="mt-1 font-mono text-xs text-muted-foreground/60">
0 TICKETS
</span>
</div>
) : (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="overflow-visible"
role="img"
aria-label="Donut Chart"
onMouseMove={handleMouseMove}
onMouseLeave={handleSliceLeave}
>
{/* Subtle Outer Guideline Ring */}
<circle
cx={centerCoord}
cy={centerCoord}
r={outerRadius + 2}
fill="none"
stroke="currentColor"
strokeOpacity="0.1"
strokeDasharray="3 3"
/>
{/* Slices */}
<g>
{slices.map((slice) => {
const isHovered = activeIdx === slice.index;
const hasActiveSlice = activeIdx !== null && activeIdx !== undefined;
const opacity = hasActiveSlice ? (isHovered ? 1 : 0.45) : 1;
// Outward translation vector for active slice
const midRad = ((slice.midAngle - 90) * Math.PI) / 180;
const hoverOffset = isHovered ? 5 : 0;
const dx = Math.cos(midRad) * hoverOffset;
const dy = Math.sin(midRad) * hoverOffset;
return (
<path
key={slice.index}
d={slice.pathData}
fill={slice.color}
stroke="var(--color-card, #fffdf5)"
strokeWidth={1.5}
strokeLinejoin="round"
className={cn(
"transition-all duration-200",
interactive && "cursor-pointer focus-visible:outline-none"
)}
style={{
transform: `translate(${dx.toFixed(2)}px, ${dy.toFixed(2)}px)`,
opacity,
filter: isHovered ? "drop-shadow(0 4px 6px rgba(0,0,0,0.18))" : "none",
}}
onMouseEnter={(e) => handleSliceHover(slice.index, e)}
onFocus={() => {
if (interactive) {
setInternalHoveredIndex(slice.index);
onActiveChange?.(slice.index, slice.item);
}
}}
onBlur={handleSliceLeave}
tabIndex={interactive ? 0 : -1}
role="graphics-symbol"
aria-label={`${slice.item.label}: ${formatValue(slice.value)} (${formatPercent(slice.percent)})`}
/>
);
})}
</g>
{/* Optional Inner Guide Ring for Donut */}
{variant === "donut" && innerRadius > 0 && (
<circle
cx={centerCoord}
cy={centerCoord}
r={Math.max(0, innerRadius - 2)}
fill="none"
stroke="currentColor"
strokeOpacity="0.12"
strokeDasharray="2 2"
/>
)}
</svg>
)}
{/* Center Readout Overlay for Donut */}
{variant === "donut" && showCenterText && slices.length > 0 && (
<div
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none text-center px-2"
style={{
maxWidth: `${innerRadius * 1.8}px`,
maxHeight: `${innerRadius * 1.8}px`,
margin: "auto",
}}
>
{activeSlice ? (
<div className="animate-fade-in flex flex-col items-center">
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground font-bold truncate max-w-[110px]">
{activeSlice.item.label}
</span>
<span className="font-mono text-xl sm:text-2xl font-black tracking-tight text-foreground leading-tight mt-0.5">
{formatValue(activeSlice.value)}
</span>
<span className="inline-block mt-0.5 font-mono text-[10px] font-bold px-1.5 py-0.2 rounded-xs border border-dashed border-border bg-secondary text-accent">
{formatPercent(activeSlice.percent)}
</span>
</div>
) : (
<div className="flex flex-col items-center">
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground font-bold">
{centerLabel}
</span>
<span className="font-mono text-xl sm:text-2xl font-black tracking-tight text-foreground leading-tight mt-0.5">
{centerValue !== undefined ? centerValue : formatValue(total)}
</span>
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-wider mt-0.5 truncate max-w-[110px]">
{centerSubtext || `${data.length} SECTORS`}
</span>
</div>
)}
</div>
)}
{/* Floating Interactive Tooltip */}
{showTooltip && activeSlice && tooltipPos && (
<div
className="pointer-events-none absolute z-30 transition-transform duration-75 ease-out"
style={{
left: `${tooltipPos.x + 12}px`,
top: `${tooltipPos.y - 34}px`,
}}
>
<div className="flex items-center gap-2 rounded-xs border-2 border-dashed border-border bg-card/95 px-2.5 py-1.5 shadow-xl backdrop-blur-xs">
<span
className="size-2.5 rounded-xs shrink-0 border border-foreground/20"
style={{ backgroundColor: activeSlice.color }}
/>
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span className="font-mono text-[10px] uppercase font-bold tracking-wider text-foreground">
{activeSlice.item.label}
</span>
<span className="font-mono text-[10px] font-bold text-accent">
{formatPercent(activeSlice.percent)}
</span>
</div>
<span className="font-mono text-[9px] text-muted-foreground">
{formatValue(activeSlice.value)} TICKETS
</span>
</div>
</div>
</div>
)}
</div>
);
// Legend Element
const legendElement = showLegend && data.length > 0 && (
<div
className={cn(
"grid gap-2 select-none",
legendPosition === "right"
? "grid-cols-1 justify-center py-2"
: "grid-cols-2 sm:grid-cols-3 pt-2"
)}
>
{slices.map((slice) => {
const isHovered = activeIdx === slice.index;
return (
<button
key={slice.index}
type="button"
className={cn(
"group flex items-center justify-between gap-2 px-2 py-1 rounded-xs border border-transparent text-start transition-all duration-150 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer",
isHovered
? "border-dashed border-foreground/40 bg-secondary shadow-xs scale-[1.02]"
: "hover:border-dashed hover:border-border hover:bg-secondary/40"
)}
onMouseEnter={() => {
if (interactive) {
setInternalHoveredIndex(slice.index);
onActiveChange?.(slice.index, slice.item);
}
}}
onMouseLeave={handleSliceLeave}
onClick={() => {
if (interactive) {
const nextIdx = activeIdx === slice.index ? null : slice.index;
setInternalHoveredIndex(nextIdx);
onActiveChange?.(nextIdx, nextIdx !== null ? data[nextIdx] : null);
}
}}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="size-2.5 rounded-xs shrink-0 border border-foreground/20"
style={{ backgroundColor: slice.color }}
/>
<span className="font-mono text-[11px] font-bold uppercase tracking-tight text-foreground truncate">
{slice.item.label}
</span>
</div>
<div className="flex items-center gap-1 text-right shrink-0">
<span className="font-mono text-[11px] text-muted-foreground">
{formatValue(slice.value)}
</span>
<span className="font-mono text-[10px] text-accent font-bold">
({formatPercent(slice.percent)})
</span>
</div>
</button>
);
})}
</div>
);
if (frameVariant === "bare") {
return (
<div
className={cn(
"flex flex-col items-center justify-center gap-4",
legendPosition === "right" && "sm:flex-row sm:items-center",
className
)}
{...props}
>
{chartElement}
{legendElement}
</div>
);
}
// Full Ticket Stub Framing
return (
<div
className={cn(
"relative rounded-lg border-2 border-foreground bg-card text-card-foreground shadow-sm select-none overflow-hidden",
className
)}
{...props}
>
{/* Ticket Stub Header Banner */}
<div className="flex items-center justify-between border-b-2 border-dashed border-border bg-secondary/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-accent animate-pulse" />
<span className="font-mono text-[10px] font-bold uppercase tracking-widest text-foreground">
{title}
</span>
{subtitle && (
<span className="hidden sm:inline font-mono text-[10px] text-muted-foreground uppercase">
{"// "}{subtitle}
</span>
)}
</div>
<span className="rounded-xs border border-dashed border-foreground/30 bg-background px-2 py-0.5 font-mono text-[9px] font-bold tracking-widest text-muted-foreground">
{ticketSerial}
</span>
</div>
{/* Notches on Outer Perforation Boundary */}
<div className="relative">
<div className="pointer-events-none absolute -left-2 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground z-10" />
<div className="pointer-events-none absolute -right-2 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground z-10" />
{/* Content Area */}
<div
className={cn(
"p-5 flex flex-col items-center justify-center gap-4",
legendPosition === "right" && "sm:flex-row sm:items-center sm:gap-6"
)}
>
{legendPosition === "top" && legendElement}
{chartElement}
{legendPosition !== "top" && legendElement}
</div>
</div>
{/* Ticket Stub Perforated Footer */}
<div className="flex items-center justify-between border-t-2 border-dashed border-border px-4 py-2 bg-secondary/20">
<span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
TOTAL DISTRIBUTION: {formatValue(total)} UNITS
</span>
<span className="font-mono text-[9px] uppercase font-bold text-accent">
{activeSlice ? `HIGHLIGHT: ${activeSlice.item.label}` : "HOVER SEGMENT TO INSPECT"}
</span>
</div>
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| data | DonutChartDataItem[] | |
| size? | number | |
| innerRadiusRatio? | number | |
| padAngle? | number | |
| formatValue? | (value: number) => string | |
| formatPercent? | (percent: number) => string | |
| centerLabel? | string | |
| centerValue? | string | number | |
| centerSubtext? | string | |
| showCenterText? | boolean | |
| showTooltip? | boolean | |
| showLegend? | boolean | |
| legendPosition? | "bottom" | "right" | "top" | "none" | |
| interactive? | boolean | |
| activeIndex? | number | null | |
| onActiveChange? | (index: number | null, item: DonutChartDataItem | null) => void | |
| variant? | "donut" | "pie" | |
| frameVariant? | "ticket" | "card" | "bare" | |
| ticketSerial? | string | |
| title? | string | |
| subtitle? | string | |
| className? | string |