state-transition
Before, processing, and resolved product states.
import {Scene, Video} from "odori";
import {StateTransition} from "../components/state-transition/state-transition";
<Video>
<Scene id="state-transition" duration="5s">
<StateTransition
from="Queued"
during="Rendering 360 frames"
to="Ready"
label="Export job"
/>
</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/state-transitionnpx odori add @odori/state-transitionbunx odori add @odori/state-transitionSource is copied into videos/components/state-transition/ 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/state-transition/state-transition.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type StateTransitionProps = {
/** The state the product starts in. */
from: string;
/** What it looks like while the work happens. */
during: string;
/** Where it resolves. */
to: string;
label?: string;
};
/**
* Before, working, resolved. The card keeps its size across all three, so the
* transition reads as one object changing rather than three cards cutting.
*/
export const StateTransition = ({from, during, to, label}: StateTransitionProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const stage = frame < 34 ? 0 : frame < 78 ? 1 : 2;
const text = [from, during, to][stage];
const enterAt = [0, 34, 78][stage];
const enter = interpolate(frame, [enterAt, enterAt + 14], [0, 1], {easing: Easing.standard});
const tint = stage === 2 ? brand.colors.accent : brand.colors.border;
const spin = (frame % 30) / 30;
return (
<Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center"}}>
{label ? <div style={{color: brand.colors.muted, fontSize: 32 * scale}}>{label}</div> : null}
<div
style={{
alignItems: "center",
background: brand.colors.surface,
border: `${2 * scale}px solid ${tint}`,
borderRadius: 26 * scale,
display: "flex",
gap: 22 * scale,
minWidth: 720 * scale,
justifyContent: "center",
padding: `${44 * scale}px ${52 * scale}px`,
}}
>
{stage === 1 ? (
<span
style={{
border: `${4 * scale}px solid ${brand.colors.border}`,
borderRadius: 999,
borderTopColor: brand.colors.accent,
display: "block",
height: 46 * scale,
transform: `rotate(${spin * 360}deg)`,
width: 46 * scale,
}}
/>
) : null}
<span
style={{
fontSize: 62 * scale,
fontWeight: 560,
letterSpacing: "-0.03em",
opacity: enter,
transform: `translateY(${(1 - enter) * 14 * scale}px)`,
}}
>
{text}
</span>
</div>
</Fill>
);
};
videos/components/state-transition/state-transition.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {StateTransition} from "./state-transition";
export default defineComponentPreview({
title: "State transition",
category: "Narrative",
description: "Before, processing, and resolved product states.",
component: StateTransition,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
label: {type: "text", defaultValue: "Export job"},
from: {type: "text", defaultValue: "Queued", maxLength: 24},
during: {type: "text", defaultValue: "Rendering 360 frames", maxLength: 32},
to: {type: "text", defaultValue: "Ready", maxLength: 24},
},
examples: [{name: "Export", props: {}}],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {StateTransition} from "../components/state-transition/state-transition";
<Video>
<Scene id="state-transition" duration="5s">
<StateTransition
from="Queued"
during="Rendering 360 frames"
to="Ready"
label="Export job"
/>
</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
- Narrative
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 150 frames · 5s
- Minimum duration
- 120 frames · 4s
- Entrance
- 14 frames
- Exit
- 0 frames
- Reduced motion
- states cut without a spinner
- Requires
- sans font
- Content limits
- from ≤ 24, during ≤ 32, to ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
fromThe state the product starts in. | string | — | Yes |
duringWhat it looks like while the work happens. | string | — | Yes |
toWhere it resolves. | string | — | Yes |
label | string | — | — |