Star Field Parallax
Stars at three depths, the near ones travelling faster.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Star Field Parallax
*
* Stars at three depths, the near ones travelling faster. The technique
* is the discreteness: three clearly separated planes rather than a
* smooth spread of speeds. Give every star its own random speed and the
* field reads as noise sliding about; group them into three, each with
* its own speed, size and brightness, and the eye resolves distance
* immediately. Only the nearest plane twinkles, for the same reason —
* twinkle applied to everything is just flicker.
*
* Star color defaults to the inherited text color, so the field reads
* on a light page as readily as on a dark one.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StarFieldParallaxProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Total stars across all three planes. Overrides the variant's density. */
count?: number;
/** Star color. Defaults to the inherited text color. */
color?: string;
/** Travel direction of the field. */
direction?: "left" | "right";
};
type VariantConfig = {
/** Stars across all three planes at this setting. */
count: number;
/** Speed in px per second for the far plane and the near plane. */
farSpeed: number;
nearSpeed: number;
/** Radius in px for the far plane and the near plane. */
farSize: number;
nearSize: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A sky you would only notice if you looked for a while.
subtle: { count: 100, farSpeed: 2, nearSpeed: 8, farSize: 0.6, nearSize: 1.2 },
// Reads as drifting without pulling the eye. All-purpose.
default: { count: 140, farSpeed: 3.5, nearSpeed: 15, farSize: 0.7, nearSize: 1.5 },
// Travelling: the near plane clearly overtakes the far one.
playful: { count: 190, farSpeed: 6, nearSpeed: 28, farSize: 0.85, nearSize: 1.9 },
};
/** Share of the total that lands in each plane, far to near. */
const PLANE_SHARE = [0.5, 0.32, 0.18];
type Star = {
x: number;
y: number;
radius: number;
alpha: number;
/** Phase into its own twinkle; only the near plane uses it. */
phase: number;
rate: number;
};
export default function StarFieldParallax({
variant = "default",
count,
color,
direction = "left",
}: StarFieldParallaxProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const total = count ?? config.count;
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Inheriting the text color is what makes this survive a theme flip:
// white stars vanish on a light page, and a fixed dark blue vanishes
// on a dark one.
const ink = color ?? getComputedStyle(canvas).color ?? "#FFFFFF";
const sign = direction === "left" ? -1 : 1;
let width = 0;
let height = 0;
let ratio = 1;
const resize = () => {
const rect = canvas.getBoundingClientRect();
ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
const mix = (from: number, to: number, t: number) => from + (to - from) * t;
/** Everything about a plane comes from its index: 0 far, 2 near. */
const planeSpec = (index: number) => {
const depth = index / (PLANE_SHARE.length - 1);
return {
speed: mix(config.farSpeed, config.nearSpeed, depth),
size: mix(config.farSize, config.nearSize, depth),
alpha: mix(0.32, 0.95, depth),
twinkles: index === PLANE_SHARE.length - 1,
};
};
const build = () =>
PLANE_SHARE.map((share, index) => {
const spec = planeSpec(index);
const stars: Star[] = Array.from(
{ length: Math.max(1, Math.round(total * share)) },
() => ({
x: random(0, width),
y: random(0, height),
radius: spec.size * random(0.75, 1.25),
alpha: spec.alpha * random(0.7, 1),
phase: random(0, Math.PI * 2),
rate: random(0.5, 1.1),
})
);
return { spec, stars };
});
let planes = build();
const render = (elapsed: number) => {
context.clearRect(0, 0, width, height);
context.fillStyle = ink;
for (const plane of planes) {
for (const star of plane.stars) {
const twinkle = plane.spec.twinkles
? 0.74 + 0.26 * Math.sin(elapsed * star.rate + star.phase)
: 1;
context.globalAlpha = star.alpha * twinkle;
context.beginPath();
context.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
context.fill();
}
}
context.globalAlpha = 1;
};
// Reduced motion: one still frame. Three sizes of star at three
// brightnesses still read as depth with nothing moving at all.
if (reduced) {
render(0);
const onResizeStill = () => {
resize();
planes = build();
render(0);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
let elapsed = 0;
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
for (const plane of planes) {
const step = plane.spec.speed * sign * delta;
for (const star of plane.stars) {
star.x += step;
// Each plane wraps on its own, which is the whole mechanism:
// nothing ties the planes together except the direction.
if (star.x < -2) {
star.x = width + 2;
star.y = random(0, height);
} else if (star.x > width + 2) {
star.x = -2;
star.y = random(0, height);
}
}
}
render(elapsed);
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
planes = build();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color, direction]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A slow travelling sky for a night mode, a sleep timer, or a screen that is on but resting. What makes it read as depth is that the planes are discrete: three groups, each with its own speed, size and brightness, rather than a smooth spread of random speeds — a continuous spread reads as noise sliding about, while three separations resolve instantly. Only the nearest plane twinkles, for the same reason: twinkle applied to every star is just flicker. Stars take the inherited text color by default, so the field survives a theme flip instead of disappearing on a light page.