Wavy Dots

A subtle animated background where dots gently pulse toward the center in a harmonic wave motion.

Wavy Dots Background

wavy-dots-background-preview.jsx

import WavyDotsBackground from "@/components/backgrounds/wavy-dots-background/wavy-dots-background";

const WavyDotsBackgroundPreview = () => {
  return (
    <WavyDotsBackground
      dotColor="rgba(162,201,229,0.5)"
      style={{
        width: "100%",
        height: "100%"
      }}
      wrapperProps={{
        style: {
          height: "100%",
          display: "grid",
          placeItems: "center",
        }
      }}
    >
      <h2>Wavy Dots Background</h2>
    </WavyDotsBackground>
  )
};

export default WavyDotsBackgroundPreview;

Installation

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

Terminal / Console

npx mosaicui-cli@latest backgrounds/wavy-dots-background

1. Copy the component file

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

wavy-dots-background.jsx

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

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 Dot {
  constructor(x, y, cx, cy, cd, waves) {
    const dx = cx - x;
    const dy = cy - y;
    this.x = x;
    this.y = y;
    this.length = Math.hypot(dx, dy);
    this.dirX = dx / this.length;
    this.dirY = dy / this.length;
    this.time = map(this.length, 0, cd, 0, (Math.PI * 2 * waves));
    this.magScale = map(this.length, 0, cd, 0.1, 0);
  }

  update(dt) {
    this.time += dt;
    this.time %= (Math.PI * 2);
  }

  getPosition() {
    const wave = (Math.sin(this.time) + 1) / 2;
    const offset = wave * this.magScale * this.length;
    return {
      x: this.x + this.dirX * offset,
      y: this.y + this.dirY * offset,
    };
  }

  draw(ctx, radius, dotColor) {
    const p = this.getPosition();
    ctx.beginPath();
    ctx.arc(p.x, p.y, radius, 0, (Math.PI * 2));
    ctx.fillStyle = dotColor;
    ctx.fill();
    ctx.closePath();
  }
}

const WavyDotsBackground = (props) => {
  const {
    children,
    dotScale = 0.5,
    dotColor = "rgba(127, 127, 127, 0.5)",
    gap = 10,
    speed = 0.5,
    offset = 100,
    className = "",
    wrapperTagName = "div",
    wrapperProps = {},
    ...restProps
  } = props;

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

  const Wrapper = wrapperTagName || "div";

  const containerRef = useRef(null);
  const canvasRef = useRef(null);
  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 _dotRadius = useMemo(() => (
    2 * Math.min(Math.max(0.1, dotScale), 5)
  ), [dotScale]);

  const _gap = useMemo(() => (
    Math.max(5, gap)
  ), [gap]);

  const _speed = useMemo(() => (
    0.005 + (0.1 * Math.min(Math.max(0.1, speed), 0.9))
  ), [speed]);

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

  const dots = useMemo(() => {
    const dots = [];
    const wavesCount = 4;
    const cw = width / 2;
    const ch = height / 2;
    const cd = Math.hypot(width, height);
    const gapIncrement = _gap + (_dotRadius * 2);
    for (let y = -offset; y <= (height + offset); y += gapIncrement) {
      for (let x = -offset; x <= (width + offset); x += gapIncrement) {
        dots.push(new Dot(x, y, cw, ch, cd, wavesCount));
      }
    }
    return dots;
  }, [width, height, _gap, _dotRadius, offset]);

  const render = useCallback(() => {
    ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
    dots.forEach(dot => {
      dot.update(_speed);
      dot.draw(ctx, _dotRadius, dotColor);
    });
    rafId.current = requestAnimationFrame(render);
  }, [
    ctx,
    devicePixelRatio,
    canvasWidth,
    canvasHeight,
    dots,
    _speed,
    _dotRadius,
    dotColor,
  ]);

  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
      {...restProps}
      ref={containerRef}
      className={[
        className,
        styles["wavy-dots-background"],
      ].join(" ")}
    >
      <canvas
        aria-hidden={true}
        width={canvasWidth}
        height={canvasHeight}
        ref={canvasRef}
      />
      <Wrapper
        {...restWrapperProps}
        className={[
          wrapperClassName,
          styles["wrapper"],
        ].join(" ")}
      >
        {children}
      </Wrapper>
    </div>
  );

};

export default memo(WavyDotsBackground);

2. Copy the CSS module file

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

wavy-dots-background.module.css

