Masonry assemble
A masonry grid that lays itself out tile by tile on a seeded stagger, then holds.
Customize
import {Scene, Video} from "odori";
import {MasonryAssemble} from "../components/masonry-assemble/masonry-assemble";
<Video>
<Scene id="masonry-assemble" duration="5s">
<MasonryAssemble
columns={3}
stagger={5}
gap={20}
/>
</Scene>
</Video>Installation
pnpm odori add component masonry-assemblenpx odori add component masonry-assemblebunx odori add component masonry-assembleEdit the source in videos/components/masonry-assemble/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/masonry-assemble/masonry-assemble.tsx
import {Children, type ReactNode} from "react";
import {Easing, Fill, interpolate, random, randomBetween, useBrand, useDesignScale, useFrame} from "odori";
export type MasonryAssembleProps = {
/** The tiles, in the order they should appear. */
children?: ReactNode;
columns?: number;
/** Frames between one tile starting its rise and the next. */
stagger?: number;
/** Gutter between tiles, in design pixels. */
gap?: number;
};
/**
* A masonry grid that builds itself, then holds.
*
* A grid that is simply there reads as a screenshot of a website. One that
* assembles — each tile rising and fading in, in roughly the order given —
* reads as a collection being laid out for you, and then it has the good
* sense to stop. The stagger carries seeded jitter because tiles arriving on
* exact multiples sound like a metronome; the jitter is from the seeded
* generator, so every render lays the same bricks.
*
* Columns are filled round-robin, not by measured height. Children are opaque
* boxes here, and even counts read as balanced when heights vary within
* reason; a caller with one towering tile should order children with that in
* mind.
*/
export const MasonryAssemble = ({children, columns = 3, stagger = 5, gap = 20}: MasonryAssembleProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const supplied = Children.toArray(children);
const tiles =
supplied.length > 0
? supplied
: Array.from({length: 9}, (_, index) => (
<div
key={index}
style={{
background: `linear-gradient(${140 + index * 25}deg, color-mix(in srgb, ${brand.colors.accent} ${18 + (index % 3) * 14}%, ${brand.colors.surface}), ${brand.colors.surface})`,
border: `1px solid ${brand.colors.border}`,
borderRadius: 20 * scale,
height: randomBetween(["masonry-tile", index], 180, 380) * scale,
}}
/>
));
const cols = Math.max(1, columns);
const lanes: Array<Array<{tile: ReactNode; order: number}>> = Array.from({length: cols}, () => []);
tiles.forEach((tile, order) => lanes[order % cols]?.push({tile, order}));
return (
<Fill style={{alignItems: "center", justifyContent: "center"}}>
<div style={{alignItems: "flex-start", display: "flex", gap: gap * scale, width: "78%"}}>
{lanes.map((lane, laneIndex) => (
<div key={laneIndex} style={{display: "flex", flex: 1, flexDirection: "column", gap: gap * scale}}>
{lane.map(({tile, order}) => {
const start = order * stagger + random(["assemble", order]) * stagger * 1.5;
const rise = interpolate(frame, [start, start + 16], [0, 1], {easing: Easing.standard});
return (
<div key={order} style={{opacity: rise, transform: `translateY(${(1 - rise) * 44 * scale}px)`}}>
{tile}
</div>
);
})}
</div>
))}
</div>
</Fill>
);
};
videos/components/masonry-assemble/masonry-assemble.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {MasonryAssemble} from "./masonry-assemble";
const hues = ["#233a63", "#2c2354", "#14324a", "#31283e", "#1f3d33", "#3d2a1f"];
const heights = [240, 340, 200, 300, 260, 380, 220, 320];
const Tile = ({index}: {index: number}) => (
<div
style={{
background: `linear-gradient(${130 + index * 30}deg, ${hues[index % hues.length]}, #0b1120)`,
borderRadius: 20,
height: heights[index % heights.length],
}}
/>
);
export default defineComponentPreview({
title: "Masonry assemble",
category: "Media/Footage",
description: "A masonry grid that lays itself out tile by tile on a seeded stagger, then holds.",
component: MasonryAssemble,
canvas: {width: 1920, height: 1080, duration: "5s"},
controls: {
columns: {type: "number", defaultValue: 3, min: 2, max: 4},
stagger: {type: "number", defaultValue: 5, min: 1, max: 15},
gap: {type: "number", defaultValue: 20, min: 8, max: 48},
},
examples: [
{name: "Default", props: {}},
{
name: "Gallery",
props: {children: Array.from({length: 8}, (_, index) => <Tile key={index} index={index} />)},
},
{
name: "Slow build, two lanes",
props: {
columns: 2,
stagger: 10,
children: Array.from({length: 6}, (_, index) => <Tile key={index} index={index} />),
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {MasonryAssemble} from "../components/masonry-assemble/masonry-assemble";
<Video>
<Scene id="masonry-assemble" duration="5s">
<MasonryAssemble
columns={3}
stagger={5}
gap={20}
/>
</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
- 150 frames · 5s
- Minimum duration
- 90 frames · 3s
- Entrance
- 70 frames
- Exit
- 0 frames
- Reduced motion
- tiles fade in together without rising
- Requires
- Odori
- Content limits
- items ≤ 12
Props
| Prop | Type | Default | Required |
|---|---|---|---|
childrenThe tiles, in the order they should appear. | ReactNode | — | — |
columns | number | 3 | — |
staggerFrames between one tile starting its rise and the next. | number | 5 | — |
gapGutter between tiles, in design pixels. | number | 20 | — |
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.