Halftone reveal
Content revealed through expanding dots.
Customize
import {Scene, Video} from "odori";
import {HalftoneReveal} from "../components/halftone-reveal/halftone-reveal";
<Video>
<Scene id="halftone-reveal" duration="2s">
<HalftoneReveal
at={0}
durationInFrames={32}
pitch={18}
angle={15}
/>
</Scene>
</Video>Installation
pnpm odori add component halftone-revealnpx odori add component halftone-revealbunx odori add component halftone-revealEdit the source in videos/components/halftone-reveal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/halftone-reveal/halftone-reveal.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useDesignScale, useFrame, useVideo} from "odori";
export type HalftoneRevealProps = {
/** What arrives through the dots. */
children?: ReactNode;
/** Frame the dots begin to grow. */
at?: number;
/** Frames until the dots have swallowed their cells. */
durationInFrames?: number;
/** Distance between dot centres, in design pixels. */
pitch?: number;
/** Screen angle in degrees, the way a print screen is rotated. */
angle?: number;
};
/**
* Children arriving through growing halftone dots.
*
* The transition twin of the halftone print: a repeating radial-gradient dot
* screen is used as a mask on the content, and the dot radius animates from
* nothing to past the point where neighbouring dots fuse, so the scene arrives
* as ink filling a screen rather than as a fade. The screen is rotated like a
* print screen — an unrotated one lines up with the pixel grid and reads as a
* mistake — by rotating a masked wrapper and counter-rotating the content
* inside it, which is the only way CSS lets a gradient tile sit at an angle.
*/
export const HalftoneReveal = ({
children,
at = 0,
durationInFrames = 32,
pitch = 18,
angle = 15,
}: HalftoneRevealProps) => {
const frame = useFrame();
const scale = useDesignScale();
const {width, height} = useVideo();
const cell = Math.max(4, pitch * scale);
const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});
/* Dots cover their cell once the radius passes cell * √2 / 2, so 0.75 lands
just beyond full bleed and the final frames are genuinely solid. */
const dot = progress * cell * 0.75;
/* The rotated wrapper must cover the frame's corners at any angle, and the
diagonal is the size that does. */
const bleed = Math.ceil(Math.hypot(width, height));
const mask = `radial-gradient(circle, #000 ${dot}px, transparent ${dot}px)`;
return (
<Fill>
<div
style={{
height: bleed,
left: "50%",
maskImage: mask,
maskPosition: "center",
maskRepeat: "repeat",
maskSize: `${cell}px ${cell}px`,
position: "absolute",
top: "50%",
transform: `translate(-50%, -50%) rotate(${angle}deg)`,
WebkitMaskImage: mask,
WebkitMaskPosition: "center",
WebkitMaskRepeat: "repeat",
WebkitMaskSize: `${cell}px ${cell}px`,
width: bleed,
}}
>
<div
style={{
alignItems: "center",
display: "flex",
height: "100%",
justifyContent: "center",
transform: `rotate(${-angle}deg)`,
width: "100%",
}}
>
{/* Back at the true frame size and orientation, so the content never
knows it travelled through two rotations. */}
<div style={{height, position: "relative", width}}>{children}</div>
</div>
</div>
</Fill>
);
};
videos/components/halftone-reveal/halftone-reveal.preview.tsx
import {Fill, useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {HalftoneReveal} from "./halftone-reveal";
const Card = () => {
const brand = useBrand();
return (
<Fill
style={{
alignItems: "center",
background: brand.colors.surface,
color: brand.colors.foreground,
display: "flex",
fontFamily: brand.typography.display ?? brand.typography.sans,
fontSize: 140,
fontWeight: 700,
justifyContent: "center",
letterSpacing: "-0.04em",
}}
>
In print
</Fill>
);
};
export default defineComponentPreview({
title: "Halftone reveal",
category: "Motion/Transitions",
description: "Content revealed through expanding dots.",
component: HalftoneReveal,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
at: {type: "number", defaultValue: 0, min: 0, max: 60},
durationInFrames: {type: "number", defaultValue: 32, min: 8, max: 90},
pitch: {type: "number", defaultValue: 18, min: 6, max: 60, step: 2},
angle: {type: "number", defaultValue: 15, min: 0, max: 45, step: 1},
},
examples: [
{name: "Default", props: {children: <Card />}},
{name: "Coarse screen", props: {children: <Card />, pitch: 44, durationInFrames: 44}},
{name: "Straight and fine", props: {children: <Card />, pitch: 10, angle: 0}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {HalftoneReveal} from "../components/halftone-reveal/halftone-reveal";
<Video>
<Scene id="halftone-reveal" duration="2s">
<HalftoneReveal
at={0}
durationInFrames={32}
pitch={18}
angle={15}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Motion
- Recommended duration
- 60 frames · 2s
- Minimum duration
- 32 frames · 1.07s
- Entrance
- 32 frames
- Exit
- 0 frames
- Reduced motion
- dots shown at full bleed, content present from the first frame
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenWhat arrives through the dots. | ReactNode | — | — |
atFrame the dots begin to grow. | number | 0 | — |
durationInFramesFrames until the dots have swallowed their cells. | number | 32 | — |
pitchDistance between dot centres, in design pixels. | number | 18 | — |
angleScreen angle in degrees, the way a print screen is rotated. | number | 15 | — |
Related components
All componentsCamera stageAn animated camera that moves through your scene.Cell revealTiles slide into place to reveal your content.Charged borderA moving glow around a rounded frame.Dissolve revealA noise threshold sweeps across the frame, so content arrives as a torn edge.Gradient fieldA looping gradient in your brand colors.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.