Starfield
Drifting, twinkling stars with parallax.
Customize
import {Scene, Video} from "odori";
import {Starfield} from "../components/starfield/starfield";
<Video>
<Scene id="starfield" duration="6s">
<Starfield
count={110}
drift="up"
twinkle={0.6}
/>
</Scene>
</Video>Installation
pnpm odori add component starfieldnpx odori add component starfieldbunx odori add component starfieldEdit the source in videos/components/starfield/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/starfield/starfield.tsx
import {Fill, random, randomBetween, useBrand, useDesignScale, useFrame, useVideo} from "odori";
/**
* A seeded star chart in three depths, drifting on parallax.
*
* Depth is done honestly: a star's layer sets its size, its brightness, and
* its drift speed together, so the small dim stars are also the slow ones and
* the eye reads distance without being told. Every position, layer, and phase
* comes from a seed named after the star, so the chart is drawn once and
* forever — the same sky on every machine and in every render chunk.
*
* The drift wraps by modular arithmetic on a span slightly wider than the
* frame, so a star leaves one edge fully before it enters the other and the
* field never pops. A third of the stars twinkle on their own sine phase; the
* rest hold steady, because a sky where everything blinks reads as noise.
*/
export type StarfieldProps = {
/** How many stars, across all layers. */
count?: number;
/** Which way the sky drifts. */
drift?: "up" | "left";
/** Drift of the nearest layer, in design pixels per second. */
speed?: number;
/** How deep the twinkling stars fade, 0 to 1. */
twinkle?: number;
/** Star colour. Defaults to the brand's muted tone. */
color?: string;
};
export const Starfield = ({count = 110, drift = "up", speed = 14, twinkle = 0.6, color}: StarfieldProps) => {
const frame = useFrame();
const brand = useBrand();
const scale = useDesignScale();
const {fps, width, height} = useVideo();
const ink = color ?? brand.colors.muted;
const t = frame / fps;
return (
<Fill style={{background: brand.colors.background, overflow: "hidden"}}>
{Array.from({length: count}, (_, index) => {
// Three layers, weighted so most stars sit far away: depth 0.35 is the
// back of the sky, 1 is the front, and everything scales from it.
const layer = Math.floor(random(`star-${index}-layer`) * 3);
const depth = [0.35, 0.65, 1][layer];
const diameter = Math.max(1, randomBetween(`star-${index}-size`, 2, 3.4) * depth * scale);
const margin = diameter * 2;
const homeX = random(`star-${index}-x`) * width;
const homeY = random(`star-${index}-y`) * height;
const travel = t * speed * depth * scale;
const spanX = width + margin * 2;
const spanY = height + margin * 2;
const x = drift === "left" ? ((((homeX - travel) % spanX) + spanX) % spanX) - margin : homeX;
const y = drift === "up" ? ((((homeY - travel) % spanY) + spanY) % spanY) - margin : homeY;
const twinkles = random(`star-${index}-flag`) < 0.35;
const phase = random(`star-${index}-phase`) * Math.PI * 2;
const rate = randomBetween(`star-${index}-rate`, 0.15, 0.5) * Math.PI * 2;
const flicker = twinkles ? 1 - twinkle * (0.5 + 0.5 * Math.sin(t * rate + phase)) : 1;
return (
<div
key={index}
aria-hidden
style={{
background: ink,
borderRadius: "50%",
boxShadow: layer === 2 ? `0 0 ${6 * scale}px ${ink}` : undefined,
height: diameter,
left: 0,
opacity: 0.85 * depth * flicker,
position: "absolute",
top: 0,
transform: `translate(${x}px, ${y}px)`,
width: diameter,
}}
/>
);
})}
</Fill>
);
};
videos/components/starfield/starfield.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Starfield} from "./starfield";
export default defineComponentPreview({
title: "Starfield",
category: "Backgrounds",
description: "Drifting, twinkling stars with parallax.",
component: Starfield,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
count: {type: "number", defaultValue: 110, min: 20, max: 300, step: 10},
twinkle: {type: "number", defaultValue: 0.6, min: 0, max: 1, step: 0.05},
drift: {type: "select", defaultValue: "up", options: ["up", "left"]},
},
examples: [
{name: "Brand", props: {}},
{name: "Sideways", props: {drift: "left", speed: 26}},
{name: "Dense and still", props: {count: 240, speed: 0, twinkle: 0.4}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Starfield} from "../components/starfield/starfield";
<Video>
<Scene id="starfield" duration="6s">
<Starfield
count={110}
drift="up"
twinkle={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 speed and twinkle to 0 and the sky is a still seeded star chart
- Requires
- Odori
- Content limits
- none
Props
| Prop | Type | Default | Required |
|---|---|---|---|
countHow many stars, across all layers. | number | 110 | — |
driftWhich way the sky drifts. | "up" | "left" | "up" | — |
speedDrift of the nearest layer, in design pixels per second. | number | 14 | — |
twinkleHow deep the twinkling stars fade, 0 to 1. | number | 0.6 | — |
colorStar colour. Defaults to the brand's muted tone. | string | — | — |
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.