Progress ring
Circular completion display with restrained motion.
Customize
import {Scene, Video} from "odori";
import {ProgressRing} from "../components/progress-ring/progress-ring";
<Video>
<Scene id="progress-ring" duration="3s">
<ProgressRing
value={84}
label="of frames validated"
thickness={12}
/>
</Scene>
</Video>Installation
pnpm odori add component progress-ringnpx odori add component progress-ringbunx odori add component progress-ringEdit the source in videos/components/progress-ring/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/progress-ring/progress-ring.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type ProgressRingProps = {
/** Target completion, 0 to 100. */
value: number;
label?: string;
/** Ring thickness in design pixels. */
thickness?: number;
};
/**
* Circular completion with restrained motion: one sweep, one counted number,
* no pulsing. The printed value tracks the sweep, so the ring and the text can
* never disagree.
*/
export const ProgressRing = ({value, label, thickness = 12}: ProgressRingProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const progress = interpolate(frame, [10, 64], [0, 1], {easing: Easing.standard});
const shown = Math.round(value * progress);
const radius = 42;
const circumference = 2 * Math.PI * radius;
return (
<Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center"}}>
<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={thickness / 2} />
<circle
cx={50}
cy={50}
fill="none"
r={radius}
stroke={brand.colors.accent}
strokeDasharray={`${(value / 100) * progress * circumference} ${circumference}`}
strokeLinecap="round"
strokeWidth={thickness / 2}
/>
</svg>
<div
style={{
alignItems: "center",
display: "flex",
fontSize: 104 * scale,
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
inset: 0,
justifyContent: "center",
letterSpacing: "-0.05em",
position: "absolute",
}}
>
{shown}%
</div>
</div>
{label ? <div style={{color: brand.colors.muted, fontSize: 34 * scale}}>{label}</div> : null}
</Fill>
);
};
videos/components/progress-ring/progress-ring.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ProgressRing} from "./progress-ring";
export default defineComponentPreview({
title: "Progress ring",
category: "Data",
description: "Circular completion display with restrained motion.",
component: ProgressRing,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
value: {type: "number", defaultValue: 84, min: 0, max: 100},
label: {type: "text", defaultValue: "of frames validated"},
thickness: {type: "number", defaultValue: 12, min: 4, max: 24},
},
examples: [
{name: "Coverage", props: {value: 84, label: "of frames validated"}},
{name: "Complete", props: {value: 100, label: "render complete"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ProgressRing} from "../components/progress-ring/progress-ring";
<Video>
<Scene id="progress-ring" duration="3s">
<ProgressRing
value={84}
label="of frames validated"
thickness={12}
/>
</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
- 90 frames · 3s
- Minimum duration
- 66 frames · 2.2s
- Entrance
- 64 frames
- Exit
- 0 frames
- Reduced motion
- the ring is drawn at its final value
- Requires
- sans font
- Content limits
- label ≤ 40
Props
| Prop | Type | Default | Required |
|---|---|---|---|
valueTarget completion, 0 to 100. | number | — | Yes |
label | string | — | — |
thicknessRing thickness in design pixels. | number | 12 | — |
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.Donut chartPart-to-whole display with a stable center metric.HeatmapGrid intensity reveal for activity and distribution.Line chartTime-series path draw with optional focal annotation.