Mask reveal
Clipped media and typography, revealed at full strength.
Customize
import {Scene, Video} from "odori";
import {MaskReveal} from "../components/mask-reveal/mask-reveal";
<Video>
<Scene id="mask-reveal" duration="2s">
<MaskReveal
from="left"
at={0}
durationInFrames={24}
feather={6}
/>
</Scene>
</Video>Installation
pnpm odori add component mask-revealnpx odori add component mask-revealbunx odori add component mask-revealEdit the source in videos/components/mask-reveal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/mask-reveal/mask-reveal.tsx
import type {ReactNode} from "react";
import {Easing, Fill, interpolate, useFrame} from "odori";
export type MaskRevealProps = {
children?: ReactNode;
/** Which edge the mask opens from. */
from?: "left" | "right" | "top" | "bottom" | "center";
/** Frame the reveal begins. */
at?: number;
durationInFrames?: number;
/** Soft edge on the mask, in percent of the frame. Zero is a hard wipe. */
feather?: number;
};
/**
* Content clipped in rather than faded in.
*
* A fade changes what the content *is* — half-opacity type is a different
* colour, and a half-opacity screenshot is a lie about the product. A mask
* leaves every visible pixel at full strength and only changes how much of it
* you can see, which is why typography and UI should arrive this way.
*
* Built on a gradient mask rather than a clip path so the edge can be soft,
* and both `mask` and `WebkitMask` are set because the export browser and the
* preview browser are the same engine but the property is not yet unprefixed
* everywhere it renders.
*/
export const MaskReveal = ({children, from = "left", at = 0, durationInFrames = 24, feather = 6}: MaskRevealProps) => {
const frame = useFrame();
const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});
const angle = {left: "to right", right: "to left", top: "to bottom", bottom: "to top", center: "to right"}[from];
// The visible edge travels past 100 so the feather clears the frame too.
const edge = progress * (100 + feather * 2);
const mask =
from === "center"
? `radial-gradient(circle at 50% 50%, #000 ${edge}%, transparent ${edge + feather}%)`
: `linear-gradient(${angle}, #000 ${Math.max(0, edge - feather)}%, transparent ${edge}%)`;
return (
<Fill
style={{
mask,
WebkitMask: mask,
}}
>
{children}
</Fill>
);
};
videos/components/mask-reveal/mask-reveal.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {MaskReveal} from "./mask-reveal";
const Content = () => (
<div
style={{
alignItems: "center",
color: "#f5f5f5",
display: "flex",
flexDirection: "column",
fontFamily: "ui-sans-serif, system-ui",
gap: 24,
inset: 0,
justifyContent: "center",
position: "absolute",
textAlign: "center",
}}
>
<strong style={{fontSize: 104, letterSpacing: "-0.045em"}}>Definitions</strong>
<span style={{color: "#8f8f8f", fontSize: 38}}>not timelines</span>
</div>
);
export default defineComponentPreview({
title: "Mask reveal",
category: "Motion",
description: "Clipped media and typography, revealed at full strength.",
component: MaskReveal,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
from: {type: "select", defaultValue: "left", options: ["left", "right", "top", "bottom", "center"]},
at: {type: "number", defaultValue: 0, min: 0, max: 60},
durationInFrames: {type: "number", defaultValue: 24, min: 8, max: 90},
feather: {type: "number", defaultValue: 6, min: 0, max: 40},
},
examples: [
{name: "Default", props: {children: <Content />}},
{name: "From below", props: {from: "bottom", children: <Content />}},
{name: "Hard edge", props: {feather: 0, children: <Content />}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {MaskReveal} from "../components/mask-reveal/mask-reveal";
<Video>
<Scene id="mask-reveal" duration="2s">
<MaskReveal
from="left"
at={0}
durationInFrames={24}
feather={6}
/>
</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
- 24 frames · 0.8s
- Entrance
- 24 frames
- Exit
- 0 frames
- Reduced motion
- the content is fully unmasked from the first frame
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | — |
fromWhich edge the mask opens from. | "left" | "right" | "top" | "bottom" | "center" | "left" | — |
atFrame the reveal begins. | number | 0 | — |
durationInFrames | number | 24 | — |
featherSoft edge on the mask, in percent of the frame. Zero is a hard wipe. | number | 6 | — |
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.Halftone revealContent revealed through expanding dots.