Building a Dynamic Background Effect
Photo by Anna Zakharova on Unsplash

Building a Dynamic Background Effect

How to create a randomized, animated icon background for any content section.

8 min read

In this article, we’ll be building a subtle animation effect where background elements and icons are randomly positioned and move in the direction of a mouse cursor.

This particular example was inspired by the homepage of Codewars.

dynamic background hover animation effect
dynamic background hover animation effect

An effect like this can help add depth and interactivity to an otherwise static content section.

This article will be using React, but the overall concept can be applied to any frontend stack.

Getting Started

Start by creating a file named Container.js. This component will output a hero section and contains pretty much everything we're building.

In this Container component, we set up a state value, along with some refs and HTML markup.


import { useEffect, useRef, useState } from "react";
import cn from "classnames";
import { icons } from "./icons";
import "./styles.css";

export const Container = () => {
  const [hasMounted, setHasMounted] = useState(false);
  const heroRef = useRef();
  const iconsRef = useRef([]);
  const iconCount = icons?.length;
  const { offsetWidth: width, offsetHeight: height } = heroRef?.current ?? {};
  
  useEffect(() => {
    setHasMounted(true);
  }, []);

  return (
    <div className="hero" ref={heroRef}>
      {hasMounted && (
        <div className="icon-layer">
          {icons.map(({ size, type, icon }, i) => {})}
        </div>
      )}
      <div className="hero-copy">
        <h1>
          Grow your
          <br /> <span>frontend skills</span>
        </h1>
        <h3>Learn advanced React, CSS, and JavaScript techniques.</h3>
        <a href="#">Get Started</a>
      </div>
    </div>
  );
};

The heroRef tells us about the hero section, including its dimensions so we know when the cursor is moving through it. The iconsRef contains all of the icons and information about them.

The hasMounted state value we set above is being used to delay the rendering of the background elements until their positioning can be calculated. Without this, you’d see all of the the items stacked on top of each other on the very first render, then be positioned correctly.

We’ll revisit this Container component in a minute.

Icons

Next add a new file named icons.js.

The icons we're using are from Tabler Icons. You can also get them from the CodeSandbox demo.

Each item in the icons array has a size, type, and may be empty if icon is null. If no icon is passed, it'll just be an outline or filled-in element alongside the icons.

The import syntax below may look different depending on your project.


import { ReactComponent as ReactIcon } from "./icons/react.svg";
import { ReactComponent as AngularIcon } from "./icons/angular.svg";
import { ReactComponent as DenoIcon } from "./icons/deno.svg";
import { ReactComponent as JSIcon } from "./icons/javascript.svg";
import { ReactComponent as NPMIcon } from "./icons/npm.svg";
import { ReactComponent as TSIcon } from "./icons/typescript.svg";
import { ReactComponent as VueIcon } from "./icons/vue.svg";
import { ReactComponent as ReduxIcon } from "./icons/redux.svg";

export const icons = [
  { size: "lg", type: "fill", icon: <ReactIcon /> },
  { size: "sm", type: "outline", icon: null },
  {
    size: "lg",
    type: "outline",
    icon: <AngularIcon />,
  },
  { size: "sm", type: "fill", icon: null },
  { size: "lg", type: "outline", icon: <DenoIcon /> },
  { size: "sm", type: "outline", icon: null },
  { size: "md", type: "fill", icon: <VueIcon /> },
  { size: "sm", type: "fill", icon: null },
  { size: "md", type: "outline", icon: null },
  { size: "sm", type: "outline", icon: null },
  { size: "md", type: "outline", icon: null },
  { size: "lg", type: "outline", icon: <JSIcon /> },
  { size: "md", type: "fill", icon: null },
  { size: "sm", type: "outline", icon: null },
  { size: "lg", type: "fill", icon: null },
  { size: "md", type: "fill", icon: null },
  { size: "sm", type: "fill", icon: <ReduxIcon /> },
  { size: "md", type: "fill", icon: null },
  { size: "sm", type: "fill", icon: null },
  { size: "lg", type: "fill", icon: <TSIcon /> },
  { size: "sm", type: "outline", icon: null },
  { size: "md", type: "fill", icon: <NPMIcon /> },
  { size: "md", type: "outline", icon: null },
];


