Volumetrics
Soft, translucent beams of light.
Customize
import {Scene, Video} from "odori";
import {Volumetrics} from "../components/volumetrics/volumetrics";
<Video>
<Scene id="volumetrics" duration="6s">
<Volumetrics
from="top-left"
intensity={0.45}
speed={1}
/>
</Scene>
</Video>Installation
pnpm odori add component volumetricsnpx odori add component volumetricsbunx odori add component volumetricsEdit the source in videos/components/volumetrics/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/volumetrics/volumetrics.tsx
import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from "odori";
/**
* A handful of translucent beams from one corner or the top edge, the way
* light falls into a room with dust in the air.
*
* Each beam is a tall gradient bar rotated about its source, blurred until the
* edges stop being edges. The beams do not sweep — a searchlight is an event,
* not a ground — they breathe: width and opacity cycle slowly on phases each
* beam takes from its own seed, so the light shifts the way daylight does and
* never in unison.
*
* Everything fades along its length, so the frame's lower half stays clean for
* whatever sits on top.
*/
export type VolumetricsProps = {
/** Where the light enters. */
from?: "top" | "top-left" | "top-right";
/** Beam colour. Defaults to the brand's accent. */
color?: string;
/** How many beams, 3 to 5. */
rays?: number;
/** Opacity ceiling for the strongest beam. */
intensity?: number;
/** How fast the beams breathe. */
speed?: number;
};
export const Volumetrics = ({from = "top-left", color, rays = 4, intensity = 0.45, speed = 1}: VolumetricsProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {fps, width, height} = useVideo();
const beam = color ?? brand.colors.accent;
const count = Math.max(3, Math.min(5, Math.round(rays)));
const t = (frame / fps) * speed;
// The fan: beams from a corner lean into the frame, beams from the top edge
// hang nearly straight with a slight spread.
const origin =
from === "top"
? {x: width / 2, base: 0, spread: 26}
: {x: from === "top-left" ? 0 : width, base: from === "top-left" ? -32 : 32, spread: 30};
const length = Math.hypot(width, height) * 1.2;
return (
<Fill style={{background: brand.colors.background, overflow: "hidden"}}>
{Array.from({length: count}, (_, index) => {
const along = index / (count - 1);
const angle = origin.base + (along - 0.5) * origin.spread + randomBetween(`ray-${index}-angle`, -3, 3);
const phase = random(`ray-${index}-phase`) * Math.PI * 2;
const rate = randomBetween(`ray-${index}-rate`, 0.06, 0.12) * Math.PI * 2;
const breathe = 0.5 + 0.5 * Math.sin(t * rate + phase);
const rayWidth = (110 + 90 * breathe) * scale;
return (
<div
key={index}
aria-hidden
style={{
background: `linear-gradient(180deg, ${beam} 0%, transparent 70%)`,
filter: `blur(${36 * scale}px)`,
height: length,
left: origin.x + (from === "top" ? (along - 0.5) * width * 0.7 : 0) - rayWidth / 2,
opacity: intensity * (0.45 + 0.55 * breathe),
position: "absolute",
top: -60 * scale,
transform: `rotate(${angle}deg)`,
transformOrigin: "top center",
width: rayWidth,
}}
/>
);
})}
</Fill>
);
};
videos/components/volumetrics/volumetrics.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Volumetrics} from "./volumetrics";
export default defineComponentPreview({
title: "Volumetrics",
category: "Backgrounds",
description: "Soft, translucent beams of light.",
component: Volumetrics,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
from: {type: "select", defaultValue: "top-left", options: ["top", "top-left", "top-right"]},
intensity: {type: "number", defaultValue: 0.45, min: 0, max: 0.8, step: 0.05},
speed: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
},
examples: [
{name: "Brand", props: {}},
{name: "Overhead", props: {from: "top", rays: 5, intensity: 0.15}},
{name: "Amber", props: {from: "top-right", color: "#ffb454", intensity: 0.3}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Volumetrics} from "../components/volumetrics/volumetrics";
<Video>
<Scene id="volumetrics" duration="6s">
<Volumetrics
from="top-left"
intensity={0.45}
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
- 180 frames · 6s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- set speed to 0 and the beams hold one still pose
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
fromWhere the light enters. | "top" | "top-left" | "top-right" | "top-left" | — |
colorBeam colour. Defaults to the brand's accent. | string | — | — |
raysHow many beams, 3 to 5. | number | 4 | — |
intensityOpacity ceiling for the strongest beam. | number | 0.45 | — |
speedHow fast the beams breathe. | number | 1 | — |
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.Dust motesSoft particles drifting across the frame.Gel washBlurred bands of color over a dark background.