Draw title
An outline draws across the line, then fills solid.
Customize
import {Scene, Video} from "odori";
import {DrawTitle} from "../components/draw-title/draw-title";
<Video>
<Scene id="draw-title" duration="5s">
<DrawTitle
text="Drawn out"
detail="Stroke first, substance second."
drawFrames={40}
fillDelay={8}
strokeWidth={2}
/>
</Scene>
</Video>Installation
pnpm odori add component draw-titlenpx odori add component draw-titlebunx odori add component draw-titleEdit the source in videos/components/draw-title/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/draw-title/draw-title.tsx
import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";
export type DrawTitleProps = {
text: string;
detail?: string;
/** Frames the outline takes to draw across the line. */
drawFrames?: number;
/** Frames between the outline finishing and the fill starting. */
fillDelay?: number;
/** Stroke width in design pixels. */
strokeWidth?: number;
};
/**
* Outline type that draws on, then fills. The text is rendered twice and
* stacked: a stroked, hollow layer revealed left to right by a frame-driven
* clip inset, then the solid layer wiped in the same direction once the
* outline pass completes. Both layers are the same string in the same box,
* so they register exactly and nothing reflows.
*/
export const DrawTitle = ({text, detail, drawFrames = 40, fillDelay = 8, strokeWidth = 2}: DrawTitleProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const draw = interpolate(frame, [4, 4 + drawFrames], [0, 1], {easing: Easing.standard});
const fillStart = 4 + drawFrames + fillDelay;
const fill = interpolate(frame, [fillStart, fillStart + 24], [0, 1], {easing: Easing.standard});
const layer = {
fontFamily: brand.typography.display ?? brand.typography.sans,
fontSize: 148 * scale,
fontWeight: 650,
gridArea: "1 / 1",
letterSpacing: "-0.03em",
lineHeight: 1.04,
whiteSpace: "pre",
} as const;
return (
<Fill
style={{
alignItems: "center",
gap: 34 * scale,
justifyContent: "center",
padding: `${120 * scale}px ${140 * scale}px`,
}}
>
<div style={{display: "grid"}}>
<div
style={{
...layer,
WebkitTextStroke: `${strokeWidth * scale}px ${brand.colors.foreground}`,
WebkitTextFillColor: "transparent",
clipPath: `inset(0 ${(1 - draw) * 100}% 0 0)`,
color: "transparent",
}}
>
{text}
</div>
<div
style={{
...layer,
clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)`,
color: brand.colors.foreground,
opacity: fill,
}}
>
{text}
</div>
</div>
{detail ? (
<div
style={{
color: brand.colors.muted,
fontSize: 34 * scale,
opacity: interpolate(frame, [fillStart + 20, fillStart + 38], [0, 1], {easing: Easing.standard}),
}}
>
{detail}
</div>
) : null}
</Fill>
);
};
videos/components/draw-title/draw-title.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DrawTitle} from "./draw-title";
export default defineComponentPreview({
title: "Draw title",
category: "Typography/Titles",
description: "An outline draws across the line, then fills solid.",
component: DrawTitle,
canvas: {width: 1920, height: 1080, duration: "4s"},
controls: {
text: {type: "text", defaultValue: "Drawn out", maxLength: 20},
detail: {type: "text", defaultValue: "Stroke first, substance second."},
drawFrames: {type: "number", defaultValue: 40, min: 12, max: 90},
fillDelay: {type: "number", defaultValue: 8, min: 0, max: 40},
strokeWidth: {type: "number", defaultValue: 2, min: 1, max: 6},
},
examples: [
{name: "Default", props: {text: "Drawn out", detail: "Stroke first, substance second."}},
{name: "Heavy stroke", props: {text: "Bold", strokeWidth: 5, drawFrames: 60}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DrawTitle} from "../components/draw-title/draw-title";
<Video>
<Scene id="draw-title" duration="5s">
<DrawTitle
text="Drawn out"
detail="Stroke first, substance second."
drawFrames={40}
fillDelay={8}
strokeWidth={2}
/>
</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
- 76 frames
- Exit
- 0 frames
- Reduced motion
- the filled title fades in without the outline pass
- Requires
- sans font
- Content limits
- text ≤ 20
Props
| Prop | Type | Default | Required |
|---|---|---|---|
text | string | — | Yes |
detail | string | — | — |
drawFramesFrames the outline takes to draw across the line. | number | 40 | — |
fillDelayFrames between the outline finishing and the fill starting. | number | 8 | — |
strokeWidthStroke width in design pixels. | number | 2 | — |
Related components
All componentsCaptionsFrame-accurate caption cues for sound-off playback.Character riseCharacters rise with a crisp per-letter stagger.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.Kinetic centerA centered statement assembled from timed word groups.