Mesh gradient
A shifting blend of colored gradients.
Customize
import {Scene, Video} from "odori";
import {MeshGradient} from "../components/mesh-gradient/mesh-gradient";
<Video>
<Scene id="mesh-gradient" duration="6s">
<MeshGradient
drift={1}
falloff={1.6}
grain={0.03}
/>
</Scene>
</Video>Installation
pnpm odori add component mesh-gradientnpx odori add component mesh-gradientbunx odori add component mesh-gradientEdit the source in videos/components/mesh-gradient/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/mesh-gradient/mesh-gradient.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Four colour centres drifting past each other, blended on the GPU.
*
* The sibling of `gradient-field`, which does a similar job with blurred DOM
* and repeats exactly across a cycle. This one is a shader: the centres blend
* by inverse distance rather than by stacking blurs, so they pass through each
* other without an edge and the falloff is a control rather than a blur
* radius. Reach for the DOM one when a background has to loop seamlessly, and
* this one when it has to look like a mesh gradient.
*
* Colours default to the brand, so it already looks like the project.
*/
export type MeshGradientProps = {
/** Two to four colours. Beyond four the extra ones are ignored. */
colors?: string[];
/** How far the centres travel. 0 holds them still. */
drift?: number;
/** How sharply one colour gives way to the next. */
falloff?: number;
/** Grain over the blend, which keeps a wide gradient from banding. */
grain?: 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 field = defineShaderEffect({
name: "mesh-gradient",
uniforms: {
drift: numberUniform(1),
falloff: numberUniform(1.6),
grain: numberUniform(0.03),
colourA: vec3Uniform([0.11, 0.13, 0.22]),
colourB: vec3Uniform([0.35, 0.22, 0.55]),
colourC: vec3Uniform([0.12, 0.35, 0.45]),
colourD: vec3Uniform([0.05, 0.06, 0.1]),
},
fragmentShader: `
uniform float drift;
uniform float falloff;
uniform float grain;
uniform vec3 colourA;
uniform vec3 colourB;
uniform vec3 colourC;
uniform vec3 colourD;
/* Inverse-distance blending rather than a gradient stop, so the centres can
pass through each other without a visible edge. */
vec3 blend(vec2 uv, vec2 p, vec3 c, inout float total) {
float w = 1.0 / (pow(distance(uv, p), falloff) + 0.0001);
total += w;
return c * w;
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
/* Corrected for shape, or the centres travel in ovals on a wide frame. */
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
uv *= aspect;
float t = progress * 6.2831853 * drift;
float total = 0.0;
vec3 sum = vec3(0.0);
sum += blend(uv, vec2(0.28, 0.30) * aspect + vec2(cos(t), sin(t * 0.9)) * 0.16 * drift, colourA, total);
sum += blend(uv, vec2(0.76, 0.26) * aspect + vec2(cos(t * 1.3 + 2.0), sin(t * 0.7)) * 0.14 * drift, colourB, total);
sum += blend(uv, vec2(0.30, 0.74) * aspect + vec2(sin(t * 0.8), cos(t * 1.1)) * 0.15 * drift, colourC, total);
sum += blend(uv, vec2(0.74, 0.78) * aspect + vec2(sin(t * 1.2 + 1.0), cos(t * 0.6)) * 0.13 * drift, colourD, total);
vec3 colour = sum / total;
/* A wide gradient bands across 8-bit output without this. */
colour += (hash(gl_FragCoord.xy + frame) - 0.5) * grain;
odoriColour = vec4(colour, 1.0);
}`,
});
export const MeshGradient = ({colors, drift = 1, falloff = 1.6, grain = 0.03}: MeshGradientProps) => {
const brand = useBrand();
const {width, height} = useVideo();
const palette = colors?.length
? colors
: [brand.colors.accent, brand.colors.surface, brand.colors.muted, brand.colors.background];
const [a, b, c, d] = [0, 1, 2, 3].map((index) => hexToRgb(palette[index % palette.length]));
return (
<EffectSurface effects={[field({drift, falloff, grain, colourA: a, colourB: b, colourC: c, colourD: d})]}>
{/* The surface needs something to photograph even where the shader
ignores it, and a flat fill is the cheapest thing to capture. */}
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/mesh-gradient/mesh-gradient.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {MeshGradient} from "./mesh-gradient";
export default defineComponentPreview({
title: "Mesh gradient",
category: "Backgrounds",
description: "A shifting blend of colored gradients.",
component: MeshGradient,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
drift: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},
falloff: {type: "number", defaultValue: 1.6, min: 0.6, max: 4, step: 0.1},
grain: {type: "number", defaultValue: 0.03, min: 0, max: 0.12, step: 0.01},
},
examples: [
{name: "Brand", props: {}},
{name: "Still", props: {drift: 0}},
{name: "Sunset", props: {colors: ["#2b1055", "#7597de", "#ff7e5f", "#120318"]}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {MeshGradient} from "../components/mesh-gradient/mesh-gradient";
<Video>
<Scene id="mesh-gradient" duration="6s">
<MeshGradient
drift={1}
falloff={1.6}
grain={0.03}
/>
</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 the field holds still
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorsTwo to four colours. Beyond four the extra ones are ignored. | string[] | — | — |
driftHow far the centres travel. 0 holds them still. | number | 1 | — |
falloffHow sharply one colour gives way to the next. | number | 1.6 | — |
grainGrain over the blend, which keeps a wide gradient from banding. | number | 0.03 | — |
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.