Gradient field
A looping gradient in your brand colors.
Customize
import {Scene, Video} from "odori";
import {GradientField} from "../components/gradient-field/gradient-field";
<Video>
<Scene id="gradient-field" duration="8s">
<GradientField
travel={0.18}
cycleInFrames={240}
softness={260}
intensity={0.55}
/>
</Scene>
</Video>Installation
pnpm odori add component gradient-fieldnpx odori add component gradient-fieldbunx odori add component gradient-fieldEdit the source in videos/components/gradient-field/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/gradient-field/gradient-field.tsx
import {Fill, useBrand, useFrame, useVideo} from "odori";
export type GradientFieldProps = {
/** Colours to drift. Defaults to the brand's accent over its background. */
colors?: string[];
/** How far the blobs travel, as a fraction of the frame. */
travel?: number;
/** Frames for one full cycle. The motion repeats seamlessly across it. */
cycleInFrames?: number;
/** Blur radius in canvas pixels. Large is the point: this is atmosphere. */
softness?: number;
/** Overall strength, 0 to 1. */
intensity?: number;
};
/**
* Slow brand-aware colour behind everything else.
*
* The motion is driven by sine and cosine of the frame over one cycle, which
* makes it exactly periodic: the last frame of a cycle matches the first, so
* a background under a looping section never jumps. Deriving position from the
* frame rather than from a CSS animation is also what keeps preview and export
* showing the same pixels — a keyframe animation runs on wall time and would
* be somewhere else in every render.
*/
export const GradientField = ({
colors,
travel = 0.18,
cycleInFrames = 240,
softness = 260,
intensity = 0.55,
}: GradientFieldProps) => {
const frame = useFrame();
const brand = useBrand();
const {width, height} = useVideo();
const palette = colors ?? [brand.colors.accent, brand.colors.surface, brand.colors.accent];
const turn = (frame / cycleInFrames) * Math.PI * 2;
return (
<Fill style={{background: brand.colors.background, overflow: "hidden"}}>
{palette.map((color, index) => {
// Each blob runs the same cycle at a different phase and a different
// ratio, so they never line up and never drift out of period.
const phase = turn + (index * Math.PI * 2) / palette.length;
const x = 50 + Math.sin(phase) * travel * 100;
const y = 50 + Math.cos(phase * (index % 2 === 0 ? 1 : -1.5)) * travel * 100;
return (
<div
key={index}
aria-hidden
style={{
background: `radial-gradient(circle at ${x}% ${y}%, ${color} 0%, transparent 60%)`,
filter: `blur(${softness}px)`,
inset: `-${softness}px`,
opacity: intensity / palette.length + intensity * 0.35,
position: "absolute",
}}
/>
);
})}
{/* A vignette keeps the corners from lifting, which is where a soft
gradient otherwise starts to look like a rendering artefact. */}
<div
aria-hidden
style={{
background: `radial-gradient(circle at 50% 50%, transparent 40%, ${brand.colors.background} 100%)`,
inset: 0,
position: "absolute",
}}
/>
<div aria-hidden style={{height, position: "absolute", width, pointerEvents: "none"}} />
</Fill>
);
};
videos/components/gradient-field/gradient-field.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {GradientField} from "./gradient-field";
export default defineComponentPreview({
title: "Gradient field",
category: "Motion",
description: "A looping gradient in your brand colors.",
component: GradientField,
canvas: {width: 1920, height: 1080, duration: "8s"},
controls: {
travel: {type: "number", defaultValue: 0.18, min: 0, max: 0.6, step: 0.02},
cycleInFrames: {type: "number", defaultValue: 240, min: 60, max: 900},
softness: {type: "number", defaultValue: 260, min: 40, max: 500},
intensity: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},
},
examples: [
{name: "Default", props: {}},
{name: "Still", props: {travel: 0}},
{name: "Strong", props: {intensity: 0.9, travel: 0.32}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {GradientField} from "../components/gradient-field/gradient-field";
<Video>
<Scene id="gradient-field" duration="8s">
<GradientField
travel={0.18}
cycleInFrames={240}
softness={260}
intensity={0.55}
/>
</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
- 240 frames · 8s
- Minimum duration
- 30 frames · 1s
- Entrance
- 0 frames
- Exit
- 0 frames
- Reduced motion
- the field holds one still position
- Requires
- Odori
- Content limits
- colors ≤ 5
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsColours to drift. Defaults to the brand's accent over its background. | string[] | — | — |
travelHow far the blobs travel, as a fraction of the frame. | number | 0.18 | — |
cycleInFramesFrames for one full cycle. The motion repeats seamlessly across it. | number | 240 | — |
softnessBlur radius in canvas pixels. Large is the point: this is atmosphere. | number | 260 | — |
intensityOverall strength, 0 to 1. | number | 0.55 | — |
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.Halftone revealContent revealed through expanding dots.KaleidoscopeThe picture folded into mirrored wedges, so type and interface turn into pattern.