Typewriter
Deterministic typing with a frame-driven caret.
Customize
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>Installation
pnpm odori add component typewriternpx odori add component typewriterbunx odori add component typewriterEdit the source in videos/components/typewriter/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files 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/Titles",
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}},
],
});
Add it to 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>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Typography
- 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 | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.Draw titleAn outline draws across the line, then fills solid.Flap boardA split-flap board clatters through to the target string.Focus inWords settle from a soft blur into a crisp line.Glitch textA title that arrives through brief seeded interference bursts.