.wavy-dots-background {
  position: relative;

  >canvas {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    z-index: 1;
  }

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

Examples

Discover how supported props transform components to match your needs.

Dot Scale

wavy-dots-background

import { useState } from "react";
import WavyDotsBackground from "@/components/backgrounds/wavy-dots-background/wavy-dots-background";

const WavyDotsBackgroundDotScalePreview = () => {
  const [dotScale, setDotScale] = useState(1);

  const handleDotScaleChange = (e) => {
    setDotScale(parseFloat(e.target.value));
  };

  return (
    <WavyDotsBackground
      dotScale={dotScale}
      style={backgroundStyles}
      wrapperProps = {{
        style: wrapperStyles,
      }}
    >
      <label htmlFor="wavy-dots-scale-input">
        Dot Scale: {dotScale}
      </label>
      <input
        type="range"
        min="0.1"
        max="5"
        step="0.1"
        id="wavy-dots-scale-input"
        value={dotScale}
        onChange={handleDotScaleChange}
      />
    </WavyDotsBackground>
  );
};

const backgroundStyles = {
  width: "100%",
  height: "100%",
  display: "grid",
  placeItems: "center",
};
const wrapperStyles = {
  display: "grid",
  placeItems: "center",
  gap: "8px",
};

export default WavyDotsBackgroundDotScalePreview;

Speed

wavy-dots-background

import { useState } from "react";
import WavyDotsBackground from "@/components/backgrounds/wavy-dots-background/wavy-dots-background";

const WavyDotsBackgroundSpeedPreview = () => {
  const [speed, setSpeed] = useState(0.5);

  const handleSpeedChange = (e) => {
    setSpeed(parseFloat(e.target.value));
  };

  return (
    <WavyDotsBackground
      speed={speed}
      style={backgroundStyles}
      wrapperProps = {{
        style: wrapperStyles,
      }}
    >
      <label htmlFor="wavy-dots-speed-input">
        Speed: {speed}
      </label>
      <input
        type="range"
        min="0.1"
        max="0.9"
        step="0.1"
        id="wavy-dots-speed-input"
        value={speed}
        onChange={handleSpeedChange}
      />
    </WavyDotsBackground>
  );
};

const backgroundStyles = {
  width: "100%",
  height: "100%",
  display: "grid",
  placeItems: "center",
};
const wrapperStyles = {
  display: "grid",
  placeItems: "center",
  gap: "8px",
};

export default WavyDotsBackgroundSpeedPreview;

Gap

wavy-dots-background

import { useState } from "react";
import WavyDotsBackground from "@/components/backgrounds/wavy-dots-background/wavy-dots-background";

const WavyDotsBackgroundGapPreview = () => {
  const [gap, setGap] = useState(10);

  const handleGapChange = (e) => {
    setGap(parseFloat(e.target.value));
  };

  return (
    <WavyDotsBackground
      gap={gap}
      style={backgroundStyles}
      wrapperProps = {{
        style: wrapperStyles,
      }}
    >
      <label htmlFor="wavy-dots-gap-input">
        Gap: {gap}
      </label>
      <input
        type="range"
        min="5"
        max="20"
        step="1"
        id="wavy-dots-gap-input"
        value={gap}
        onChange={handleGapChange}
      />
    </WavyDotsBackground>
  );
};

const backgroundStyles = {
  width: "100%",
  height: "100%",
  display: "grid",
  placeItems: "center",
};
const wrapperStyles = {
  display: "grid",
  placeItems: "center",
  gap: "8px",
};

export default WavyDotsBackgroundGapPreview;

Props

Configure the component with the following props:

PropTypeRequiredDefaultDescription
childrenReact.ReactNodeNoContent rendered above the animated background.
dotScalenumberNo1Scale factor for each dot. Accepts values between 0.1 and 5.
dotColorstringNo"rgba(127, 127, 127, 0.5)"Color of the dots. Supports RGB, RGBA, or HEX color formats.
gapnumberNo10Distance between dots in the grid (in px). Minimum value is 5.
speednumberNo0.5Speed of the oscillation animation. Accepts values between 0.1 and 0.9.
offsetnumberNo100Extra area (in px) outside the canvas where additional dots are generated to ensure the screen remains filled during motion. Reduce this if using radial masking near the corners.
classNamestringNo""Additional CSS class names applied to the root container.
styleReact.CSSPropertiesNoInline styles applied to the root container.
wrapperTagNamestringNo"div"HTML tag used as the wrapper element around the children.
wrapperPropsReact.HTMLAttributes<HTMLElement>No{}Additional props passed to the wrapper element containing the children.