Scan glow
Fine scanlines with one soft refresh band sweeping down on a slow cycle, under a faint vignette.
Customize
import {Scene, Video} from "odori";
import {ScanGlow} from "../components/scan-glow/scan-glow";
<Video>
<Scene id="scan-glow" duration="6s">
<ScanGlow
lines={4}
sweepSeconds={5}
intensity={0.6}
/>
</Scene>
</Video>Installation
pnpm odori add component scan-glownpx odori add component scan-glowbunx odori add component scan-glowEdit the source in videos/components/scan-glow/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/scan-glow/scan-glow.tsx
import {Fill, useBrand, useDesignScale, useVideo} from "odori";
import {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";
/**
* A scanline ground: fine horizontal lines, one soft refresh band sweeping
* down on a slow cycle, and a faint vignette to hold the corners in.
*
* The CRT feel without the terminal. The lines are a raised cosine sharpened
* so the bright ridge is thin and the gap is wide — texture, not stripes —
* and the sweep brightens the lines it passes rather than painting over them,
* which is what a real refresh looked like. The band position is a pure
* function of `seconds`, so the cycle is exact and every worker agrees on it.
*
* Line pitch is a design length, scaled outside the shader, so the texture
* stays the same apparent fineness in a vertical or square cut.
*/
export type ScanGlowProps = {
/** Glow colour. Defaults to the brand accent. */
color?: string;
/** Distance between scanlines, in design pixels. */
lines?: number;
/** Seconds per full sweep. 0 removes the sweep and holds the texture still. */
sweepSeconds?: number;
/** How bright the lines and sweep sit over the ground. */
intensity?: 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 glow = defineShaderEffect({
name: "scan-glow",
uniforms: {
pitch: numberUniform(4),
sweepSeconds: numberUniform(5),
intensity: numberUniform(0.6),
base: vec3Uniform([0.04, 0.05, 0.09]),
ink: vec3Uniform([0.45, 0.55, 0.85]),
},
fragmentShader: `
uniform float pitch;
uniform float sweepSeconds;
uniform float intensity;
uniform vec3 base;
uniform vec3 ink;
void main() {
vec2 uv = gl_FragCoord.xy / resolution;
/* A raised cosine per pitch, cubed so the ridge is thin against a wide
gap. This is texture at reading distance, not a striped wall. */
float line = 0.5 + 0.5 * cos(gl_FragCoord.y * 6.2831853 / pitch);
line = line * line * line;
/* The refresh band falls from top to bottom once per cycle. A cycle of 0
seconds means no sweep at all, which is the reduced-motion story. */
float band = 0.0;
if (sweepSeconds > 0.0) {
float pos = 1.0 - fract(seconds / sweepSeconds);
band = smoothstep(0.16, 0.0, abs(uv.y - pos));
}
/* A faint vignette, quadratic from centre, so the ground has a middle. */
vec2 c = uv - 0.5;
c.x *= resolution.x / resolution.y;
float vignette = 1.0 - dot(c, c) * 0.55;
/* The sweep mostly brightens the lines it passes, with a little ambient
lift of its own — a refresh, not a searchlight. */
float lift = line * 0.22 + band * (0.1 + line * 0.5);
odoriColour = vec4((base + ink * lift * intensity) * vignette, 1.0);
}`,
});
export const ScanGlow = ({color, lines = 4, sweepSeconds = 5, intensity = 0.6}: ScanGlowProps) => {
const brand = useBrand();
const scale = useDesignScale();
const {width, height} = useVideo();
const ink = color ?? brand.colors.accent;
return (
<EffectSurface
effects={[
glow({
pitch: Math.max(2, lines * scale),
sweepSeconds,
intensity,
base: hexToRgb(brand.colors.background),
ink: hexToRgb(ink),
}),
]}
>
<Fill style={{background: brand.colors.background, height, width}} />
</EffectSurface>
);
};
videos/components/scan-glow/scan-glow.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {ScanGlow} from "./scan-glow";
export default defineComponentPreview({
title: "Scan glow",
category: "Backgrounds",
description: "Fine scanlines with one soft refresh band sweeping down on a slow cycle, under a faint vignette.",
component: ScanGlow,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
lines: {type: "number", defaultValue: 4, min: 2, max: 16, step: 1},
sweepSeconds: {type: "number", defaultValue: 5, min: 0, max: 12, step: 0.5},
intensity: {type: "number", defaultValue: 0.6, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "Brand", props: {}},
{name: "Phosphor", props: {color: "#4ade80", lines: 3, intensity: 0.7}},
{name: "Still texture", props: {sweepSeconds: 0, intensity: 0.45}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {ScanGlow} from "../components/scan-glow/scan-glow";
<Video>
<Scene id="scan-glow" duration="6s">
<ScanGlow
lines={4}
sweepSeconds={5}
intensity={0.6}
/>
</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 sweepSeconds to 0 and only the still line texture and vignette remain
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
colorGlow colour. Defaults to the brand accent. | string | — | — |
linesDistance between scanlines, in design pixels. | number | 4 | — |
sweepSecondsSeconds per full sweep. 0 removes the sweep and holds the texture still. | number | 5 | — |
intensityHow bright the lines and sweep sit over the ground. | number | 0.6 | — |
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.