Resolve text
Glyphs flicker through seeded noise and lock in left to right.
Customize
import {Scene, Video} from "odori";
import {ResolveText} from "../components/resolve-text/resolve-text";
<Video>
<Scene id="resolve-text" duration="5s">
<ResolveText
text="deploy --prod"
detail="Zero-config, every time."
stagger={3}
settleFrames={16}
/>
</Scene>
</Video>Installation
pnpm odori add component resolve-textnpx odori add component resolve-textbunx odori add component resolve-textEdit the source in videos/components/resolve-text/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/resolve-text/resolve-text.tsx
import {Fill, Easing, interpolate, random, useBrand, useFrame, useDesignScale} from "odori";
export type ResolveTextProps = {
text: string;
detail?: string;
/** Frames between one glyph locking and the next. */
stagger?: number;
/** How long a glyph flickers through noise before it locks. */
settleFrames?: number;
/** Characters the flicker draws from. Defaults to a dev-tool spread. */
alphabet?: string;
};
const DEFAULT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789#$%&*+<>/";
/**
* Glyphs resolve out of seeded noise, locking in left to right. The scramble
* is a pure function of the glyph index and a quantized frame step, so a
* scrubbed preview and a parallel render flicker through the same wrong
* characters. The mono face keeps every slot the same width, so the line
* never shifts while it is still lying.
*/
export const ResolveText = ({
text,
detail,
stagger = 3,
settleFrames = 16,
alphabet = DEFAULT_ALPHABET,
}: ResolveTextProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const glyphs = [...text];
const settled = 6 + glyphs.length * stagger + settleFrames;
return (
<Fill
style={{
alignItems: "center",
gap: 30 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
}}
>
<div style={{display: "flex", fontFamily: brand.typography.mono, fontSize: 104 * scale, letterSpacing: "-0.02em"}}>
{glyphs.map((glyph, index) => {
// Seeded jitter keeps the lock order left-to-right in spirit
// without reading as a metronome.
const jitter = Math.floor(random(["resolve-jitter", index]) * stagger);
const start = 4 + index * stagger + jitter;
const lock = start + settleFrames;
const locked = frame >= lock;
// A new wrong character every third frame: fast enough to read as
// noise, slow enough that each one is legible.
const step = Math.floor(frame / 3);
const noise = alphabet[Math.floor(random(["resolve", index, step]) * alphabet.length)] ?? glyph;
const shown = glyph === " " || locked ? glyph : noise;
return (
<span
key={`${glyph}-${index}`}
style={{
color: locked ? brand.colors.foreground : brand.colors.muted,
opacity: frame < start ? 0 : locked ? 1 : 0.85,
whiteSpace: "pre",
}}
>
{shown}
</span>
);
})}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontFamily: brand.typography.mono,
fontSize: 30 * scale,
opacity: interpolate(frame, [settled, settled + 18], [0, 1], {easing: Easing.standard}),
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/resolve-text/resolve-text.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ResolveText} from "./resolve-text";
export default defineComponentPreview({
title: "Resolve text",
category: "Typography/Titles",
description: "Glyphs flicker through seeded noise and lock in left to right.",
component: ResolveText,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
text: {type: "text", defaultValue: "deploy --prod", maxLength: 24},
detail: {type: "text", defaultValue: "Zero-config, every time."},
stagger: {type: "number", defaultValue: 3, min: 1, max: 8},
settleFrames: {type: "number", defaultValue: 16, min: 4, max: 40},
},
examples: [
{name: "Default", props: {text: "deploy --prod", detail: "Zero-config, every time."}},
{name: "Slow burn", props: {text: "v2.0.0", stagger: 6, settleFrames: 28}},
{name: "Binary", props: {text: "SHIPPED", alphabet: "01"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ResolveText} from "../components/resolve-text/resolve-text";
<Video>
<Scene id="resolve-text" duration="5s">
<ResolveText
text="deploy --prod"
detail="Zero-config, every time."
stagger={3}
settleFrames={16}
/>
</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
- 150 frames · 5s
- Minimum duration
- 90 frames · 3s
- Entrance
- 80 frames
- Exit
- 0 frames
- Reduced motion
- glyphs appear already resolved, without the flicker
- Requires
- mono font
- Content limits
- text ≤ 20
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
detail | string | — | — |
staggerFrames between one glyph locking and the next. | number | 3 | — |
settleFramesHow long a glyph flickers through noise before it locks. | number | 16 | — |
alphabetCharacters the flicker draws from. Defaults to a dev-tool spread. | string | DEFAULT_ALPHABET | — |
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.