typewriter
Deterministic typing with a frame-driven caret.
import {Scene, Video} from "odori";
import {Typewriter} from "../components/typewriter/typewriter";
<Video>
<Scene id="typewriter" duration="4s">
<Typewriter
text="pnpm odori dev"
label="one command"
charactersPerSecond={18}
/>
</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/typewriternpx odori add @odori/typewriterbunx odori add @odori/typewriterSource is copied into videos/components/typewriter/ 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/typewriter/typewriter.tsx
import {Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type TypewriterProps = {
text: string;
label?: string;
/** Characters revealed per second. */
charactersPerSecond?: number;
};
/**
* Typing driven by the frame counter rather than a timer, so scrubbing to a
* frame shows exactly the characters that frame will render. The caret blinks
* on a frame cycle for the same reason.
*/
export const Typewriter = ({text, label, charactersPerSecond = 18}: TypewriterProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const visible = Math.min(text.length, Math.max(0, Math.floor(((frame - 8) / 30) * charactersPerSecond)));
const typed = text.slice(0, visible);
const done = visible >= text.length;
// A 30 frame cycle: on for the first half, off for the second.
const caretOn = done ? frame % 30 < 15 : true;
return (
<Fill
style={{
alignItems: "center",
gap: 26 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
}}
>
{label ? (
<div
style={{
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 26 * scale,
opacity: interpolate(frame, [0, 12], [0, 1]),
}}
>
{label}
</div>
) : null}
<div
style={{
alignItems: "center",
display: "flex",
fontFamily: brand.typography.mono,
fontSize: 76 * scale,
gap: 6 * scale,
letterSpacing: "-0.02em",
}}
>
<span style={{whiteSpace: "pre"}}>{typed}</span>
<span
style={{
background: brand.colors.accent,
display: "inline-block",
height: 76 * scale,
opacity: caretOn ? 1 : 0,
width: 10 * scale,
}}
/>
</div>
</Fill>
);
};
videos/components/typewriter/typewriter.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Typewriter} from "./typewriter";
export default defineComponentPreview({
title: "Typewriter",
category: "Typography",
description: "Deterministic typing with a frame-driven caret.",
component: Typewriter,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
text: {type: "text", defaultValue: "pnpm odori dev", maxLength: 48},
label: {type: "text", defaultValue: "one command"},
charactersPerSecond: {type: "number", defaultValue: 18, min: 4, max: 40},
},
examples: [
{name: "Command", props: {text: "pnpm odori dev", label: "one command"}},
{name: "Export", props: {text: "pnpm odori export launch", charactersPerSecond: 26}},
],
});
Use it in a scene.
import {Scene, Video} from "odori";
import {Typewriter} from "../components/typewriter/typewriter";
<Video>
<Scene id="typewriter" duration="4s">
<Typewriter
text="pnpm odori dev"
label="one command"
charactersPerSecond={18}
/>
</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
- Typography
- Aspect ratios
- 16:9 · 9:16 · 1:1
- Recommended duration
- 120 frames · 4s
- Minimum duration
- 60 frames · 2s
- Entrance
- 8 frames
- Exit
- 0 frames
- Reduced motion
- the full string is shown with a static caret
- Requires
- mono font
- Content limits
- text ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
label | string | — | — |
charactersPerSecondCharacters revealed per second. | number | 18 | — |