Modal flow
Trigger, dialog, confirmation, and the state it resolves to.
Customize
import {Scene, Video} from "odori";
import {ModalFlow} from "../components/modal-flow/modal-flow";
<Video>
<Scene id="modal-flow" duration="4s">
<ModalFlow
title="Deploy to production?"
confirm="Deploy"
resolved="Deployed"
openAt={18}
confirmAt={66}
/>
</Scene>
</Video>Installation
pnpm odori add component modal-flownpx odori add component modal-flowbunx odori add component modal-flowEdit the source in videos/components/modal-flow/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/modal-flow/modal-flow.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type ModalFlowProps = {
/** What sits behind the dialog. */
children?: ReactNode;
title?: string;
body?: string;
/** The primary button's label. */
confirm?: string;
cancel?: string;
/** What the surface says once the dialog has resolved. */
resolved?: string;
/** Frame the dialog opens on. */
openAt?: number;
/** Frame the confirm button is pressed. */
confirmAt?: number;
};
/**
* The four states a dialog actually has: closed, open, confirming, resolved.
*
* They are driven by two frame numbers rather than by props a caller flips,
* because the point of a video is the transition between states — a component
* that only renders one of them makes the author animate it from outside, and
* then two videos of the same dialog disagree about how it opens.
*/
export const ModalFlow = ({
children,
title = "Deploy to production?",
body = "This will promote the current preview build. It can be rolled back.",
confirm = "Deploy",
cancel = "Cancel",
resolved = "Deployed",
openAt = 18,
confirmAt = 66,
}: ModalFlowProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const open = interpolate(frame, [openAt, openAt + 12], [0, 1], {easing: Easing.standard});
const close = interpolate(frame, [confirmAt, confirmAt + 10], [1, 0], {easing: Easing.standard});
const visible = Math.min(open, close);
const pressed = interpolate(frame, [confirmAt - 3, confirmAt, confirmAt + 6], [0, 1, 0]);
const settled = interpolate(frame, [confirmAt + 8, confirmAt + 20], [0, 1], {easing: Easing.standard});
return (
<Fill>
{children}
<div
aria-hidden
style={{background: "rgba(0,0,0,0.62)", inset: 0, opacity: visible, position: "absolute"}}
/>
<div
style={{
alignItems: "center",
inset: 0,
justifyContent: "center",
display: "flex",
opacity: visible,
position: "absolute",
}}
>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 24 * scale,
boxShadow: `0 ${30 * scale}px ${70 * scale}px ${brand.colors.shadow}`,
display: "flex",
flexDirection: "column",
gap: 20 * scale,
padding: 48 * scale,
transform: `translateY(${(1 - visible) * 24 * scale}px) scale(${0.97 + visible * 0.03})`,
width: 900 * scale,
}}
>
<strong
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.sans,
fontSize: 44 * scale,
fontWeight: 600,
letterSpacing: "-0.025em",
}}
>
{title}
</strong>
<p
style={{
color: brand.colors.muted,
fontFamily: brand.typography.sans,
fontSize: 27 * scale,
lineHeight: 1.5,
margin: 0,
}}
>
{body}
</p>
<div style={{display: "flex", gap: 14 * scale, justifyContent: "flex-end", marginTop: 12 * scale}}>
<span
style={{
border: `1px solid ${brand.colors.border}`,
borderRadius: 12 * scale,
color: brand.colors.muted,
fontFamily: brand.typography.sans,
fontSize: 26 * scale,
padding: `${16 * scale}px ${28 * scale}px`,
}}
>
{cancel}
</span>
<span
style={{
background: brand.colors.accent,
borderRadius: 12 * scale,
color: brand.colors.background,
fontFamily: brand.typography.sans,
fontSize: 26 * scale,
fontWeight: 560,
padding: `${16 * scale}px ${28 * scale}px`,
// The press is the only motion on the button, and it is what
// makes the dialog close read as caused rather than scheduled.
transform: `scale(${1 - pressed * 0.04})`,
}}
>
{confirm}
</span>
</div>
</div>
</div>
{resolved ? (
<div
style={{
alignItems: "center",
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 14 * scale,
bottom: 60 * scale,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.sans,
fontSize: 26 * scale,
gap: 12 * scale,
left: "50%",
opacity: settled,
padding: `${18 * scale}px ${28 * scale}px`,
position: "absolute",
transform: `translate(-50%, ${(1 - settled) * 16 * scale}px)`,
}}
>
<span style={{background: brand.colors.accent, borderRadius: 999, height: 10 * scale, width: 10 * scale}} />
{resolved}
</div>
) : null}
</Fill>
);
};
videos/components/modal-flow/modal-flow.preview.tsx
import {useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {ModalFlow} from "./modal-flow";
const Behind = () => {
const brand = useBrand();
return (
<div style={{background: brand.colors.background, inset: 0, position: "absolute"}}>
<div
style={{
color: brand.colors.foreground,
fontFamily: "ui-sans-serif, system-ui",
fontSize: 44,
left: 90,
position: "absolute",
top: 80,
}}
>
Deployments
</div>
</div>
);
};
export default defineComponentPreview({
title: "Modal flow",
category: "Interface/Surfaces",
description: "Trigger, dialog, confirmation, and the state it resolves to.",
component: ModalFlow,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Deploy to production?"},
confirm: {type: "text", defaultValue: "Deploy"},
resolved: {type: "text", defaultValue: "Deployed"},
openAt: {type: "number", defaultValue: 18, min: 0, max: 60},
confirmAt: {type: "number", defaultValue: 66, min: 20, max: 110},
},
examples: [
{name: "Default", props: {children: <Behind />}},
{name: "Destructive", props: {children: <Behind />, title: "Delete project?", confirm: "Delete", resolved: "Deleted"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ModalFlow} from "../components/modal-flow/modal-flow";
<Video>
<Scene id="modal-flow" duration="4s">
<ModalFlow
title="Deploy to production?"
confirm="Deploy"
resolved="Deployed"
openAt={18}
confirmAt={66}
/>
</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
- 120 frames · 4s
- Minimum duration
- 60 frames · 2s
- Entrance
- 12 frames
- Exit
- 10 frames
- Reduced motion
- the dialog cuts between states without scaling
- Requires
- sans font
- Content limits
- title ≤ 64, body ≤ 160, confirm ≤ 24, cancel ≤ 24, resolved ≤ 40
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat sits behind the dialog. | ReactNode | — | — |
title | string | "Deploy to production?" | — |
body | string | "This will promote the current preview build. It can be rolled back." | — |
confirmThe primary button's label. | string | "Deploy" | — |
cancel | string | "Cancel" | — |
resolvedWhat the surface says once the dialog has resolved. | string | "Deployed" | — |
openAtFrame the dialog opens on. | number | 18 | — |
confirmAtFrame the confirm button is pressed. | number | 66 | — |
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.