rolling-number
Metrics roll without width changes or digit jitter.
import {Scene, Video} from "odori";
import {RollingNumber} from "../components/rolling-number/rolling-number";
<Video>
<Scene id="rolling-number" duration="3s">
<RollingNumber
from={0}
to={98.7}
label="frames delivered on time"
suffix="%"
decimals={1}
/>
</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/rolling-numbernpx odori add @odori/rolling-numberbunx odori add @odori/rolling-numberSource is copied into videos/components/rolling-number/ 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/rolling-number/rolling-number.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type RollingNumberProps = {
from?: number;
to: number;
label?: string;
prefix?: string;
suffix?: string;
decimals?: number;
};
/**
* A number that counts without the width jitter proportional digits cause.
* Tabular figures hold every glyph in the same advance, so the label under it
* never shifts while the value climbs.
*/
export const RollingNumber = ({from = 0, to, label, prefix = "", suffix = "", decimals = 0}: RollingNumberProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const progress = interpolate(frame, [8, 62], [0, 1], {easing: Easing.standard});
const value = (from + (to - from) * progress).toFixed(decimals);
return (
<Fill
style={{
alignItems: "flex-start",
gap: 16 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${150 * scale}px`,
}}
>
<div
style={{
fontSize: 208 * scale,
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
letterSpacing: "-0.06em",
lineHeight: 1,
opacity: interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard}),
}}
>
{prefix}
{value}
{suffix}
</div>
{label ? (
<div
style={{
color: brand.colors.muted,
fontSize: 40 * scale,
letterSpacing: "-0.015em",
opacity: interpolate(frame, [10, 30], [0, 1], {easing: Easing.standard}),
}}
>
{label}
</div>
) : null}
</Fill>
);
};
videos/components/rolling-number/rolling-number.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {RollingNumber} from "./rolling-number";
export default defineComponentPreview({
title: "Rolling number",
category: "Typography",
description: "Metrics roll without width changes or digit jitter.",
component: RollingNumber,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
from: {type: "number", defaultValue: 0, min: 0, max: 10000},
to: {type: "number", defaultValue: 98.7, min: 0, max: 10000},
decimals: {type: "number", defaultValue: 1, min: 0, max: 3},
label: {type: "text", defaultValue: "frames delivered on time"},
suffix: {type: "text", defaultValue: "%"},
},
examples: [
{name: "Percentage", props: {to: 98.7, decimals: 1, suffix: "%", label: "frames delivered on time"}},
{name: "Count", props: {to: 12480, decimals: 0, suffix: "", label: "frames rendered"}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {RollingNumber} from "../components/rolling-number/rolling-number";
<Video>
<Scene id="rolling-number" duration="3s">
<RollingNumber
from={0}
to={98.7}
label="frames delivered on time"
suffix="%"
decimals={1}
/>
</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
- Typography
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 90 frames · 3s
- Minimum duration
- 54 frames · 1.8s
- Entrance
- 54 frames
- Exit
- 0 frames
- Reduced motion
- the final value is shown immediately
- Requires
- sans font
- Content limits
- label ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
from | number | 0 | — |
to | number | — | Yes |
label | string | — | — |
prefix | string | "" | — |
suffix | string | "" | — |
decimals | number | 0 | — |