Form flow
A form with validation and submission states.
Customize
import {Scene, Video} from "odori";
import {FormFlow} from "../components/form-flow/form-flow";
<Video>
<Scene id="form-flow" duration="6s">
<FormFlow
title="Create a project"
fields={[
{
label: "Name",
value: "launch-video",
at: 12
},
{
label: "Source root",
value: "videos/",
at: 60
}
]}
submit="Create"
submitAt={100}
success="Project created"
/>
</Scene>
</Video>Installation
pnpm odori add component form-flownpx odori add component form-flowbunx odori add component form-flowEdit the source in videos/components/form-flow/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/form-flow/form-flow.tsx
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type FormField = {
label: string;
/** What gets typed into it. */
value: string;
/** Frame the typing starts. */
at: number;
/** A validation message that appears, then clears when the value is fixed. */
error?: string;
};
export type FormFlowProps = {
title?: string;
fields?: FormField[];
submit?: string;
/** Frame the submit is pressed. */
submitAt?: number;
/** What replaces the form once it succeeds. */
success?: string;
};
/**
* Fields, validation, submit, success — the whole progression, driven by
* frames.
*
* Typing is drawn character by character from the frame clock rather than
* animated with a CSS trick, so the same characters appear on the same frames
* in preview and in the export, and a scrub backwards untypes them.
*/
export const FormFlow = ({
title = "Create a project",
fields = [],
submit = "Create",
submitAt = 100,
success = "Project created",
}: FormFlowProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const pressed = interpolate(frame, [submitAt - 3, submitAt, submitAt + 6], [0, 1, 0]);
const done = interpolate(frame, [submitAt + 6, submitAt + 20], [0, 1], {easing: Easing.standard});
return (
<Fill style={{alignItems: "center", justifyContent: "center", opacity: appear, padding: 80 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 24 * scale,
display: "flex",
flexDirection: "column",
gap: 26 * scale,
padding: 52 * scale,
position: "relative",
width: 880 * scale,
}}
>
<strong
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 40 * scale,
fontWeight: 600,
letterSpacing: "-0.025em",
opacity: 1 - done,
}}
>
{title}
</strong>
{fields.map((field) => {
// Six frames a character: fast enough to read as typing, slow
// enough that the viewer can follow what is being written.
const typed = Math.max(0, Math.min(field.value.length, Math.floor((frame - field.at) / 3)));
const text = field.value.slice(0, typed);
const complete = typed >= field.value.length;
const focused = frame >= field.at && !complete;
const showError = Boolean(field.error) && typed > 0 && !complete;
return (
<label key={field.label} style={{display: "flex", flexDirection: "column", gap: 10 * scale, opacity: 1 - done}}>
<span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>
{field.label}
</span>
<span
style={{
background: brand.colors.background,
border: `1px solid ${showError ? "#e5484d" : focused ? brand.colors.accent : brand.colors.border}`,
borderRadius: 12 * scale,
color: brand.colors.foreground,
fontFamily: brand.typography.mono,
fontSize: 26 * scale,
minHeight: 34 * scale,
padding: `${18 * scale}px ${22 * scale}px`,
}}
>
{text}
{focused ? (
// The caret blinks on the frame clock, not on wall time.
<span style={{opacity: Math.floor(frame / 8) % 2 === 0 ? 1 : 0.15}}>│</span>
) : null}
</span>
{showError ? (
<span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 20 * scale}}>
{field.error}
</span>
) : null}
</label>
);
})}
<span
style={{
background: brand.colors.accent,
borderRadius: 12 * scale,
color: brand.colors.background,
fontFamily: brand.typography.sans,
fontSize: 27 * scale,
fontWeight: 560,
opacity: 1 - done,
padding: `${18 * scale}px ${30 * scale}px`,
textAlign: "center",
transform: `scale(${1 - pressed * 0.03})`,
}}
>
{submit}
</span>
{success ? (
<div
style={{
alignItems: "center",
display: "flex",
flexDirection: "column",
gap: 16 * scale,
inset: 0,
justifyContent: "center",
opacity: done,
position: "absolute",
}}
>
<span
style={{
alignItems: "center",
background: brand.colors.accent,
borderRadius: 999,
color: brand.colors.background,
display: "flex",
fontSize: 34 * scale,
height: 76 * scale,
justifyContent: "center",
transform: `scale(${0.8 + done * 0.2})`,
width: 76 * scale,
}}
>
✓
</span>
<strong
style={{color: brand.colors.foreground, fontFamily: brand.typography.sans, fontSize: 34 * scale}}
>
{success}
</strong>
</div>
) : null}
</div>
</Fill>
);
};
videos/components/form-flow/form-flow.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {FormFlow} from "./form-flow";
export default defineComponentPreview({
title: "Form flow",
category: "Interface/Surfaces",
description: "A form with validation and submission states.",
component: FormFlow,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
title: {type: "text", defaultValue: "Create a project"},
submit: {type: "text", defaultValue: "Create"},
submitAt: {type: "number", defaultValue: 100, min: 30, max: 170},
success: {type: "text", defaultValue: "Project created"},
},
examples: [
{
name: "Default",
props: {
fields: [
{label: "Name", value: "launch-video", at: 12},
{label: "Source root", value: "videos/", at: 60},
],
},
},
{
name: "With validation",
props: {
fields: [{label: "Name", value: "Launch Video", at: 12, error: "Use a lowercase, dash separated name."}],
submitAt: 90,
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {FormFlow} from "../components/form-flow/form-flow";
<Video>
<Scene id="form-flow" duration="6s">
<FormFlow
title="Create a project"
fields={[
{
label: "Name",
value: "launch-video",
at: 12
},
{
label: "Source root",
value: "videos/",
at: 60
}
]}
submit="Create"
submitAt={100}
success="Project created"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 60 frames · 2s
- Entrance
- 14 frames
- Exit
- 20 frames
- Reduced motion
- values appear complete rather than typing in
- Requires
- sans font
- Content limits
- title ≤ 48, fields ≤ 4, submit ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | "Create a project" | — |
fields | FormField[] | [] | — |
submit | string | "Create" | — |
submitAtFrame the submit is pressed. | number | 100 | — |
successWhat replaces the form once it succeeds. | string | "Project created" | — |
Related components
All componentsAccordionSections opening one at a time, the outgoing one closing as the next grows.Browser demoBrowser chrome for deterministic product interface frames.Card deckStacked cards that cycle from front to back.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.