Liquid title
A headline displaced by moving glass that settles as the shot goes on.
Customize
import {Scene, Video} from "odori";
import {LiquidTitle} from "../components/liquid-title/liquid-title";
<Video>
<Scene id="liquid-title" duration="4s">
<LiquidTitle
title="Author the story."
detail="Frame accurate, every render."
amount={28}
scale={2.2}
settle={1}
/>
</Scene>
</Video>Installation
pnpm odori add component liquid-titlenpx odori add component liquid-titlebunx odori add component liquid-titleEdit the source in videos/components/liquid-title/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/liquid-title/liquid-title.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";
/**
* A headline seen through moving glass.
*
* The text is ordinary HTML, set in the brand's display face, and the whole
* frame is displaced by a noise field before it reaches the screen. That is
* the point of doing it this way rather than as an SVG filter: the type stays
* type, so it kerns and wraps and can be read by anything inspecting the DOM,
* and only the last step is pixels.
*
* The distortion settles as the shot goes on, so a title arrives unreadable
* and resolves, which is usually what this is for. Set `settle` to 0 to keep
* it moving throughout.
*/
export type LiquidTitleProps = {
title: string;
/** A quieter line under the headline. */
detail?: string;
/** How far the glass pushes the picture, in pixels at 1080p. */
amount?: number;
/** Size of the ripples. Larger is slower and wider. */
scale?: number;
/** 1 resolves to clear by the end of the shot, 0 never settles. */
settle?: number;
};
const liquid = defineShaderEffect({
name: "liquid-title",
uniforms: {amount: numberUniform(28), scale: numberUniform(2.2), settle: numberUniform(1)},
fragmentShader: `
uniform float amount;
uniform float scale;
uniform float settle;
float value(vec2 p) {
vec2 cell = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x),
mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x),
f.y);
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
float t = seconds * 0.6;
/* Two noise samples a little apart give a direction to push in, rather than
one sample which would only ever push along one axis. */
vec2 p = uv * scale + vec2(t, -t * 0.7);
vec2 push = vec2(value(p) - 0.5, value(p + 31.7) - 0.5);
/* Eased so it lets go quickly and then creeps to nothing, which reads as
settling rather than as a fade. */
float strength = mix(1.0, pow(1.0 - progress, 2.2), settle);
odoriColour = tex(uv + push * (amount / resolution) * strength * 2.0);
}`,
});
export const LiquidTitle = ({title, detail, amount = 28, scale = 2.2, settle = 1}: LiquidTitleProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const unit = Math.min(width, height) / 1080;
return (
<EffectSurface effects={[liquid({amount, scale, settle})]}>
<Fill
style={{
alignItems: "center",
background: brand.colors.background,
display: "flex",
flexDirection: "column",
gap: 28 * unit,
height,
justifyContent: "center",
padding: 96 * unit,
width,
}}
>
<h1
style={{
color: brand.colors.foreground,
fontFamily: brand.typography.display ?? brand.typography.sans,
fontSize: 168 * unit,
fontWeight: 700,
letterSpacing: "-0.035em",
lineHeight: 1.02,
margin: 0,
textAlign: "center",
}}
>
{title}
</h1>
{detail ? (
<p
style={{
color: brand.colors.muted,
fontFamily: brand.typography.sans,
fontSize: 44 * unit,
margin: 0,
textAlign: "center",
}}
>
{detail}
</p>
) : null}
</Fill>
</EffectSurface>
);
};
videos/components/liquid-title/liquid-title.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {LiquidTitle} from "./liquid-title";
export default defineComponentPreview({
title: "Liquid title",
category: "Typography/Titles",
description: "A headline displaced by moving glass that settles as the shot goes on.",
component: LiquidTitle,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
title: {type: "text", defaultValue: "Author the story.", maxLength: 48},
detail: {type: "text", defaultValue: "Frame accurate, every render.", maxLength: 64},
amount: {type: "number", defaultValue: 28, min: 0, max: 90, step: 2},
scale: {type: "number", defaultValue: 2.2, min: 0.5, max: 8, step: 0.1},
settle: {type: "number", defaultValue: 1, min: 0, max: 1, step: 0.1},
},
examples: [
{name: "Settles", props: {}},
{name: "Never settles", props: {settle: 0, amount: 18}},
{name: "Heat haze", props: {amount: 12, scale: 6}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {LiquidTitle} from "../components/liquid-title/liquid-title";
<Video>
<Scene id="liquid-title" duration="4s">
<LiquidTitle
title="Author the story."
detail="Frame accurate, every render."
amount={28}
scale={2.2}
settle={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
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set amount to 0 and the type is undisturbed
- Requires
- display font, sans font
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
title | string | — | Yes |
detailA quieter line under the headline. | string | — | — |
amountHow far the glass pushes the picture, in pixels at 1080p. | number | 28 | — |
scaleSize of the ripples. Larger is slower and wider. | number | 2.2 | — |
settle1 resolves to clear by the end of the shot, 0 never settles. | 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.Glitch textA title that arrives through brief seeded interference bursts.