terminal
A typed command session with deterministic output reveals.
import {Scene, Video} from "odori";
import {Terminal} from "../components/terminal/terminal";
<Video>
<Scene id="terminal" duration="6s">
<Terminal
steps={[
{
command: "pnpm create odori@latest product-stories",
output: [
"Created videos/launch/video.tsx"
]
},
{
command: "pnpm odori dev",
output: [
"Odori Studio ready on http://127.0.0.1:4300"
]
}
]}
title="zsh"
prompt="$"
/>
</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/terminalnpx odori add @odori/terminalbunx odori add @odori/terminalSource is copied into videos/components/terminal/ 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/terminal/terminal.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TerminalStep = {command: string; output?: string[]; durationInFrames?: number};
export type TerminalProps = {
steps: TerminalStep[];
title?: string;
prompt?: string;
};
/**
* A typed session. Characters, the caret blink, and output all derive from the
* frame, so scrubbing backwards untypes the command exactly.
*/
export const Terminal = ({steps, title = "product-stories", prompt = "$"}: TerminalProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const enter = interpolate(frame, [0, 20], [0, 1], {easing: Easing.standard});
let cursor = 0;
const rendered = steps.map((step) => {
const typing = Math.max(14, Math.round(step.command.length * 1.5));
const hold = step.durationInFrames ?? typing + 40;
const start = cursor;
cursor += hold;
const local = frame - start;
return {
step,
visible: local >= 0,
typed: Math.max(0, Math.min(step.command.length, Math.round(local / 1.5))),
typing,
local,
outputProgress: interpolate(local, [typing + 6, typing + 20], [0, 1], {easing: Easing.standard}),
};
});
return (
<Fill style={{alignItems: "center", justifyContent: "center", padding: 110 * scale}}>
<div
style={{
background: brand.colors.surface,
border: `1px solid ${brand.colors.border}`,
borderRadius: 16 * scale,
boxShadow: `0 ${30 * scale}px ${90 * scale}px rgba(0,0,0,0.6)`,
fontFamily: brand.typography.mono,
maxWidth: 1360 * scale,
minHeight: 540 * scale,
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * 26 * scale}px)`,
width: "100%",
}}
>
<div
style={{
alignItems: "center",
background: "rgba(255,255,255,0.02)",
borderBottom: `1px solid ${brand.colors.border}`,
color: brand.colors.muted,
display: "flex",
fontSize: 21 * scale,
gap: 14 * scale,
padding: `${18 * scale}px ${24 * scale}px`,
}}
>
<div style={{display: "flex", gap: 9 * scale}}>
{[0, 1, 2].map((index) => (
<span key={index} style={{background: "#2a2a2a", borderRadius: 999, height: 12 * scale, width: 12 * scale}} />
))}
</div>
<span style={{marginLeft: 8 * scale}}>{title}</span>
</div>
<div style={{display: "grid", fontSize: 29 * scale, gap: 20 * scale, lineHeight: 1.5, padding: 32 * scale}}>
{rendered.map((item, index) =>
item.visible ? (
<div key={index} style={{display: "grid", gap: 8 * scale}}>
<div style={{display: "flex", gap: 14 * scale}}>
<span style={{color: brand.colors.muted}}>{prompt}</span>
<span style={{color: brand.colors.foreground, whiteSpace: "pre"}}>
{item.step.command.slice(0, item.typed)}
{item.typed < item.step.command.length || Math.floor(item.local / 15) % 2 === 0 ? (
<span
style={{
background: brand.colors.foreground,
display: "inline-block",
height: 30 * scale,
marginLeft: 3 * scale,
transform: `translateY(${5 * scale}px)`,
width: 13 * scale,
}}
/>
) : null}
</span>
</div>
{item.outputProgress > 0
? (item.step.output ?? []).map((line, lineIndex) => (
<div
key={lineIndex}
style={{
color: brand.colors.muted,
opacity: item.outputProgress,
paddingLeft: 32 * scale,
transform: `translateY(${(1 - item.outputProgress) * 6 * scale}px)`,
}}
>
{line}
</div>
))
: null}
</div>
) : null,
)}
</div>
</div>
</Fill>
);
};
videos/components/terminal/terminal.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Terminal} from "./terminal";
export default defineComponentPreview({
title: "Terminal",
category: "Developer proof",
description: "A typed command session with deterministic output reveals.",
component: Terminal,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
title: {type: "text", defaultValue: "zsh"},
prompt: {type: "text", defaultValue: "$", maxLength: 4},
},
examples: [
{
name: "Install and preview",
props: {
steps: [
{command: "pnpm create odori@latest product-stories", output: ["Created videos/launch/video.tsx"]},
{command: "pnpm odori dev", output: ["Odori Studio ready on http://127.0.0.1:4300"]},
],
},
},
{
name: "Single command",
props: {steps: [{command: "pnpm odori export launch", output: ["out/launch.mp4"]}]},
},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {Terminal} from "../components/terminal/terminal";
<Video>
<Scene id="terminal" duration="6s">
<Terminal
steps={[
{
command: "pnpm create odori@latest product-stories",
output: [
"Created videos/launch/video.tsx"
]
},
{
command: "pnpm odori dev",
output: [
"Odori Studio ready on http://127.0.0.1:4300"
]
}
]}
title="zsh"
prompt="$"
/>
</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
- Developer proof
- Aspect ratios
- 16:9 · 1:1
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 60 frames · 2s
- Entrance
- 12 frames
- Exit
- 10 frames
- Reduced motion
- prints commands without typing
- Requires
- mono font
- Content limits
- commandLength ≤ 58, steps ≤ 4
Props
| Prop | Type | Default | Required |
|---|---|---|---|
steps | TerminalStep[] | — | Yes |
title | string | "product-stories" | — |
prompt | string | "$" | — |