Donut chart
Part-to-whole display with a stable center metric.
Customize
import {Scene, Video} from "odori";
import {DonutChart} from "../components/donut-chart/donut-chart";
<Video>
<Scene id="donut-chart" duration="4s">
<DonutChart
slices={[
{
label: "encode",
value: 72
},
{
label: "paint",
value: 18
},
{
label: "audio",
value: 10
}
]}
centerLabel="72%"
title="Where render time goes"
/>
</Scene>
</Video>Installation
pnpm odori add component donut-chartnpx odori add component donut-chartbunx odori add component donut-chartEdit the source in videos/components/donut-chart/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/donut-chart/donut-chart.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type DonutSlice = {label: string; value: number};
export type DonutChartProps = {
slices: DonutSlice[];
/** Held in the middle of the ring. */
centerLabel?: string;
title?: string;
};
/**
* Part to whole with a stable center. Slices sweep in order around one ring,
* and the center metric never moves, so the eye has somewhere to rest.
*/
export const DonutChart = ({slices, centerLabel, title}: DonutChartProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const total = slices.reduce((sum, slice) => sum + slice.value, 0) || 1;
const sweep = interpolate(frame, [10, 62], [0, 1], {easing: Easing.standard});
const radius = 40;
const circumference = 2 * Math.PI * radius;
let offset = 0;
return (
<Fill style={{alignItems: "center", gap: 34 * scale, justifyContent: "center", padding: 100 * scale}}>
{title ? (
<div style={{fontSize: 54 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>
{title}
</div>
) : null}
<div style={{height: 460 * scale, position: "relative", width: 460 * scale}}>
<svg style={{height: "100%", transform: "rotate(-90deg)", width: "100%"}} viewBox="0 0 100 100">
<circle cx={50} cy={50} fill="none" r={radius} stroke={brand.colors.border} strokeWidth={12} />
{slices.map((slice, index) => {
const share = (slice.value / total) * sweep;
const dash = share * circumference;
const element = (
<circle
key={slice.label}
cx={50}
cy={50}
fill="none"
r={radius}
stroke={index === 0 ? brand.colors.accent : brand.colors.muted}
strokeDasharray={`${dash} ${circumference}`}
strokeDashoffset={-offset * circumference}
strokeWidth={12}
opacity={index === 0 ? 1 : 0.45 + index * 0.1}
/>
);
offset += slice.value / total;
return element;
})}
</svg>
{centerLabel ? (
<div
style={{
alignItems: "center",
display: "flex",
fontSize: 92 * scale,
fontWeight: 600,
inset: 0,
justifyContent: "center",
letterSpacing: "-0.05em",
position: "absolute",
}}
>
{centerLabel}
</div>
) : null}
</div>
<div style={{color: brand.colors.muted, display: "flex", fontSize: 28 * scale, gap: 28 * scale}}>
{slices.map((slice) => (
<span key={slice.label}>
{slice.label} {Math.round((slice.value / total) * 100)}%
</span>
))}
</div>
</Fill>
);
};
videos/components/donut-chart/donut-chart.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DonutChart} from "./donut-chart";
export default defineComponentPreview({
title: "Donut chart",
category: "Data",
description: "Part-to-whole display with a stable center metric.",
component: DonutChart,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Where render time goes"},
centerLabel: {type: "text", defaultValue: "72%"},
},
examples: [
{
name: "Split",
props: {
slices: [
{label: "encode", value: 72},
{label: "paint", value: 18},
{label: "audio", value: 10},
],
centerLabel: "72%",
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DonutChart} from "../components/donut-chart/donut-chart";
<Video>
<Scene id="donut-chart" duration="4s">
<DonutChart
slices={[
{
label: "encode",
value: 72
},
{
label: "paint",
value: 18
},
{
label: "audio",
value: 10
}
]}
centerLabel="72%"
title="Where render time goes"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Data
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 72 frames · 2.4s
- Entrance
- 62 frames
- Exit
- 0 frames
- Reduced motion
- slices appear without sweeping
- Requires
- sans font
- Content limits
- slices ≤ 5, labelLength ≤ 18
Props
| Prop | Type | Default | Required |
|---|---|---|---|
slices | DonutSlice[] | — | Yes |
centerLabelHeld in the middle of the ring. | string | — | — |
title | string | — | — |
Related components
All componentsArea chartFilled trend reveal for volume and cumulative change.Bar chartRanked bars with labels, values, and staged growth.Data tableAnimated sorting and row selection.HeatmapGrid intensity reveal for activity and distribution.Line chartTime-series path draw with optional focal annotation.Progress ringCircular completion display with restrained motion.