Dust motes
Soft particles drifting across the frame.
Customize
import {Scene, Video} from "odori";
import {DustMotes} from "../components/dust-motes/dust-motes";
<Video>
<Scene id="dust-motes" duration="6s">
<DustMotes
count={48}
drift={24}
opacity={0.7}
/>
</Scene>
</Video>Installation
pnpm odori add component dust-motesnpx odori add component dust-motesbunx odori add component dust-motesEdit the source in videos/components/dust-motes/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/dust-motes/dust-motes.tsx
import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from "odori";
/**
* A still field of soft dots, each breathing around its own home position.
*
* Particles that travel across the frame become an event; particles that orbit
* a fixed point a few pixels wide become air. Every home position, size, and
* phase comes from a seed named after the particle, so the field is arranged
* once and forever — the same fifty dots on every machine, in every render
* chunk, with nothing to jump at a boundary.
*
* Drift is the control worth having: it is the orbit radius, and at 0 the
* field is a designed still rather than a paused animation.
*/
export type DustMotesProps = {
/** How many dots. */
count?: number;
/** Largest dot diameter, in design pixels. */
size?: number;
/** Orbit radius, in design pixels. 0 holds every dot at home. */
drift?: number;
/** Dot colour. Defaults to the brand's muted tone. */
color?: string;
/** Opacity of the brightest dot. */
opacity?: number;
};
export const DustMotes = ({count = 48, size = 7, drift = 24, color, opacity = 0.7}: DustMotesProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {fps} = useVideo();
const dot = color ?? brand.colors.muted;
const t = frame / fps;
return (
<Fill style={{background: brand.colors.background, overflow: "hidden"}}>
{Array.from({length: count}, (_, index) => {
const home = {
x: random(`particle-${index}-x`) * 100,
y: random(`particle-${index}-y`) * 100,
};
const phase = random(`particle-${index}-phase`) * Math.PI * 2;
const rate = randomBetween(`particle-${index}-rate`, 0.04, 0.1) * Math.PI * 2;
// Depth from size: the small dots are also the dim ones, which is
// enough to read as distance without any actual parallax.
const depth = randomBetween(`particle-${index}-depth`, 0.35, 1);
const diameter = size * depth * scale;
const x = Math.sin(t * rate + phase) * drift * depth * scale;
const y = Math.cos(t * rate * 0.8 + phase * 1.6) * drift * depth * scale;
return (
<div
key={index}
aria-hidden
style={{
background: `radial-gradient(circle, ${dot} 0%, transparent 70%)`,
borderRadius: "50%",
height: diameter * 3,
left: `${home.x}%`,
opacity: opacity * depth,
position: "absolute",
top: `${home.y}%`,
transform: `translate(${x}px, ${y}px)`,
width: diameter * 3,
}}
/>
);
})}
</Fill>
);
};
videos/components/dust-motes/dust-motes.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DustMotes} from "./dust-motes";
export default defineComponentPreview({
title: "Dust motes",
category: "Backgrounds",
description: "Soft particles drifting across the frame.",
component: DustMotes,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
count: {type: "number", defaultValue: 48, min: 8, max: 160, step: 8},
drift: {type: "number", defaultValue: 24, min: 0, max: 120, step: 4},
opacity: {type: "number", defaultValue: 0.7, min: 0.05, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Dust", props: {count: 120, size: 3, drift: 12, opacity: 0.25}},
{name: "Embers", props: {color: "#ff9a3d", count: 32, size: 8, drift: 48}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DustMotes} from "../components/dust-motes/dust-motes";
<Video>
<Scene id="dust-motes" duration="6s">
<DustMotes
count={48}
drift={24}
opacity={0.7}
/>
</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
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set drift to 0 and every dot holds its seeded home position
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
countHow many dots. | number | 48 | — |
sizeLargest dot diameter, in design pixels. | number | 7 | — |
driftOrbit radius, in design pixels. 0 holds every dot at home. | number | 24 | — |
colorDot colour. Defaults to the brand's muted tone. | string | — | — |
opacityOpacity of the brightest dot. | number | 0.7 | — |
Related components
All componentsAurora veilFlowing curtains of colored light.Bead fieldA fixed grid of dots that swell as two drifting ripples cross them.Deep swellOcean waves beneath a sunlit horizon.Dither fieldA moving gradient with pixelated shading.Gel washBlurred bands of color over a dark background.Glass causticsShifting patterns of light beneath water.