Terminal zoom
A typed command with a camera zoom.
Customize
import {Scene, Video} from "odori";
import {TerminalZoom} from "../components/terminal-zoom/terminal-zoom";
<Video>
<Scene id="terminal-zoom" duration="8s">
<TerminalZoom
command="npm create odori@latest"
title="~/code/odori"
zoom={2.4}
prompt="$"
output=""
charactersPerSecond={14}
/>
</Scene>
</Video>Installation
pnpm odori add component terminal-zoomnpx odori add component terminal-zoombunx odori add component terminal-zoomEdit the source in videos/components/terminal-zoom/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/terminal-zoom/terminal-zoom.tsx
import {Easing, Fill, interpolate, useBrand, useDesignScale, useFrame, useTyping, typingFrames} from "odori";
export type TerminalZoomProps = {
/** The command typed at the prompt. */
command: string;
/** The window title, the way a shell titles its tab. */
title?: string;
/** How close the camera pushes while it tracks the caret. */
zoom?: number;
/** What the shell prints before the command. */
prompt?: string;
/** Line printed under the command once it finishes. */
output?: string;
charactersPerSecond?: number;
};
/** Monospace advance width as a fraction of the font size, near enough for
* every mono face we ship, and the only geometry the camera needs. */
const ADVANCE = 0.6;
/**
* A command typed under a camera that pushes in to read it and pulls back to
* show where it was.
*
* A camera locked at magnification for the whole shot shows you letters and
* never the room. This one earns the detail and then returns it: it opens on
* the whole window, dollies in as the first characters land, tracks the caret
* while the line is written, and eases back out once the command is finished
* so the frame ends on the thing in context. Typing lands character by
* character while the camera moves continuously, which is what keeps the push
* smooth under text that is inherently steppy.
*/
export const TerminalZoom = ({
command,
title = "~/code/odori",
zoom = 2.4,
prompt = "$",
output,
charactersPerSecond = 14,
}: TerminalZoomProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const from = 24;
const typed = useTyping(command, {from, charactersPerSecond});
const typingEnds = from + typingFrames(command, {charactersPerSecond});
// Design geometry, in composition pixels before the canvas scale.
const fontSize = 30;
const advance = fontSize * ADVANCE;
const view = 1240;
const lead = `${prompt} `.length;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
// In as the line starts, out once it is written: the push is a sentence with
// a beginning and an end rather than a state the shot is stuck in.
const push = interpolate(
frame,
[from - 6, from + 14, typingEnds + 14, typingEnds + 40],
[1, zoom, zoom, 1],
{easing: Easing.standard},
);
// The camera reads a continuous character count while the text lands in
// whole characters, so it glides where the letters step.
const continuous = Math.max(0, Math.min(command.length, ((frame - from) / 30) * charactersPerSecond));
const caret = (lead + continuous) * advance;
const lineWidth = (lead + command.length) * advance + advance * 2;
// Keep the caret centred, but never pull past either end of the line.
const centred = view / 2 - caret * push;
const offset = Math.min(0, Math.max(centred, view - Math.max(lineWidth, view) * push));
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div
style={{
background: brand.colors.surface,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(16),
boxShadow: `0 ${px(30)}px ${px(90)}px ${brand.colors.shadow}`,
maxWidth: px(1360),
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(16)}px)`,
width: "100%",
}}
>
<div
style={{
alignItems: "center",
borderBottom: `${px(1)}px solid ${brand.colors.border}`,
color: brand.colors.muted,
display: "flex",
fontFamily: brand.typography.mono,
fontSize: px(20),
gap: px(14),
padding: `${px(16)}px ${px(22)}px`,
}}
>
<span style={{display: "flex", gap: px(8)}}>
{[0, 1, 2].map((dot) => (
<span key={dot} style={{background: brand.colors.surface, borderRadius: px(999), height: px(11), width: px(11)}} />
))}
</span>
{title}
</div>
{/* The camera. Only this element moves; the window frame stays put, so
the push reads as a lens rather than the whole set sliding. */}
<div style={{height: px(300), overflow: "hidden", position: "relative"}}>
<div
style={{
fontFamily: brand.typography.mono,
fontSize: px(fontSize),
left: 0,
position: "absolute",
top: "50%",
transform: `translate(${px(offset)}px, -50%) scale(${push})`,
transformOrigin: "0 50%",
whiteSpace: "pre",
}}
>
<div style={{color: brand.colors.foreground}}>
<span style={{color: brand.colors.muted}}>{prompt} </span>
{typed.text}
<span
style={{
background: typed.caret ? "#E6E6EA" : "transparent",
display: "inline-block",
height: px(fontSize),
transform: `translateY(${px(6)}px)`,
width: px(advance),
}}
/>
</div>
{output ? (
<div
style={{
color: brand.colors.muted,
marginTop: px(18),
opacity: interpolate(frame, [typingEnds + 6, typingEnds + 18], [0, 1], {easing: Easing.standard}),
}}
>
{output}
</div>
) : null}
</div>
</div>
</div>
</Fill>
);
};
videos/components/terminal-zoom/terminal-zoom.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {TerminalZoom} from "./terminal-zoom";
export default defineComponentPreview({
title: "Terminal zoom",
category: "Developer proof/Terminal",
description: "A typed command with a camera zoom.",
component: TerminalZoom,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
command: {type: "text", defaultValue: "npm create odori@latest", maxLength: 72},
title: {type: "text", defaultValue: "~/code/odori", maxLength: 40},
prompt: {type: "text", defaultValue: "$", maxLength: 8},
output: {type: "text", defaultValue: "", maxLength: 72},
zoom: {type: "number", defaultValue: 2.4, min: 1, max: 4, step: 0.1},
charactersPerSecond: {type: "number", defaultValue: 14, min: 4, max: 40, step: 1},
},
examples: [
{name: "Default", props: {}},
{name: "With output", props: {command: "odori export launch", output: "ok out/launch.mp4"}},
{name: "Close", props: {zoom: 3.2, command: "odori add component terminal-zoom"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {TerminalZoom} from "../components/terminal-zoom/terminal-zoom";
<Video>
<Scene id="terminal-zoom" duration="8s">
<TerminalZoom
command="npm create odori@latest"
title="~/code/odori"
zoom={2.4}
prompt="$"
output=""
charactersPerSecond={14}
/>
</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
- 240 frames · 8s
- Minimum duration
- 120 frames · 4s
- Entrance
- 14 frames
- Exit
- 26 frames
- Reduced motion
- camera held at 1x, no push
- Requires
- mono font
- Content limits
- command ≤ 72, title ≤ 40, output ≤ 72
Props
| Prop | Type | Default | Required |
|---|---|---|---|
commandThe command typed at the prompt. | string | — | Yes |
titleThe window title, the way a shell titles its tab. | string | "~/code/odori" | — |
zoomHow close the camera pushes while it tracks the caret. | number | 2.4 | — |
promptWhat the shell prints before the command. | string | "$" | — |
outputLine printed under the command once it finishes. | string | — | — |
charactersPerSecond | number | 14 | — |
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.