Zubeyr··BlockschartsDonut Chart Card
A donut breakdown with the total and trend in the centre and a legend listing each slice with its change.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/donut-chart-card.json- →Installs
rechartsfrom npm. - →Pulls in
card. - →Files land in
components/dashboard.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add rechartsUsage
import { DonutChartCard } from '@/components/dashboard/donut-chart-card'
const data = [
{ name: 'Mobile', value: 10000, change: 5 },
{ name: 'Desktop', value: 8000, change: 3 },
{ name: 'Tablet', value: 6000, change: 1 },
{ name: 'Other', value: 4000, change: 4 },
{ name: 'TV', value: 1200, change: -2 },
{ name: 'Console', value: 800 },
]
export default function Demo() {
return (
<div className="grid min-h-svh place-items-center p-8">
<DonutChartCard
title="Sessions by device type"
change={8}
data={data}
className="w-full max-w-md"
/>
</div>
)
}Source code
'use client'
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts'
import { Card, Trend } from '@/registry/dashboard/components/card/card'
export type DonutSlice = { name: string; value: number; change?: number; color?: string }
type DonutChartCardProps = {
title: React.ReactNode
data: DonutSlice[]
change?: number
formatOptions?: Intl.NumberFormatOptions
maxLegendItems?: number
action?: React.ReactNode
className?: string
}
const palette = [
'var(--chart-1)',
'var(--chart-3)',
'var(--chart-4)',
'var(--chart-5)',
'var(--chart-2)',
]
export function DonutChartCard({
title,
data,
change,
formatOptions,
maxLegendItems = 4,
action,
className,
}: DonutChartCardProps) {
const compact = new Intl.NumberFormat('en', { ...formatOptions, notation: 'compact' })
const total = data.reduce((sum, d) => sum + d.value, 0)
const slices = data.map((d, i) => ({ ...d, color: d.color ?? palette[i % palette.length] }))
const hidden = slices.length - maxLegendItems
return (
<Card title={title} action={action} className={className}>
<div className="flex items-center gap-6">
<div className="relative size-40 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={slices}
dataKey="value"
nameKey="name"
innerRadius="68%"
outerRadius="100%"
paddingAngle={2}
cornerRadius={3}
stroke="none"
isAnimationActive={false}
>
{slices.map((s) => (
<Cell key={s.name} fill={s.color} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold tabular-nums">{compact.format(total)}</span>
{change !== undefined && <Trend value={change} />}
</div>
</div>
<ul className="flex min-w-0 flex-1 flex-col gap-2 text-xs">
{slices.slice(0, maxLegendItems).map((s) => (
<li key={s.name} className="flex items-center gap-2">
<span className="size-2.5 shrink-0 rounded-[3px]" style={{ background: s.color }} />
<span className="truncate">{s.name}</span>
<span className="ml-auto font-semibold tabular-nums">{compact.format(s.value)}</span>
{s.change !== undefined && <Trend value={s.change} className="w-10 justify-end" />}
</li>
))}
{hidden > 0 && <li className="pl-4.5 text-muted-foreground">+{hidden} more</li>}
</ul>
</div>
</Card>
)
}