Star Field

A dynamic star field background with adjustable speed, creating a sense of motion and depth.

Big Bang, Again?

1. Copy the component file

Create a new file called star-field-background.jsx in your reusable components folder (for example /src/components/) and paste the following code into it.

star-field-background.jsx

import { useRef, useState, useEffect, useLayoutEffect, useMemo, memo, useCallback } from "react";
import styles from "./star-field-background.module.css";

const random = (n1 = 1, n2) => {
  if (n1 === undefined) return Math.random();
  if (n2 === undefined) return Math.random() * n1;
  return Math.random() * (n2 - n1) + n1;
};

const map = (value, start1, stop1, start2, stop2) => {
  const min = Math.min(start2, stop2);
  const max = Math.max(start2, stop2);
  const newValue = start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1));
  return Math.min(Math.max(newValue, min), max);
};

function Star(ctx) {
  this.canvasWidth = 0;
  this.canvasHeight = 0;
  this.starColor = "#ffffff";
  this.starTrailColor = "#dddddd";
  this.speed = 5;

  this.init = () => {
    this.x = random(-this.canvasWidth / 2, this.canvasWidth / 2);
    this.y = random(-this.canvasHeight / 2, this.canvasHeight / 2);
    this.z = random(this.canvasWidth);
    this.sz = this.z;
    this.r = 1;
  };

  this.setCanvasSize = (width, height) => {
    this.canvasWidth = width;
    this.canvasHeight = height;
  };

  this.setSpeed = (speed) => {
    this.speed = speed;
  };

  this.update = () => {
    this.z -= this.speed;
    if (
      (this.speed >= 0 && this.z <= 0) ||
      (this.speed <= 0 && this.z >= (this.canvasWidth / 2))
    ) {
      this.init();
    }
  };

  this.show = () => {
    const x1 = map(this.x / this.z, -1, 1, -this.canvasWidth / 2, this.canvasWidth / 2);
    const y1 = map(this.y / this.z, -1, 1, -this.canvasHeight / 2, this.canvasHeight / 2);
    this.sx = map(this.x / this.sz, -1, 1, -this.canvasWidth / 2, this.canvasWidth / 2);
    this.sy = map(this.y / this.sz, -1, 1, -this.canvasHeight / 2, this.canvasHeight / 2);
    const radius = map(this.z, -this.canvasWidth / 2, this.canvasWidth / 2, 0.2, 0.8);
    ctx.beginPath();
    ctx.fillStyle = this.starColor;
    ctx.fill();
    ctx.ellipse(x1, y1, radius, radius, 0, 0, 360, false);
    ctx.strokeStyle = this.starTrailColor;
    ctx.lineWidth = 1;
    ctx.moveTo(x1, y1);
    ctx.lineTo(this.sx, this.sy);
    ctx.stroke();
    ctx.closePath(); 
    this.sz = this.z;
  };

  this.init();
}

const StarFieldBackground = (props) => {
  const {
    speed = 5,
    className = "",
    wrapperProps = {},
    wrapperTagName = "div",
    children,
    ...rest
  } = props;

  const {
    className: wrapperClassName = "", 
    ...restWrapperProps
  } = wrapperProps;

  const Wrapper = wrapperTagName || "div";

  const spaceColor = "#000000";
  const starsCount = 500;

  const canvasRef = useRef();
  const containerRef = useRef();
  const rafId = useRef(null);

  const [mounted, setMounted] = useState(false);
  const [width, setWidth] = useState(0);
  const [height, setHeight] = useState(0);

  const ctx = useMemo(() => {
    return canvasRef.current?.getContext("2d");
  }, [canvasRef.current]);

  const stars = useMemo(() => (
    Array.from({ length: starsCount }, () => new Star(ctx))
  ), [ctx, starsCount]);

  const render = useCallback(() => {
    ctx.fillStyle = spaceColor;
    ctx.fillRect(0, 0, width, height);
    ctx.save();
    ctx.translate(width / 2, height / 2);
    for (let i = 0; i < stars.length; i++) {
      stars[i].setSpeed(speed);
      stars[i].setCanvasSize(width, height);
      stars[i].update();
      stars[i].show();
    }
    ctx.restore();
    rafId.current = requestAnimationFrame(render);
  }, [ctx, width, height, stars, speed, spaceColor]);

  useEffect(() => {
    const updateContainerDimensions = () => {
      const {
        width,
        height,
      } = containerRef.current.getBoundingClientRect();
      setWidth(width);
      setHeight(height);
    };
    const resizeObserver = new ResizeObserver(updateContainerDimensions);
    resizeObserver.observe(containerRef.current);
    updateContainerDimensions();
    setMounted(true);
    return () => {
      resizeObserver.disconnect();
    }
  }, []);

  useLayoutEffect(() => {
    if (!mounted) return;
    render();
    return () => {
      cancelAnimationFrame(rafId.current);
    };
  }, [mounted, render]);

  return (
    <div
      {...rest}
      ref={containerRef}
      className={[
        className,
        styles["star-field"]
      ].join(" ")}
    >
      <canvas
        aria-hidden={true}
        width={width}
        height={height}
        ref={canvasRef}
      />
      <Wrapper
        {...restWrapperProps}
        className={[
          wrapperClassName,
          styles["children-wrapper"]
        ].join(" ")}
      >
        {children}
      </Wrapper>
    </div>
  );
};

export default memo(StarFieldBackground);

2. Copy the CSS module file

In the same folder, create a file called star-field-background.module.css and paste the following CSS.

star-field-background.module.css

.star-field {
  position: relative;
  width: 100%;

  canvas {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    z-index: 1;
    transform: scale(1.15);
  }

  .children-wrapper {
    position: relative;
    z-index: 2;
  }
}

3. Use the component

Now you can import and use the component anywhere in your project.

star-field-background-preview.jsx

import StarFieldBackground from "@/components/backgrounds/star-field-background/star-field-background";

const StarFieldBackgroundPreview = () => {
  return (
    <StarFieldBackground
      style={{
        width: "100%",
        height: "100%"
      }}
      wrapperProps={{
        style: {
          height: "100%",
          display: "grid",
          placeItems: "center",
        }
      }}
    >
      <h1 
        style={{
          color:"#fff", 
        }}
      >
        Big Bang, Again?
      </h1> 
    </StarFieldBackground>
  )
};

export default StarFieldBackgroundPreview;

Negative Speed

Am I Tachyon?

star-field-background

import StarFieldBackground from "@/components/backgrounds/star-field-background/star-field-background";

const StarFieldBackgroundReversePreview = () => {
  return (
    <StarFieldBackground
      speed={-10}
      style={{
        width: "100%",
        height: "320px"
      }}
      wrapperProps={{
        style: {
          height: "100%",
          display: "grid",
          placeItems: "center",
        }
      }}
    >
      <h1 
        style={{
          color:"#fff", 
        }}
      >
        Am I Tachyon?
      </h1> 
    </StarFieldBackground>
  )
};

export default StarFieldBackgroundReversePreview;

Props

Configure the component with the following props:

PropTypeRequiredDefaultDescription
speednumberNo5Controls the star movement speed. Positive = forward, Negative = backward.
classNamestringNoAdditional CSS classes applied to the main container.
wrapperPropsReact.HTMLAttributes<any>NoExtra props passed to the wrapper element containing the children.
wrapperTagNamestringNo"div"HTML tag used as the wrapper element around the children.
childrenReactNodeYesContent rendered on top of the moving star field.