Dissolve reveal
A noise threshold sweeps across the frame, so content arrives as a torn edge.
Customize
import {Scene, Video} from "odori";
import {DissolveReveal} from "../components/dissolve-reveal/dissolve-reveal";
<Video>
<Scene id="dissolve-reveal" duration="3s">
<DissolveReveal
hold={0.4}
grain={9}
softness={0.22}
direction="up"
/>
</Scene>
</Video>Installation
pnpm odori add component dissolve-revealnpx odori add component dissolve-revealbunx odori add component dissolve-revealEdit the source in videos/components/dissolve-reveal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dissolve-reveal/dissolve-reveal.tsx
import type {ReactNode} from "react";
import {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";
/**
* Anything you put inside it, arriving through a dissolve.
*
* A cut usually needs its content to appear, and a fade is the answer everyone
* reaches for because it is the one CSS gives away. This is the other one: a
* noise threshold sweeps across the frame, so the picture comes in as a torn
* edge rather than a uniform brightening.
*
* The sweep runs on `progress`, so it always fills exactly the scene it is in
* however long that scene is. `hold` keeps it complete for the rest of the
* shot once it has arrived.
*/
export type DissolveRevealProps = {
children: ReactNode;
/** How much of the scene the arrival takes. 0.4 is the first two fifths. */
hold?: number;
/** Size of the torn edge. Small is grainy, large is cloudy. */
grain?: number;
/** Width of the soft edge between arrived and not. */
softness?: number;
/** Direction the sweep travels. */
direction?: "up" | "down" | "left" | "right";
};
const AXIS: Record<string, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
const dissolve = defineShaderEffect({
name: "dissolve-reveal",
uniforms: {
hold: numberUniform(0.4),
grain: numberUniform(9),
softness: numberUniform(0.22),
axisX: numberUniform(0),
axisY: numberUniform(1),
},
fragmentShader: `
uniform float hold;
uniform float grain;
uniform float softness;
uniform float axisX;
uniform float axisY;
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;
/* Where along the sweep this pixel sits, 0 at the leading edge. */
vec2 axis = vec2(axisX, axisY);
float along = dot(uv - 0.5, axis) * 0.5 + 0.5;
/* The threshold each pixel has to beat: its position plus some noise, so the
boundary is torn rather than straight. */
float threshold = along * 0.7 + value(uv * grain) * 0.3;
float swept = clamp(progress / max(hold, 0.0001), 0.0, 1.0);
/* A pixel is in once the sweep passes the threshold it was given. Padded at
both ends by the softness so the frame is genuinely empty at 0 and
genuinely full at 1, rather than starting or finishing part way. */
float front = swept * (1.0 + softness * 2.0) - softness;
float arrived = smoothstep(threshold - softness, threshold + softness, front);
odoriColour = tex(uv) * arrived;
}`,
});
export const DissolveReveal = ({
children,
hold = 0.4,
grain = 9,
softness = 0.22,
direction = "up",
}: DissolveRevealProps) => {
const [axisX, axisY] = AXIS[direction] ?? AXIS.up;
return (
<EffectSurface effects={[dissolve({hold, grain, softness, axisX, axisY})]}>{children}</EffectSurface>
);
};
videos/components/dissolve-reveal/dissolve-reveal.preview.tsx
import {Fill, useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {DissolveReveal} from "./dissolve-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: 150,
fontWeight: 700,
justifyContent: "center",
}}
>
Arrived
</Fill>
);
};
export default defineComponentPreview({
title: "Dissolve reveal",
category: "Motion/Transitions",
description: "A noise threshold sweeps across the frame, so content arrives as a torn edge.",
component: DissolveReveal,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
hold: {type: "number", defaultValue: 0.4, min: 0.1, max: 1, step: 0.05},
grain: {type: "number", defaultValue: 9, min: 2, max: 40, step: 1},
softness: {type: "number", defaultValue: 0.22, min: 0.02, max: 0.6, step: 0.02},
direction: {type: "select", defaultValue: "up", options: ["up", "down", "left", "right"]},
},
examples: [
{name: "Up", props: {children: <Card />}},
{name: "Fine grain", props: {children: <Card />, grain: 30, softness: 0.1}},
{name: "Cloudy", props: {children: <Card />, grain: 3, softness: 0.45, direction: "right"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DissolveReveal} from "../components/dissolve-reveal/dissolve-reveal";
<Video>
<Scene id="dissolve-reveal" duration="3s">
<DissolveReveal
hold={0.4}
grain={9}
softness={0.22}
direction="up"
/>
</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
- 90 frames · 3s
- Minimum duration
- 24 frames · 0.8s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set hold to a small value so content is present almost at once
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | Yes |
holdHow much of the scene the arrival takes. 0.4 is the first two fifths. | number | 0.4 | — |
grainSize of the torn edge. Small is grainy, large is cloudy. | number | 9 | — |
softnessWidth of the soft edge between arrived and not. | number | 0.22 | — |
directionDirection the sweep travels. | "up" | "down" | "left" | "right" | "up" | — |
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.Gradient fieldA looping gradient in your brand colors.Halftone revealContent revealed through expanding dots.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.