← All particles

Star Field Parallax

Stars at three depths, the near ones travelling faster.

ambientcalmquietfuturistic140 particles · light · canvas-2d · automatic · looping
Variant

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.

220 lines · react only
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.

Night modeSleep timerSpace-themed onboardingIdle background

Related effects