Waving Dots

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

Usage

waving-dots-background-preview.tsx
import WavingDotsBackground from "@/registry/backgrounds/waving-dots-background/waving-dots-background";

const WavingDotsBackgroundPreview = () => {
  return (
    <div className="relative w-full h-full grid place-items-center">
      <WavingDotsBackground />
      <HeroSection />
    </div>
  );
};

const HeroSection = () => (
  <section className="relative z-10 flex items-center justify-center p-8">
    <div className="mx-auto max-w-md text-center">
      <div className="mb-5 lg:mb-6 inline-flex items-center leading-0 rounded-full border border-gray-300 dark:border-white/15 px-4 py-4 text-sm font-medium text-gray-800 dark:text-white backdrop-blur-md">
        ⚛️ Background Component
      </div>
      <h1 className="text-4xl font-bold tracking-tight text-gray-800 dark:text-white lg:text-5xl text-balance">
        Physics Is Doing the Heavy Lifting
      </h1>
      <p className="mx-auto mt-6 max-w-md text-md lg:text-lg leading-6 text-gray-500 dark:text-white/70 text-balance">
        A synchronized field of oscillating particles converges toward equilibrium. It only looks effortless.
      </p>
      <div className="mt-6 lg:mt-8">
        <button className="rounded-full border border-gray-300 dark:border-white/20 border-gray-50 bg-white/10 px-8 py-3 text-sm font-semibold text-gray-800 dark:text-white backdrop-blur-md transition-all duration-300 hover:bg-gray-100/50 dark:hover:bg-white/20">
          Get Component
        </button>
      </div>
    </div>
  </section>
);

export default WavingDotsBackgroundPreview;

Component

Install the following packages before using this component.

Tailwind CSS
waving-dots-background.tsx
import cn from "@/utils/cn";
import { memo, useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type ComponentProps } from "react";

export type WavingDotsBackgroundProps = {
  dotScale?: number;
  dotColor?: string;
  gap?: number;
  speed?: number;
  offset?: number;
} & ComponentProps<"div">;

const map = (
  value:number,
  start1:number,
  stop1:number,
  start2:number,
  stop2:number
): number => {
  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 {
  x: number;
  y: number;
  length: number;
  dirX: number;
  dirY: number;
  time: number;
  magScale: number;

  constructor(
    x: number,
    y: number,
    cx: number,
    cy: number,
    cd: number,
    waves: number,
  ) {
    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: number) {
    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: CanvasRenderingContext2D,
    radius: number,
    dotColor: string,
  ) {
    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 WavingDotsBackground = (
  props: WavingDotsBackgroundProps,
) => {
  const {
    children,
    dotScale = 0.5,
    dotColor = "rgba(127, 127, 127, 0.5)",
    gap = 5,
    speed = 0.5,
    offset = 100,
    className = "",
    ...restProps
  } = props;

  const canvasRef = useRef<HTMLCanvasElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const rafId = useRef<ReturnType<typeof requestAnimationFrame>>(null);

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

  const ctx: CanvasRenderingContext2D | null | undefined = 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(() => {
    if (!ctx) return;
    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(() => {
    if (!containerRef.current) return;
    const updateContainerDimensions = () => {
      if (!containerRef.current) return;
      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 () => {
      if (rafId.current) {
        cancelAnimationFrame(rafId.current);
      }
    };
  }, [mounted, render]);

  return (
    <div 
      {...restProps}
      className={cn("absolute top-[0] left-[0] right-[0] bottom-[0] overflow-hidden", className)}
      ref={containerRef}
    >
      <canvas
        aria-hidden={true}
        width={canvasWidth}
        height={canvasHeight}
        ref={canvasRef}
        className="w-full h-full"
      />
    </div>
  );

};

export default memo(WavingDotsBackground);

Component API

PropTypeRequiredDefaultDescription
dotScalenumberNo0.5Scale 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.
gapnumberNo5Distance 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.
classNamestringNoAdditional CSS class names applied to the root container.
styleReact.CSSPropertiesNoInline styles applied to the root container.

Support the Project

If you find this component useful, consider starring the repository on GitHub. Found a bug or have a suggestion? Open an issue to help improve it.