Donut Chart
Rounded ring segments that sweep in; hovering one swells it and counts its value up in the centre.
Sessions by source
Last 30 days
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/donut-chart.jsonManual installation
Install the dependencies:
bash
npm install motion-vCopy the Chart Utils and Number Ticker files into components/ui/chart-utils/ and components/ui/number-ticker/, then copy these files into components/ui/donut-chart/ (plus lib/utils.ts if you don't have it):
vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { DonutChartDatum } from './types'
import { motion, useReducedMotion } from 'motion-v'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { chartColor, useElementWidth, useTween } from '@/components/ui/chart-utils'
import { NumberTicker } from '@/components/ui/number-ticker'
const props = withDefaults(defineProps<{
data: DonutChartDatum[]
/** Largest diameter in px; shrinks to fit narrow containers. */
size?: number
/** Ring thickness in px. */
thickness?: number
/** Gap between segments in degrees. */
padAngle?: number
/** Round segment ends. */
rounded?: boolean
/** Caption under the total when nothing is hovered. */
centerLabel?: string
showLegend?: boolean
/** Seconds for the full sweep-in. */
duration?: number
/** Number format for the centre readout. */
format?: Intl.NumberFormatOptions
class?: HTMLAttributes['class']
}>(), {
size: 240,
thickness: 26,
padAngle: 2.5,
rounded: true,
centerLabel: 'Total',
showLegend: true,
duration: 1.1,
})
const EXPAND = 8
const GROW = { type: 'spring', stiffness: 420, damping: 26 } as const
const reduced = useReducedMotion()
const root = ref<HTMLElement>()
const available = useElementWidth(root, props.size)
const diameter = computed(() => Math.max(120, Math.min(props.size, available.value)))
const radius = computed(() => diameter.value / 2 - (props.thickness + EXPAND) / 2 - 1)
// Until the sweep finishes, each segment fades in exactly when its sweep starts
// (otherwise a round cap would show as a dot early).
const revealed = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
onMounted(() => (timer = setTimeout(() => (revealed.value = true), props.duration * 1000 + 100)))
onBeforeUnmount(() => clearTimeout(timer))
const values = useTween(() => props.data.map(d => Math.max(0, d.value)), { reduced })
const total = computed(() => props.data.reduce((s, d) => s + Math.max(0, d.value), 0))
const percent = new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 })
function point(angle: number) {
const c = diameter.value / 2
return `${c + radius.value * Math.cos(angle)},${c + radius.value * Math.sin(angle)}`
}
const segments = computed(() => {
const vals = values.value
const sum = vals.reduce((s, v) => s + v, 0) || 1
const r = radius.value
const pad = (props.padAngle * Math.PI) / 180
// Round caps poke out by half the stroke; pull the ends in so the gaps stay even.
const cap = props.rounded ? props.thickness / 2 / r : 0
let angle = -Math.PI / 2
let elapsed = 0
return props.data.map((d, i) => {
const share = (vals[i] ?? 0) / sum
const a0 = angle
const a1 = angle + share * Math.PI * 2
angle = a1
let s = a0 + pad / 2 + cap
let e = a1 - pad / 2 - cap
if (props.data.length === 1) {
s = a0 + 0.0001
e = a1 - 0.0001
}
if (e < s)
s = e = (a0 + a1) / 2
const large = e - s > Math.PI ? 1 : 0
const path = `M${point(s)}A${r},${r} 0 ${large} 1 ${point(e === s ? e + 0.0001 : e)}`
const delay = elapsed * props.duration
elapsed += share
return {
label: d.label,
value: d.value,
share: total.value ? Math.max(0, d.value) / total.value : 0,
color: d.color ?? chartColor(i),
path,
delay,
span: Math.max(0.08, share * props.duration),
}
})
})
const active = ref<number | null>(null)
const current = computed(() => (active.value === null ? null : segments.value[active.value] ?? null))
const summary = computed(() =>
`Donut chart, total ${total.value}: ${segments.value.map(s => `${s.label} ${percent.format(s.share)}`).join(', ')}`)
</script>
<template>
<div
ref="root"
:class="cn('flex w-full flex-col items-center gap-6 sm:flex-row sm:justify-center sm:gap-10', props.class)"
>
<div class="relative shrink-0" :style="{ width: `${diameter}px`, height: `${diameter}px` }">
<svg
:width="diameter"
:height="diameter"
:viewBox="`0 0 ${diameter} ${diameter}`"
role="img"
:aria-label="summary"
class="block overflow-visible"
@pointerleave="active = null"
@mousedown.prevent
>
<circle
:cx="diameter / 2"
:cy="diameter / 2"
:r="radius"
fill="none"
class="stroke-muted"
:stroke-width="props.thickness"
opacity="0.5"
/>
<motion.path
v-for="(s, i) in segments"
:key="i"
:d="s.path"
fill="none"
:style="{ stroke: s.color }"
:stroke-linecap="props.rounded ? 'round' : 'butt'"
class="cursor-pointer"
:initial="reduced ? false : { pathLength: 0, opacity: 0, strokeWidth: props.thickness }"
:animate="{
pathLength: 1,
opacity: active === null || active === i ? 1 : 0.35,
strokeWidth: active === i ? props.thickness + EXPAND : props.thickness,
}"
:transition="{
pathLength: { duration: s.span, delay: s.delay, ease: 'linear' },
opacity: { duration: revealed ? 0.2 : 0.05, delay: revealed ? 0 : s.delay },
strokeWidth: GROW,
}"
@pointerenter="active = i"
/>
</svg>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center" aria-hidden="true">
<span class="max-w-[60%] truncate text-xs text-muted-foreground">
{{ current ? current.label : props.centerLabel }}
</span>
<NumberTicker
:value="current ? current.value : total"
:format="props.format"
locale="en-US"
:start-on-view="false"
:stiffness="220"
:damping="28"
class="text-2xl font-semibold"
/>
<span class="h-4 text-xs tabular-nums text-muted-foreground">
{{ current ? percent.format(current.share) : '' }}
</span>
</div>
</div>
<ul v-if="props.showLegend" class="m-0 grid w-full max-w-60 list-none gap-0.5 p-0">
<li v-for="(s, i) in segments" :key="i">
<button
type="button"
class="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-[background-color,opacity] focus-visible:ring-[3px] focus-visible:ring-ring/50"
:class="[
active === i ? 'bg-muted' : '',
active !== null && active !== i ? 'opacity-50' : '',
]"
@pointerenter="active = i"
@pointerleave="active = null"
@focus="active = i"
@blur="active = null"
>
<span class="size-2.5 shrink-0 rounded-full" :style="{ background: s.color }" />
<span class="truncate">{{ s.label }}</span>
<span class="ml-auto pl-3 text-xs tabular-nums text-muted-foreground">{{ percent.format(s.share) }}</span>
</button>
</li>
</ul>
</div>
</template>ts
export interface DonutChartDatum {
label: string
/** Non-negative amount; segments are sized by their share of the total. */
value: number
/** Any CSS colour. Defaults to the `--chart-1…5` tokens in order. */
color?: string
}ts
export { default as DonutChart } from './DonutChart.vue'
export type { DonutChartDatum } from './types'Usage
ts
import { DonutChart } from '@/components/ui/donut-chart'vue
<DonutChart
:data="[
{ label: 'Chrome', value: 6120 },
{ label: 'Safari', value: 2830 },
{ label: 'Firefox', value: 940 },
]"
center-label="Visitors"
/>The segments sweep around the ring one after another, like a single stroke. Hover a segment or a legend row (or tab through the legend) to swell it and show its value and share in the centre. New data re-sizes the segments in place.
API
| Prop | Type | Default | Description |
|---|---|---|---|
data | DonutChartDatum[] | — | { label, value, color? } per segment. |
size | number | 240 | Largest diameter in px (shrinks to fit). |
thickness | number | 26 | Ring thickness in px. |
padAngle | number | 2.5 | Gap between segments, in degrees. |
rounded | boolean | true | Round segment ends. |
centerLabel | string | 'Total' | Caption above the total. |
showLegend | boolean | true | Legend with percentages. |
duration | number | 1.1 | Seconds for the full sweep (skipped with reduced motion). |
format | Intl.NumberFormatOptions | — | Number format for the centre value. |