Topo lines
Slowly shifting contour lines.
Customize
import {Scene, Video} from "odori";
import {TopoLines} from "../components/topo-lines/topo-lines";
<Video>
<Scene id="topo-lines" duration="6s">
<TopoLines
lines={14}
speed={0.08}
weight={1}
/>
</Scene>
</Video>Installation
pnpm odori add component topo-linesnpx odori add component topo-linesbunx odori add component topo-linesEdit the source in videos/components/topo-lines/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/topo-lines/topo-lines.tsx
import {Fill, useBrand, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* Contour lines over a drifting elevation, like a survey map of weather.
*
* The elevation is fbm noise; slicing its fract() into bands and stroking the
* band edges is what turns a soft field into drawn lines. The stroke width is
* measured in screen derivatives (fwidth), so a line is one crisp hairline
* whether the slope under it is a cliff or a plain — thickness by slope is the
* artefact that makes cheap contour shaders look like moiré.
*
* Lines against a flat ground, nothing filled between the bands: quiet enough
* to sit under type, structured enough to not read as empty.
*/
export type TopoLinesProps = {
/** Line colour. Defaults to the brand's border. */
color?: string;
/** Ground colour. Defaults to the brand's background. */
background?: string;
/** How many elevation bands cross the range. */
lines?: number;
/** How fast the terrain drifts. */
speed?: number;
/** Stroke weight, in multiples of a hairline. */
weight?: 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: "topo-lines",
uniforms: {
lines: numberUniform(14),
speed: numberUniform(0.08),
weight: numberUniform(1),
ink: vec3Uniform([0.12, 0.12, 0.12]),
ground: vec3Uniform([0, 0, 0]),
},
fragmentShader: `
uniform float lines;
uniform float speed;
uniform float weight;
uniform vec3 ink;
uniform vec3 ground;
float value(vec2 p) {
vec2 cell = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash(cell);
float b = hash(cell + vec2(1.0, 0.0));
float c = hash(cell + vec2(0.0, 1.0));
float d = hash(cell + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float sum = 0.0;
float weight_ = 0.5;
for (int i = 0; i < 4; i++) {
sum += value(p) * weight_;
p = p * 2.03 + 17.3;
weight_ *= 0.5;
}
return sum;
}
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
uv.x *= resolution.x / resolution.y;
float t = seconds * speed;
/* The terrain drifts sideways and breathes a little, rather than boiling:
contours that reconnect too fast stop reading as a map. */
float elevation = fbm(uv * 2.6 + vec2(t, t * 0.4));
elevation += fbm(uv * 5.2 - t * 0.6) * 0.25;
float banded = elevation * lines;
/* Distance to the nearest band edge, in bands, against how many bands one
pixel spans — which is what makes the stroke one hairline everywhere. */
float edge = min(fract(banded), 1.0 - fract(banded));
float span = fwidth(banded);
float line = 1.0 - smoothstep(span * weight * 0.5, span * (weight * 0.5 + 1.0), edge);
odoriColour = vec4(mix(ground, ink, line), 1.0);
}`,
});
export const TopoLines = ({color, background, lines = 14, speed = 0.08, weight = 1}: TopoLinesProps) => {
const brand = useBrand();
const {width, height} = useVideo();
return (
<EffectSurface
effects={[
field({
lines,
speed,
weight,
ink: hexToRgb(color ?? brand.colors.muted),
ground: hexToRgb(background ?? brand.colors.background),
}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/topo-lines/topo-lines.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {TopoLines} from "./topo-lines";
export default defineComponentPreview({
title: "Topo lines",
category: "Backgrounds",
description: "Slowly shifting contour lines.",
component: TopoLines,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
lines: {type: "number", defaultValue: 14, min: 4, max: 40, step: 1},
speed: {type: "number", defaultValue: 0.08, min: 0, max: 0.5, step: 0.02},
weight: {type: "number", defaultValue: 1, min: 0.5, max: 4, step: 0.5},
},
examples: [
{name: "Brand", props: {}},
{name: "Dense", props: {lines: 28, weight: 0.5}},
{name: "Chart", props: {color: "#1d4044", background: "#04100f", lines: 10, weight: 2}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {TopoLines} from "../components/topo-lines/topo-lines";
<Video>
<Scene id="topo-lines" duration="6s">
<TopoLines
lines={14}
speed={0.08}
weight={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 contours hold one still map
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorLine colour. Defaults to the brand's border. | string | — | — |
backgroundGround colour. Defaults to the brand's background. | string | — | — |
linesHow many elevation bands cross the range. | number | 14 | — |
speedHow fast the terrain drifts. | number | 0.08 | — |
weightStroke weight, in multiples of a hairline. | 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.