Cell reveal
Tiles slide into place to reveal your content.
Customize
import {Scene, Video} from "odori";
import {CellReveal} from "../components/cell-reveal/cell-reveal";
<Video>
<Scene id="cell-reveal" duration="3s">
<CellReveal
cells={9}
hold={0.5}
scatter={0.22}
direction="in"
/>
</Scene>
</Video>Installation
pnpm odori add component cell-revealnpx odori add component cell-revealbunx odori add component cell-revealEdit the source in videos/components/cell-reveal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/cell-reveal/cell-reveal.tsx
import type {ReactNode} from "react";
import {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";
/**
* Content assembling out of shattered cells.
*
* The frame is divided into seeded organic cells. Each cell starts shifted
* off its true position and slightly scaled, and slides home on its own
* seeded schedule across the scene, so the picture arrives the way a pane of
* glass would un-break. Run it in reverse, `direction: "out"`, and a scene
* leaves by shattering, which is a harder cut than a fade and earns it.
*
* The reveal is driven by scene progress: however long the scene, the last
* cell lands as `hold` runs out.
*/
export type CellRevealProps = {
children: ReactNode;
/** How many cells across the frame. */
cells?: number;
/** How much of the scene the assembly takes. */
hold?: number;
/** How far a cell starts from home, as a fraction of the frame. */
scatter?: number;
/** "in" assembles, "out" shatters. */
direction?: "in" | "out";
};
const reveal = defineShaderEffect({
name: "cell-reveal",
uniforms: {
cells: numberUniform(9),
hold: numberUniform(0.5),
scatter: numberUniform(0.22),
outward: numberUniform(0),
},
fragmentShader: `
uniform float cells;
uniform float hold;
uniform float scatter;
uniform float outward;
/* Which seeded site this pixel belongs to: nearest of nine neighbours'
jittered points, the classic cellular partition. */
vec2 site(vec2 cell) {
return cell + vec2(hash(cell * 1.13 + 4.0), hash(cell * 2.71 + 9.0));
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
vec2 field = uv * aspect * cells;
vec2 best = vec2(0.0);
float bestDistance = 1e9;
vec2 base = floor(field);
for (float around = 0.0; around < 9.0; around += 1.0) {
vec2 cell = base + vec2(mod(around, 3.0) - 1.0, floor(around / 3.0) - 1.0);
vec2 p = site(cell);
float d = dot(field - p, field - p);
if (d < bestDistance) { bestDistance = d; best = cell; }
}
/* Each cell's schedule: a seeded start inside the hold, eased home. */
float seed = hash(best * 3.31 + 17.0);
float t = clamp(progress / max(hold, 0.0001), 0.0, 1.0);
float local = clamp((t - seed * 0.6) / 0.4, 0.0, 1.0);
local = local * local * (3.0 - 2.0 * local);
float away = outward > 0.5 ? local : 1.0 - local;
/* The cell's own drift direction and spin, from its seed. */
vec2 drift = (vec2(hash(best + 23.0), hash(best + 41.0)) - 0.5) * 2.0;
vec2 shifted = uv + drift * scatter * away;
float grow = 1.0 + away * 0.15;
vec2 centreOfCell = (site(best) / cells) / aspect;
vec2 sampleAt = centreOfCell + (shifted - centreOfCell) * grow;
vec4 colour = tex(clamp(sampleAt, 0.0, 1.0));
/* Dim with distance from home, and gone entirely once fully away, so an
assembling cell reads as arriving and a shattering one actually leaves. */
colour *= (1.0 - away * 0.5) * (1.0 - smoothstep(0.92, 1.0, away));
odoriColour = colour;
}`,
});
export const CellReveal = ({children, cells = 9, hold = 0.5, scatter = 0.22, direction = "in"}: CellRevealProps) => (
<EffectSurface effects={[reveal({cells, hold, scatter, outward: direction === "out" ? 1 : 0})]}>
{children}
</EffectSurface>
);
videos/components/cell-reveal/cell-reveal.preview.tsx
import {Fill, useBrand} from "odori";
import {defineComponentPreview} from "odori/preview";
import {CellReveal} from "./cell-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: 160,
fontWeight: 700,
justifyContent: "center",
}}
>
Assembled
</Fill>
);
};
export default defineComponentPreview({
title: "Cell reveal",
category: "Motion/Transitions",
description: "Tiles slide into place to reveal your content.",
component: CellReveal,
canvas: {width: 1920, height: 1080, duration: "3s"},
controls: {
cells: {type: "number", defaultValue: 9, min: 3, max: 24, step: 1},
hold: {type: "number", defaultValue: 0.5, min: 0.2, max: 1, step: 0.05},
scatter: {type: "number", defaultValue: 0.22, min: 0.05, max: 0.6, step: 0.01},
direction: {type: "select", defaultValue: "in", options: ["in", "out"]},
},
examples: [
{name: "Assemble", props: {children: <Card />}},
{name: "Shatter out", props: {children: <Card />, direction: "out"}},
{name: "Fine shards", props: {children: <Card />, cells: 20, scatter: 0.35}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CellReveal} from "../components/cell-reveal/cell-reveal";
<Video>
<Scene id="cell-reveal" duration="3s">
<CellReveal
cells={9}
hold={0.5}
scatter={0.22}
direction="in"
/>
</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 scatter low so cells barely travel
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
children | ReactNode | — | Yes |
cellsHow many cells across the frame. | number | 9 | — |
holdHow much of the scene the assembly takes. | number | 0.5 | — |
scatterHow far a cell starts from home, as a fraction of the frame. | number | 0.22 | — |
direction"in" assembles, "out" shatters. | "in" | "out" | "in" | — |
Related components
All componentsCamera stageAn animated camera that moves through your scene.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.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.