Random Positioning

Now that the icons are defined, we need to render them and provide a randomized position when the component is first mounted.

Revisiting the Container component we added previously, we’ll map over each item.

Each one will be assigned a position based on its order in the icons array. We divide all of the elements into rows and columns, ensuring they are laid out in a grid pattern and determine the width and height based on the number of cols and rows.


<div className="icon-layer">
  {icons.map(({ size, type, icon }, i) => {
	  // Determine the rows and columns based on the amount of icons we have
    const rows = Math.ceil(Math.sqrt(iconCount));
    const cols = Math.ceil(iconCount / rows);

    // Determines the size of each sell
    const cellWidth = width / cols;
    const cellHeight = height / rows;
  })}
</div>

Next we'll determine the specific row and col that each item appears in using its index. This ensures that every icon gets its own “cell” in the grid.


{icons.map(({ size, type, icon }, i) => {
  ...
	
  const row = Math.floor(i / cols);
  const col = i % cols
})}

A slight offset within each cell (75px) is also added. Feel free to experiment with this value.


{icons.map(({ size, type, icon }, i) => {
  ...
  
  const xOffset = Math.random() * (cellWidth - 75);
  const yOffset = Math.random() * (cellHeight - 75);

  const baseX = col * cellWidth;
  const baseY = row * cellHeight;

  const xPos = baseX + xOffset;
  const yPos = baseY + yOffset;
})}

The xPos and yPos constants define the exact final positioning in each cell.

With everything calculated, return each element as a span.


{icons.map(({ size, type, icon }, i) => {
  ...
  
  return (
    <span
      key={i}
      ref={refItem =>
        (iconsRef.current[i] = {
          refItem,
          // Save initialPositions object and size on the ref itself for easy access later
          initialPositions: {
            xPos,
            yPos,
          },
          size,
        })
      }
      className={cn("icon", type, size, `pos-${i}`)}
      style={{
        transform: `translateX(${xPos}px) translateY(${yPos}px)`,
      }}
    >
      {icon && icon}
    </span>
  );
})}

Notice how we’re storing an initialPositions object and size values onto each ref. Often you’ll see React refs used to get references to an HTML element, but they can be used to store other types of data too. Here we’re using refs for both because getting the translateX and translateY values from an element is not very straightforward. Since we already know what those values are to begin with, we may as well store them to reference later. We'll revisit this in a few minutes.

CSS

Now let’s add some CSS into a new file named styles.css.

This section uses modern CSS features like nesting and variables. If you’re unfamiliar with either, please check out Dynamic Styling with CSS Variables and Nesting in CSS to learn more.

First we have some CSS variables. These are defined at the root level and set some commonly used values like colors and border radius.


:root {
  --border-radius: 8px;
  --black: #000;
  --white: #fff;
  --red: #ad1d1d;
  --gray: #414141;
}

A .hero class defines the layout and background of the hero section. We use a radial gradient to create a soft background effect. The hero content itself is centered using flexbox.


.hero {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  margin: 0 auto;
  padding: 150px 0;
  position: relative;
  height: 100vh;
  overflow: hidden;
  background: radial-gradient(
    circle,
    hsla(30, 100%, 8%, 1) 0%,
    rgb(0, 0, 0) 100%
  );
}

The .hero-copy class styles the copy for both the heading and sub-heading.


