Halftone print
Text revealed as a pattern of printed dots.
Customize
import {Scene, Video} from "odori";
import {HalftonePrint} from "../components/halftone-print/halftone-print";
<Video>
<Scene id="halftone-print" duration="6s">
<HalftonePrint
text="LAUNCH"
subtitle=""
pitch={18}
angle={15}
growFrames={44}
/>
</Scene>
</Video>Installation
pnpm odori add component halftone-printnpx odori add component halftone-printbunx odori add component halftone-printEdit the source in videos/components/halftone-print/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/halftone-print/halftone-print.tsx
import {Easing, Fill, interpolate, useBrand, useCanvas, useVideo} from "odori";
export type HalftonePrintProps = {
/** The word rendered as dots. */
text: string;
/** The line under it. */
subtitle?: string;
/** Distance between dot centres, in composition pixels. */
pitch?: number;
/** Screen angle in degrees, the way a print screen is rotated. */
angle?: number;
/** Frames the dots take to grow to full size. */
growFrames?: number;
/** Ink colour. Defaults to the brand's accent. */
color?: string;
};
/**
* A word screened into halftone dots.
*
* The grid is rotated the way a print screen is, because an unrotated screen
* lines its dots up with the pixel grid and reads as a mistake rather than as
* print. Dots grow from the centre of the word outwards, so the shape arrives
* as ink spreading rather than as an image fading up.
*/
export const HalftonePrint = ({
text,
subtitle,
pitch = 18,
angle = 15,
growFrames = 44,
color,
}: HalftonePrintProps) => {
const {width, height} = useVideo();
const brand = useBrand();
const ink = color ?? brand.colors.accent;
const canvas = useCanvas(
(context, {frame, width: w, height: h}) => {
context.clearRect(0, 0, w, h);
const sample = 4;
const columns = Math.floor(w / sample);
const rows = Math.floor(h / sample);
const source = document.createElement("canvas");
source.width = columns;
source.height = rows;
const scratch = source.getContext("2d", {willReadFrequently: true});
if (!scratch) return;
scratch.fillStyle = "#000000";
scratch.fillRect(0, 0, columns, rows);
scratch.fillStyle = "#ffffff";
scratch.textAlign = "center";
scratch.textBaseline = "middle";
const headline = Math.floor((columns / Math.max(4, text.length)) * 1.6);
scratch.font = `700 ${headline}px ${brand.typography.sans}`;
scratch.fillText(text, columns / 2, rows / 2 - (subtitle ? headline * 0.3 : 0), columns * 0.9);
if (subtitle) {
const small = Math.max(4, Math.floor(headline * 0.2));
scratch.font = `500 ${small}px ${brand.typography.sans}`;
scratch.fillText(subtitle, columns / 2, rows / 2 + headline * 0.55, columns * 0.8);
}
const {data} = scratch.getImageData(0, 0, columns, rows);
const grow = interpolate(frame, [8, 8 + growFrames], [0, 1], {easing: Easing.standard});
const radians = (angle * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const reach = Math.hypot(w, h) / 2;
context.fillStyle = ink;
// Walk the rotated grid rather than the pixel grid, which is what makes
// the screen read as print instead of as a mosaic.
const span = Math.ceil(Math.hypot(w, h) / pitch) + 2;
for (let gridY = -span; gridY <= span; gridY += 1) {
for (let gridX = -span; gridX <= span; gridX += 1) {
const x = w / 2 + (gridX * cos - gridY * sin) * pitch;
const y = h / 2 + (gridX * sin + gridY * cos) * pitch;
if (x < -pitch || y < -pitch || x > w + pitch || y > h + pitch) continue;
const sx = Math.floor((x / w) * columns);
const sy = Math.floor((y / h) * rows);
if (sx < 0 || sy < 0 || sx >= columns || sy >= rows) continue;
const luminance = data[(sy * columns + sx) * 4] / 255;
if (luminance <= 0.03) continue;
// Ink spreads outwards from the middle of the word.
const distance = Math.hypot(x - w / 2, y - h / 2) / reach;
const arrived = interpolate(grow, [distance * 0.7, distance * 0.7 + 0.35], [0, 1], {
easing: Easing.standard,
});
if (arrived <= 0.01) continue;
const radius = (pitch / 2) * Math.sqrt(luminance) * arrived;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
}
},
[text, subtitle, pitch, angle, growFrames, ink, brand.typography.sans],
);
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center"}}>
<canvas ref={canvas} width={width} height={height} style={{height: "100%", width: "100%"}} />
</Fill>
);
};
videos/components/halftone-print/halftone-print.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {HalftonePrint} from "./halftone-print";
export default defineComponentPreview({
title: "Halftone print",
category: "Media/Treatments",
description: "Text revealed as a pattern of printed dots.",
component: HalftonePrint,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
text: {type: "text", defaultValue: "LAUNCH", maxLength: 16},
subtitle: {type: "text", defaultValue: "", maxLength: 32},
pitch: {type: "number", defaultValue: 18, min: 8, max: 48, step: 1},
angle: {type: "number", defaultValue: 15, min: 0, max: 90, step: 5},
growFrames: {type: "number", defaultValue: 44, min: 10, max: 120, step: 4},
},
examples: [
{name: "Default", props: {}},
{name: "Coarse", props: {pitch: 30, text: "0.0.3"}},
{name: "With a line", props: {subtitle: "out today", pitch: 14}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {HalftonePrint} from "../components/halftone-print/halftone-print";
<Video>
<Scene id="halftone-print" duration="6s">
<HalftonePrint
text="LAUNCH"
subtitle=""
pitch={18}
angle={15}
growFrames={44}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 12 frames
- Exit
- 10 frames
- Reduced motion
- dots shown at full size, no spread
- Requires
- sans font, mono font
- Content limits
- text ≤ 16, subtitle ≤ 32
Props
| Prop | Type | Default | Required |
|---|---|---|---|
textThe word rendered as dots. | string | — | Yes |
subtitleThe line under it. | string | — | — |
pitchDistance between dot centres, in composition pixels. | number | 18 | — |
angleScreen angle in degrees, the way a print screen is rotated. | number | 15 | — |
growFramesFrames the dots take to grow to full size. | number | 44 | — |
colorInk colour. Defaults to the brand's accent. | string | — | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.CarouselA paced media sequence with stable aspect handling.CRT terminalA retro terminal with curved glass and glowing text.Glare sweepA light reflection that sweeps across your content.