Meta blobs
Seeded blobs orbiting through one gooey silhouette, merging and parting as they pass.
Customize
import {Scene, Video} from "odori";
import {MetaBlobs} from "../components/meta-blobs/meta-blobs";
<Video>
<Scene id="meta-blobs" duration="6s">
<MetaBlobs
count={5}
size={0.16}
speed={0.6}
threshold={1}
/>
</Scene>
</Video>Installation
pnpm odori add component meta-blobsnpx odori add component meta-blobsbunx odori add component meta-blobsEdit the source in videos/components/meta-blobs/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/meta-blobs/meta-blobs.tsx
import {Fill, randomBetween, useBrand, useFrame, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Blobs that merge and part as they drift, one crisp gooey silhouette.
*
* The goo is a field, not geometry: each blob contributes r²/d² and one tight
* threshold decides inside from outside, which is what makes two blobs neck
* into each other instead of overlapping like circles. The fill is flat on a
* flat ground on purpose — a gradient here reads as lava lamp, a silhouette
* reads as a mark.
*
* Every orbit is seeded by name, so the choreography is identical in the
* preview, in every export, and in every render worker.
*/
export type MetaBlobsProps = {
/** How many blobs share the field. Clamped to 4-7. */
count?: number;
/** Fill of the silhouette. Defaults to the brand accent. */
color?: string;
/** The ground behind it. Defaults to the brand background. */
background?: string;
/** Radius of a typical blob, as a fraction of frame height. */
size?: number;
/** How fast the orbits turn. */
speed?: number;
/** Field level that counts as inside. Lower merges sooner. */
threshold?: number;
};
const BLOBS = 7;
const hexToRgb = (hex: string): [number, number, number] => {
const clean = hex.replace("#", "");
const full = clean.length === 3 ? [...clean].map((c) => c + c).join("") : clean;
const value = Number.parseInt(full.slice(0, 6), 16);
return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
};
const goo = defineShaderEffect({
name: "meta-blobs",
uniforms: {
blob0: vec3Uniform([0.5, 0.5, 0]),
blob1: vec3Uniform([0.5, 0.5, 0]),
blob2: vec3Uniform([0.5, 0.5, 0]),
blob3: vec3Uniform([0.5, 0.5, 0]),
blob4: vec3Uniform([0.5, 0.5, 0]),
blob5: vec3Uniform([0.5, 0.5, 0]),
blob6: vec3Uniform([0.5, 0.5, 0]),
threshold: numberUniform(1),
fill: vec3Uniform([1, 1, 1]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform vec3 blob0;
uniform vec3 blob1;
uniform vec3 blob2;
uniform vec3 blob3;
uniform vec3 blob4;
uniform vec3 blob5;
uniform vec3 blob6;
uniform float threshold;
uniform vec3 fill;
uniform vec3 ground;
/* The classic metaball charge. A blob with radius zero contributes nothing,
which is how the inactive slots switch off without a branch. */
float charge(vec3 blob, vec2 p) {
vec2 d = p - blob.xy;
return (blob.z * blob.z) / max(dot(d, d), 1e-6);
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
uv.x *= resolution.x / resolution.y;
float field = charge(blob0, uv) + charge(blob1, uv) + charge(blob2, uv)
+ charge(blob3, uv) + charge(blob4, uv) + charge(blob5, uv) + charge(blob6, uv);
/* One tight step. The field is steep where blobs meet the threshold, so a
narrow smoothstep gives a clean edge at any resolution; a wide one melts
the silhouette into a glow, which is a different component. */
float inside = smoothstep(threshold - 0.06, threshold + 0.06, field);
odoriColour = vec4(mix(ground, fill, inside), 1.0);
}`,
});
export const MetaBlobs = ({
count = 5,
color,
background,
size = 0.16,
speed = 0.6,
threshold = 1,
}: MetaBlobsProps) => {
const frame = useFrame();
const brand = useBrand();
const {fps, width, height} = useVideo();
const ground = background ?? brand.colors.background;
const ink = color ?? brand.colors.accent;
const aspect = width / height;
const t = (frame / fps) * speed;
const active = Math.max(4, Math.min(BLOBS, Math.round(count)));
/* Each slot owns a seeded orbit: centre, two radii, a rate, and a phase.
Positions are computed here per frame — a pure function of the frame
number — and handed to the shader, which never needs to know about time. */
const values: Record<string, number | readonly number[]> = {
threshold,
fill: hexToRgb(ink),
ground: hexToRgb(ground),
};
for (let index = 0; index < BLOBS; index += 1) {
const key = `meta-blobs:${index}`;
const rate = randomBetween(`${key}:rate`, 0.5, 1.1);
const phase = randomBetween(`${key}:phase`, 0, Math.PI * 2);
const x =
randomBetween(`${key}:cx`, 0.32, 0.68) * aspect +
Math.cos(t * rate + phase) * randomBetween(`${key}:ox`, 0.08, 0.24);
const y =
randomBetween(`${key}:cy`, 0.34, 0.66) +
Math.sin(t * rate * 0.85 + phase) * randomBetween(`${key}:oy`, 0.08, 0.2);
const radius = index < active ? size * randomBetween(`${key}:r`, 0.7, 1.25) : 0;
values[`blob${index}`] = [x, y, radius];
}
return (
<EffectSurface effects={[goo(values)]}>
<Fill style={{background: ground, height, width}} />
</EffectSurface>
);
};
videos/components/meta-blobs/meta-blobs.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {MetaBlobs} from "./meta-blobs";
export default defineComponentPreview({
title: "Meta blobs",
category: "Motion",
description: "Seeded blobs orbiting through one gooey silhouette, merging and parting as they pass.",
component: MetaBlobs,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
count: {type: "number", defaultValue: 5, min: 4, max: 7, step: 1},
size: {type: "number", defaultValue: 0.16, min: 0.06, max: 0.3, step: 0.01},
speed: {type: "number", defaultValue: 0.6, min: 0, max: 2, step: 0.05},
threshold: {type: "number", defaultValue: 1, min: 0.5, max: 2, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Crowded", props: {count: 7, size: 0.12, speed: 0.9}},
{name: "Ink on paper", props: {color: "#141414", background: "#f4f1ea", threshold: 1.2}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {MetaBlobs} from "../components/meta-blobs/meta-blobs";
<Video>
<Scene id="meta-blobs" duration="6s">
<MetaBlobs
count={5}
size={0.16}
speed={0.6}
threshold={1}
/>
</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
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the silhouette holds still
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
countHow many blobs share the field. Clamped to 4-7. | number | 5 | — |
colorFill of the silhouette. Defaults to the brand accent. | string | — | — |
backgroundThe ground behind it. Defaults to the brand background. | string | — | — |
sizeRadius of a typical blob, as a fraction of frame height. | number | 0.16 | — |
speedHow fast the orbits turn. | number | 0.6 | — |
thresholdField level that counts as inside. Lower merges sooner. | number | 1 | — |
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.