.hero-copy {
  text-align: center;
  color: var(--white);
  max-width: 750px;
  z-index: 10;

  h1 {
    font-size: 76px;
    margin: 0 0 30px;
    font-weight: 300;

    span {
      background: linear-gradient(30deg, #ff8000 40%, var(--red) 70%);
      background-clip: text;
      -webkit-background-clip: text;
      color: transparent;
    }
  }

  h3 {
    font-size: 20px;
    font-weight: 300;
  }

  a {
    margin-top: 20px;
    display: inline-block;
    color: var(--white);
    text-decoration: none;
    font-size: 20px;
    border-radius: 8px;
    background: var(--red);
    padding: 20px 30px;
  }
}

To learn more about how to apply gradients and other examples of CSS text effects, check out a past article - CSS Text Effects - Five Minimal Examples.

The .icon-layer class ensures that the icons sit above the background but behind the text. This layer covers the entire hero section, with icons positioned absolutely within it.


.icon-layer {
  position: absolute;
  height: 100%;
  width: 100%;
}

Finally we have the .icon class. This sets the positioning, color, and size for each element. We’ll also add an animation keyframe for a basic fade-in effect when the page first loads.


.icon {
  position: absolute;
  border-radius: 12px;
  transition: transform 1s ease-out;
  animation: iconFadeIn 7s ease forwards;
  padding: 10px;

  svg {
    width: 100%;
    height: 100%;
  }

  path {
    color: var(--white);
  }

  &.outline {
    border: 1px solid var(--gray);
  }

  &.fill {
    background: var(--gray);
  }

  &.sm {
    height: 40px;
    width: 40px;
    z-index: 1;
  }

  &.md {
    height: 60px;
    width: 60px;
    z-index: 3;
  }

  &.lg {
    height: 80px;
    width: 80px;
    z-index: 5;
  }
}

@keyframes iconFadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 0.6;
  }
}

Moving the Icons

So far we have background elements and icons randomly positioned on page load. Let’s add some movement!

First, let’s revisit the useEffect we added previously to the Container component.

We’ll use the heroRef that we set up and add a mousemove event to the hero section. This gives us a way to detect when the mouse cursor is moving through that section specifically. We'll remove the event if the component unmounts.


  export const Container = () => {
	...
	
	useEffect(() => {
	  setHasMounted(true);
	  
	  const refVal = heroRef?.current;
	  refVal.addEventListener("mousemove", onHover);
	
	  return () => refVal.removeEventListener("mousemove", onHover);
	}, []);
	
	return ( ... );
}

Then we have the onHover function itself.

This function gets the current X and Y position of the cursor and determines how to position each icon accordingly. It applies these hoverX and hoverY values onto the original position (xPos/yPos) so that the element doesn’t move too far from where it was originally rendered.


export const Container = () => {
  ...
	
  const onHover = ({ clientX, clientY }) => {
	  iconsRef.current.forEach(item => {
	    const { xPos, yPos } = item?.initialPositions ?? {};
	    const size = item.size;
	    const { hoverX, hoverY } = setThresholds(size, clientX, clientY);
	
	    item.refItem.style.transform = `translate(${xPos + hoverX}px, ${yPos + hoverY}px)`;
	  });
  };
	
  ...
	
  return ( ... );
}

This is why we stored the initialPositions object directly on the ref. We needed an easy way to get the original X and Y position for each item so that we could modify it with the hover position. To get these values cleanly from the item ref, you’d otherwise need to use regex or string manipulation because it would look exactly how we set it initially, for example, translateX(100px) translateY(150px).

Finally we’ll add a function named setThresholds that takes the icon size and cursor position and determines how far to actually move each element, depending on its size.


const setThresholds = (size, x, y) => {
  if (size === "sm") {
    return {
      hoverX: x * 0.03,
      hoverY: y * 0.03,
    };
  }

  if (size === "md") {
    return {
      hoverX: x * 0.06,
      hoverY: y * 0.06,
    };
  }

  if (size === "lg") {
    return {
      hoverX: x * 0.1,
      hoverY: y * 0.1,
    };
  }
};

These thresholds are what provide a sort of “parallax” effect, where the larger elements move more than the smaller ones. Feel free to experiment with these values.

Limitations

We’ve finished the background animation effect, but there are a few final things to consider.

Responsiveness
In this example, the icon positions are only calculated on page load. You would need to recalculate those for a fully dynamic effect if the screen were to be resized after the pages loads.

Accessibility
In a real-world situation, you will have users who would rather not see animations like this. Consider reducing or removing the animations for them. Be sure to check out Reducing Motion in Animations for more information.

Summary

In this article, we created a subtle, interactive hero section that responds to mouse movements. This effect is perfect for adding a dynamic touch to your website’s landing page or any prominent section.

Feel free to experiment with different icon layouts, movement speeds, and styles to make the effect your own!