donut-chart
Part-to-whole display with a stable center metric.
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>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/donut-chartnpx odori add @odori/donut-chartbunx odori add @odori/donut-chartSource is copied into videos/components/donut-chart/ and belongs to your repository. odori diff compares it with upstream later; odori update applies the change you accept. It has no registry dependencies.
Copy and paste the following code 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%",
},
},
],
});
Use it in 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>Nothing else to install.
The component imports only from odori, so there are no dependencies to add and no import paths to rewrite.
Timing contract
Every registry component declares how it behaves in time.odori test fails a cut that violates these.
- Family
- Data
- Aspect ratios
- 16:9 · 9:16 · 1:1
- 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 | — | — |