Star Parallax
Size, brightness and colour all read off one depth, so the field reads as distance rather than as assorted dots.
The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
"use client";
import * as React from "react";
import * as THREE from "three";
/**
* Star Parallax — one number decides everything about a star.
*
* A field of dots where size, brightness and speed were each picked
* separately looks like a field of dots. The same field where all three
* are read off a single depth reads as distance, and the difference is
* not subtle: distance is the only thing the eye is actually being
* offered, and it either arrives whole or not at all. A large dim star
* that drifts quickly is not a star at any distance, and one of those
* in the frame is enough to flatten the rest.
*
* So a star here is a position and nothing else. Depth comes from where
* it is; the point size falls off with depth, the brightness falls off
* faster because it is the inverse square, and the drift is the same
* depth again. There is no per-star size, no per-star brightness and no
* per-star speed to get out of step, because none of them exist.
*
* The stars pass the camera and wrap around behind, so the field is
* finite and endless at once — the near ones are always the ones that
* were far a moment ago.
*
* Single file, react and three only. No textures, no add-ons.
*/
type Variant = "subtle" | "default" | "playful";
const VARIANTS = {
subtle: { driftRate: 0.05, starCount: 600, dimOpacity: 0.32, depthScale: 0.85 },
default: { driftRate: 0.12, starCount: 1100, dimOpacity: 0.52, depthScale: 1.2 },
playful: { driftRate: 0.28, starCount: 2000, dimOpacity: 0.74, depthScale: 1.75 },
} as const;
const PALETTES = {
night: {
sky: [0.021, 0.026, 0.04],
near: [0.93, 0.95, 1.0],
far: [0.36, 0.46, 0.72],
css: "radial-gradient(120% 90% at 50% 55%, #101728 0%, #080b14 60%, #05070d 100%)",
},
day: {
sky: [0.95, 0.955, 0.965],
near: [0.1, 0.16, 0.3],
far: [0.62, 0.68, 0.78],
css: "radial-gradient(120% 90% at 50% 55%, #e9ecf3 0%, #f2f4f8 60%, #f7f8fb 100%)",
},
} as const;
const VERTEX = /* glsl */ `
uniform float uSize;
varying float vNearness;
void main() {
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
float depth = max(-viewPosition.z, 0.35);
// Size falls off with distance…
gl_PointSize = uSize / depth;
// …and brightness falls off faster, because that is what an inverse
// square does. Reading both off the same depth is the entire effect;
// choosing them separately is what turns a sky into confetti.
vNearness = clamp(1.6 / (depth * depth), 0.0, 1.0);
gl_Position = projectionMatrix * viewPosition;
}
`;
const FRAGMENT = /* glsl */ `
uniform float uOpacity;
uniform vec3 uNear;
uniform vec3 uFar;
varying float vNearness;
void main() {
// A round point without a texture: discard outside the disc, and
// soften the rim so the near stars are not hexagons.
vec2 fromCentre = gl_PointCoord - 0.5;
float d = dot(fromCentre, fromCentre);
if (d > 0.25) discard;
float disc = 1.0 - smoothstep(0.05, 0.25, d);
// Near stars are white, far ones sit towards the sky's own colour —
// the same aerial perspective that makes distant hills blue.
vec3 color = mix(uFar, uNear, vNearness);
gl_FragColor = vec4(color, uOpacity * disc * (0.18 + 0.82 * vNearness));
}
`;
/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
const parts = color.match(/[\d.]+/g);
if (!parts || parts.length < 3) return 0.9;
const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export type StarParallaxProps = {
variant?: Variant;
height?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
export function StarParallax({
variant = "default",
height = "100%",
className,
style,
children,
}: StarParallaxProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const cfg = VARIANTS[variant] ?? VARIANTS.default;
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
const palette =
luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
host.style.background = palette.css;
const reduced =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "low-power",
});
} catch {
return; // No WebGL. The CSS still above is already painted.
}
const canvas = renderer.domElement;
canvas.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(
new THREE.Color(palette.sky[0], palette.sky[1], palette.sky[2]),
1
);
host.insertBefore(canvas, host.firstChild);
const count = cfg.starCount;
const far = 26 * cfg.depthScale;
const near = 0.6;
// Seeded rather than Math.random, so the sky is the same sky on
// every reload — which is what makes the reduced-motion still a
// fair picture of the effect instead of one arbitrary draw.
let seed = 0x9e3779b;
const random = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed / 4294967296;
};
const positions = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Spread in depth as a cube root so the shell volumes are equal
// and the field does not thin out towards the back.
const z = -near - Math.cbrt(random()) * far;
const spread = 0.55 * -z;
positions[i * 3] = (random() - 0.5) * spread * 2.4;
positions[i * 3 + 1] = (random() - 0.5) * spread * 1.7;
positions[i * 3 + 2] = z;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms: {
uSize: { value: 34 },
uOpacity: { value: 0.52 },
uNear: { value: new THREE.Vector3(...palette.near) },
uFar: { value: new THREE.Vector3(...palette.far) },
},
transparent: true,
depthWrite: false,
});
material.uniforms.uOpacity.value = cfg.dimOpacity;
const scene = new THREE.Scene();
scene.add(new THREE.Points(geometry, material));
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 120);
const resize = () => {
const width = Math.max(1, host.clientWidth);
const heightPx = Math.max(1, host.clientHeight);
renderer.setSize(width, heightPx, false);
camera.aspect = width / heightPx;
camera.updateProjectionMatrix();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(host);
const advance = (distance: number) => {
for (let i = 0; i < count; i++) {
const at = i * 3 + 2;
positions[at] += distance;
if (positions[at] > -near) {
// Past the camera: send it to the back of the field. The near
// stars are always the ones that were far a moment ago, which
// is what keeps a finite field endless.
positions[at] -= far;
const spread = 0.55 * -positions[at];
positions[i * 3] = (random() - 0.5) * spread * 2.4;
positions[i * 3 + 1] = (random() - 0.5) * spread * 1.7;
}
}
geometry.attributes.position.needsUpdate = true;
};
const draw = () => renderer.render(scene, camera);
let frame = 0;
let last = 0;
let running = false;
const tick = (now: number) => {
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
advance(delta * cfg.driftRate * 9.0);
draw();
frame = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
last = 0;
frame = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
const onLost = (event: Event) => {
event.preventDefault();
stop();
};
const onRestored = () => {
resize();
start();
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
if (reduced) {
// A still sky keeps the whole claim: size, brightness and colour
// are all depth, and depth is in the frame whether or not it moves.
draw();
} else {
start();
}
return () => {
stop();
observer.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
canvas.remove();
};
}, [cfg]);
return (
<div
ref={hostRef}
className={className}
style={{
position: "relative",
width: "100%",
height,
overflow: "hidden",
background: PALETTES.night.css,
...style,
}}
>
{children}
</div>
);
}
export default StarParallax;About this effect
For a sign-in screen, a splash, or the top of a marketing page — and it is the safest of these backdrops to put text on, because most of the frame is empty by construction. A field of dots whose size, brightness and speed were each picked separately looks like a field of dots. The same field with all three read off a single depth reads as distance, and the difference is not subtle: distance is the only thing the eye is being offered here, and it either arrives whole or not at all. One large dim star drifting quickly is not a star at any distance, and one of those in the frame is enough to flatten the rest. So a star in this file is a position and nothing else. Depth comes from where it is, the point size falls off with depth, the brightness falls off faster because that is what an inverse square does, the colour moves towards the sky's own — the same aerial perspective that makes distant hills blue — and the drift is that depth again. There is no per-star size, brightness or speed to fall out of step, because none of them exist. Stars that pass the camera are sent to the back of the field, so the near ones are always the ones that were far a moment ago and a finite field stays endless.
Related effects
- Swell SurfacePoints moved in circles rather than up and down, so crests peak and troughs flatten without either being shaped.
- Lattice NetEdges recomputed from distance every frame with nothing created or destroyed, so the mesh thickens where nodes gather.
- Aurora CurtainRays that arrive where a sampled sheet folds end-on, so no line of code decides where a ray belongs.