Bead field
A fixed grid of dots that swell as two drifting ripples cross them.
Customize
import {Scene, Video} from "odori";
import {BeadField} from "../components/bead-field/bead-field";
<Video>
<Scene id="bead-field" duration="8s">
<BeadField
spacing={46}
amplitude={0.6}
speed={1}
/>
</Scene>
</Video>Installation
pnpm odori add component bead-fieldnpx odori add component bead-fieldbunx odori add component bead-fieldEdit the source in videos/components/bead-field/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/bead-field/bead-field.tsx
import {Fill, useBrand, useDesignScale, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* A field of beads, sized by the ripples crossing it.
*
* Nothing here moves. The dots sit on a fixed pitch and never leave it; what
* changes is how big each one is, and the size comes from two ripple sources
* drifting across the frame on periods that do not divide into each other.
* A grid where the dots travel reads as particles, which is a different and
* busier idea; a grid where they only swell reads as a surface responding to
* something, which is what a background is for.
*
* The pitch is a design length, so the beads stay the same apparent size in a
* vertical cut as in a wide one.
*/
export type BeadFieldProps = {
/** Bead and ground. Defaults to the brand's foreground on its background. */
colors?: [string, string];
/** Distance between bead centres, in design pixels. */
spacing?: number;
/** How much the ripples change a bead's size, 0 to 1. */
amplitude?: number;
/** How fast the ripples cross. */
speed?: number;
};
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 beads = defineShaderEffect({
name: "bead-field",
uniforms: {
spacing: numberUniform(46),
amplitude: numberUniform(0.6),
speed: numberUniform(1),
ink: vec3Uniform([1, 1, 1]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform float spacing;
uniform float amplitude;
uniform float speed;
uniform vec3 ink;
uniform vec3 ground;
void main() {
float pitch = max(6.0, spacing);
vec2 id = floor(gl_FragCoord.xy / pitch);
vec2 local = (fract(gl_FragCoord.xy / pitch) - 0.5) * pitch;
/* Every bead is asked about the ripple at its own centre, not at the pixel
being drawn, so a single bead is one size all over rather than a smear of
the field it sits in. */
vec2 centre = (id + 0.5) * pitch / resolution;
float aspect = resolution.x / resolution.y;
float t = seconds * speed;
vec2 first = vec2(0.5 + 0.36 * sin(t * 0.37), 0.5 + 0.28 * cos(t * 0.29));
vec2 second = vec2(0.5 + 0.42 * cos(t * 0.23), 0.5 + 0.31 * sin(t * 0.41));
float toFirst = length((centre - first) * vec2(aspect, 1.0));
float toSecond = length((centre - second) * vec2(aspect, 1.0));
/* Two trains, and the ripple decays with distance from its source, which is
what keeps the far corners quiet instead of uniformly busy. */
float wave =
sin(toFirst * 26.0 - t * 2.2) * exp(-toFirst * 1.1) * 0.62 +
sin(toSecond * 19.0 - t * 1.7) * exp(-toSecond * 0.9) * 0.44;
float radius = pitch * (0.10 + amplitude * 0.26 * clamp(wave * 0.5 + 0.5, 0.0, 1.0));
float mask = 1.0 - smoothstep(radius - 1.2, radius + 1.2, length(local));
odoriColour = vec4(mix(ground, ink, mask * 0.9), 1.0);
}`,
});
export const BeadField = ({colors, spacing = 46, amplitude = 0.6, speed = 1}: BeadFieldProps) => {
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const [ink, ground] = colors ?? [brand.colors.foreground, brand.colors.background];
return (
<EffectSurface
effects={[
beads({
spacing: Math.max(6, spacing * scale),
amplitude,
speed,
ink: hexToRgb(ink),
ground: hexToRgb(ground),
}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/bead-field/bead-field.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {BeadField} from "./bead-field";
export default defineComponentPreview({
title: "Bead field",
category: "Backgrounds",
description: "A fixed grid of dots that swell as two drifting ripples cross them.",
component: BeadField,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
spacing: {type: "number", defaultValue: 46, min: 12, max: 120, step: 2},
amplitude: {type: "number", defaultValue: 0.6, min: 0, max: 1, step: 0.05},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
},
examples: [
{name: "Brand", props: {}},
{name: "Dense", props: {spacing: 22, amplitude: 0.75}},
{name: "Slow tide", props: {spacing: 70, speed: 0.4, amplitude: 0.9}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {BeadField} from "../components/bead-field/bead-field";
<Video>
<Scene id="bead-field" duration="8s">
<BeadField
spacing={46}
amplitude={0.6}
speed={1}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Backgrounds
- Recommended duration
- 240 frames · 8s
- Minimum duration
- 60 frames · 2s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the beads hold one size
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsBead and ground. Defaults to the brand's foreground on its background. | [string, string] | — | — |
spacingDistance between bead centres, in design pixels. | number | 46 | — |
amplitudeHow much the ripples change a bead's size, 0 to 1. | number | 0.6 | — |
speedHow fast the ripples cross. | number | 1 | — |
Related components
All componentsAurora veilFlowing curtains of colored light.Deep swellOcean waves beneath a sunlit horizon.Dither fieldA moving gradient with pixelated shading.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.Glass causticsShifting patterns of light beneath water.