Glitch text
A title that arrives through brief seeded interference bursts.
Customize
import {Scene, Video} from "odori";
import {GlitchText} from "../components/glitch-text/glitch-text";
<Video>
<Scene id="glitch-text" duration="4s">
<GlitchText
text="Signal"
detail="Locked on the first pass."
burstFrames={6}
strength={1}
/>
</Scene>
</Video>Installation
pnpm odori add component glitch-textnpx odori add component glitch-textbunx odori add component glitch-textEdit the source in videos/components/glitch-text/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/glitch-text/glitch-text.tsx
import type {CSSProperties} from "react";
import {Easing, Fill, interpolate, random, randomBetween, useBrand, useDesignScale, useFrame} from "odori";
export type GlitchTextProps = {
text: string;
detail?: string;
/** Frames on which a burst begins. Two or three short ones read best. */
bursts?: number[];
/** How long each burst lasts. */
burstFrames?: number;
/** How far slices and the colour split are pushed, 1 being the design. */
strength?: number;
};
/** Characters a glyph briefly becomes mid-burst. All near the title's width. */
const NOISE = "#%&$/\\<>*+=@";
/**
* A title that arrives through interference.
*
* The effect lives entirely inside the bursts: between them the text is
* perfectly clean, which is what makes the corruption read as an event
* rather than a style. Each burst hits at full intensity and decays, so it
* feels like a signal being caught, and the last one leaves the title
* settled for good — this never loops.
*
* All jitter comes from seeded random keyed to a two-frame step, so the
* shudder is fast but stable within a step, and identical on every machine.
*/
export const GlitchText = ({text, detail, bursts = [8, 30], burstFrames = 6, strength = 1}: GlitchTextProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const step = Math.floor(frame / 2);
let intensity = 0;
for (const burst of bursts) {
if (frame >= burst && frame < burst + burstFrames) {
intensity = Math.max(intensity, 1 - (frame - burst) / burstFrames);
}
}
const visible = bursts.length === 0 || frame >= Math.min(...bursts);
const settled = bursts.length === 0 ? 0 : Math.max(...bursts) + burstFrames;
const push = intensity * strength;
const glyphs = [...text]
.map((character, index) => {
if (intensity <= 0 || character === " ") return character;
const swap = random([step, index, "swap"]) < intensity * 0.28;
return swap ? NOISE[Math.floor(random([step, index, "pick"]) * NOISE.length)] : character;
})
.join("");
const line = (style: CSSProperties, key?: number) => (
<div
key={key}
aria-hidden={style.position === "absolute"}
style={{
fontSize: 128 * scale,
fontWeight: 650,
letterSpacing: "-0.03em",
lineHeight: 1.05,
whiteSpace: "pre",
...style,
}}
>
{glyphs}
</div>
);
return (
<Fill style={{alignItems: "center", gap: 28 * scale, justifyContent: "center"}}>
<div style={{opacity: visible ? 1 : 0, position: "relative"}}>
{/* The colour split sits under the clean copy at low opacity, so the
title doubles without ever losing its own ink. */}
{intensity > 0 ? (
<>
{line({
color: brand.colors.muted,
left: -randomBetween([step, "split"], 3, 7) * push * scale,
opacity: 0.45 * intensity,
position: "absolute",
top: 0,
})}
{line({
color: brand.colors.foreground,
left: randomBetween([step, "split"], 3, 7) * push * scale,
opacity: 0.45 * intensity,
position: "absolute",
top: 0,
})}
</>
) : null}
{line({color: brand.colors.foreground, position: "relative"})}
{/* Horizontal slices, each a clipped copy shoved a few pixels aside.
Three bands are enough to read as a tear without shredding. */}
{intensity > 0
? [0, 1, 2].map((band) => {
const top = randomBetween([step, band, "top"], 5, 80);
const depth = randomBetween([step, band, "depth"], 8, 20);
return line(
{
clipPath: `inset(${top}% 0 ${Math.max(0, 100 - top - depth)}% 0)`,
color: brand.colors.foreground,
left: randomBetween([step, band, "shove"], -14, 14) * push * scale,
position: "absolute",
top: 0,
},
band,
);
})
: null}
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
opacity: interpolate(frame, [settled, settled + 16], [0, 1], {easing: Easing.standard}),
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/glitch-text/glitch-text.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {GlitchText} from "./glitch-text";
export default defineComponentPreview({
title: "Glitch text",
category: "Typography/Titles",
description: "A title that arrives through brief seeded interference bursts.",
component: GlitchText,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
text: {type: "text", defaultValue: "Signal", maxLength: 24},
detail: {type: "text", defaultValue: "Locked on the first pass."},
burstFrames: {type: "number", defaultValue: 6, min: 2, max: 14},
strength: {type: "number", defaultValue: 1, min: 0.2, max: 2, step: 0.1},
},
examples: [
{name: "Default", props: {text: "Signal", detail: "Locked on the first pass."}},
{name: "Three bursts", props: {text: "Interference", bursts: [6, 24, 44], burstFrames: 5}},
{name: "Gentle", props: {text: "Static", strength: 0.5, bursts: [10, 36], burstFrames: 8}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {GlitchText} from "../components/glitch-text/glitch-text";
<Video>
<Scene id="glitch-text" duration="4s">
<GlitchText
text="Signal"
detail="Locked on the first pass."
burstFrames={6}
strength={1}
/>
</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
- 36 frames
- Exit
- 0 frames
- Reduced motion
- the title fades in cleanly with no bursts
- Requires
- sans font
- Content limits
- text ≤ 24, detail ≤ 48
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
detail | string | — | — |
burstsFrames on which a burst begins. Two or three short ones read best. | number[] | [8 | — |
burstFramesHow long each burst lasts. | number | 6 | — |
strengthHow far slices and the colour split are pushed, 1 being the design. | number | 1 | — |
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.Kinetic centerA centered statement assembled from timed word groups.