Star Field

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

Big Bang, Again?

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;

Installation

Run the command from your project root directory (the folder that contains package.json).

Terminal / Console

npx mosaicui-cli@latest backgrounds/star-field-background

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);
};

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

  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;
  };

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

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

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

  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);
    const x2 = map(this.x / this.sz, -1, 1, -this.canvasWidth / 2, this.canvasWidth / 2);
    const y2 = 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);
    this.ctx.beginPath();
    this.ctx.fillStyle = this.starColor;
    this.ctx.fill();
    this.ctx.ellipse(x1, y1, radius, radius, 0, 0, 360, false);
    this.ctx.strokeStyle = this.starTrailColor;
    this.ctx.lineWidth = 1;
    this.ctx.moveTo(x1, y1);
    this.ctx.lineTo(x2, y2);
    this.ctx.stroke();
    this.ctx.closePath(); 
    this.sz = this.z;
  };
}

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 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 { devicePixelRatio, canvasWidth, canvasHeight } = useMemo(() => {
    const devicePixelRatio = Math.max(1, globalThis.devicePixelRatio || 1);
    return {
      devicePixelRatio,
      canvasWidth: width * devicePixelRatio,
      canvasHeight: height * devicePixelRatio,
    };
  }, [width, height]);

  const starsCount = Math.min(500, width * height * 0.001);

  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.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
    ctx.fillStyle = spaceColor;
    ctx.fillRect(0, 0, canvasWidth, canvasHeight);
    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,
    devicePixelRatio,
    width,
    height,
    canvasWidth,
    canvasHeight,
    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={canvasWidth}
        height={canvasHeight}
        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;
  background: rgb(0, 0, 0);

  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;
  }
}

Examples

Discover how supported props transform components to match your needs.

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.