Terminal
A typed command session with deterministic output reveals.
Customize
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>Installation
pnpm odori add component terminalnpx odori add component terminalbunx odori add component terminalEdit the source in videos/components/terminal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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};
/** Close, minimise, zoom — the colours a window has had for twenty years. */
const TRAFFIC = ["#FF5F57", "#FEBC2E", "#28C840"];
export type TerminalProps = {
steps: TerminalStep[];
title?: string;
prompt?: string;
/** Window buttons in the brand's own surface colour rather than in red,
amber, and green. */
monochrome?: boolean;
};
/**
* 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 = "$", monochrome = false}: 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 ${brand.colors.shadow}`,
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`,
}}
>
{/* The window buttons, in the colours every reader already knows.
They are not brand colours and should not become them: a
terminal that recolours its close button to match a palette
stops reading as a terminal, which is the one job this chrome
has. `monochrome` is there for a brand that wants the quieter
window. */}
<div style={{display: "flex", gap: 9 * scale}}>
{(monochrome ? [brand.colors.surface, brand.colors.surface, brand.colors.surface] : TRAFFIC).map(
(colour, index) => (
<span
key={index}
style={{background: colour, 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/Terminal",
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"]}]},
},
],
});
Add it to 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>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Developer proof
- 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 | "$" | — |
monochromeWindow buttons in the brand's own surface colour rather than in red, | boolean | false | — |
Related components
All componentsProduct launchA framed source excerpt that reveals line by line.Code walkthroughProgressive code reveal with authored focal regions.Dependency graphPackages and modules connected as a readable graph.Diff proofBefore and after source changes with semantic emphasis.File treeProgressive repository navigation with active file state.Log streamPaced runtime logs with levels and bounded scrolling.