progress-ring
Circular completion display with restrained motion.
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>The same runtime that renders the export. Scrub to any frame, switch format, and edit the fixture props.
Installation
pnpm odori add @odori/progress-ringnpx odori add @odori/progress-ringbunx odori add @odori/progress-ringSource is copied into videos/components/progress-ring/ 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/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"}},
],
});
Use it in 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>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
- 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 | — |