Stripe floating logo bubbles clone using React Hooks

Last updated 10 October 2021

Hi everyone, in this article, I will cover an exciting topic. We are going to clone animating logo bubbles from Stripe using React Hooks. To get the most of this tutorial, you need to have a basic understanding of React Hooks and CSS.

At a glance, we can see a few challenges:

  • Randomly generating and placing the bubbles on page load.
  • Animating the smooth up and down movement.
  • Looping the animation indefinitely.
  • This tutorial, however, will only focus on the second and third challenge as we will initially place the bubbles at designated coordinates.

    Getting started

    In this tutorial, we will use Next.js to build our application. First, let's create a new project called stripe-bubbles.

    mkdir stripe-bubbles
    cd stripe-bubbles

    After creating the project folder, we need to install the required dependencies.

    yarn add -E next react react-dom
    yarn add -DE @types/node @types/react typescript

    And, we need to add these scripts to our package.json.

    "scripts": {
      "dev": "next",
      "build": "next build",
      "start": "next start"
    }

    Creating the bubble

    Creating the bubble is a pretty straightforward process. We just need to create a div element with equal height and width and border-radius: 50%. Let's create a new directory called styles and add a css file called main.css. And, copy and paste the following code to main.css.

    .bubble {
      background-color: white;
      border-radius: 50%;
      box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1), 0 3px 10px rgba(0, 0, 0, 0.1);
      height: 152px;
      margin-right: 20px;
      width: 152px;
    }

    Next.js allows you to import CSS files from a JavaScript file. This is possible because Next.js extends the concept of import beyond JavaScript. To add a global stylesheet to your application, import the CSS file within pages/_app.tsx. Create a pages/_app.tsx file and import the main.css file.

    import React from 'react';
    // Modules
    import { AppProps } from 'next/app';
    // CSS
    import '../styles/main.css';
    
    const MyApp: React.FC<AppProps> = ({ Component, pageProps }) => {
      return <Component {...pageProps} />;
    };
    
    export default MyApp;

    Now, we need to create our page file to display the bubble. Create a pages/index.tsx file and add a div to display the bubble.

    import React from 'react';
    // Modules
    import { NextPage } from 'next/types';
    
    const HomePage: NextPage = () => {
      return <div className="bubble" />;
    };
    
    export default HomePage;

    You can try to run your application to see the result by running yarn dev in your terminal.

    Adding the logo

    Stripe combines all the logos into a single image file which is called a sprite sheet. This technique reduces the amount of network request being made to download the images. Instead of fetching tens of images, we just need to fetch it once. You can download the sprite sheet here and place it inside public/images directory.

    Let's get back to our code. We will use that logo sprite sheet and set it as the background for each bubble. Then, we will adjust the size of the sprite sheet with the background-size CSS property such that one logo in the image has the same size as one bubble. Update your .bubble class with the following code.

    background-image: url(/images/spritesheet.png);
    background-size: 1076px 1076px;

    And then, we can use the background-position CSS property to shift the image's position in each bubble and reveal different logos.

    .company-1 {
      background-position: -154px 0;
    }
    
    .company-2 {
      background-position: -308px 0;
    }
    
    .company-3 {
      background-position: -462px 0;
    }

    Update your pages/index.tsx file to display more logos.

    import React from 'react';
    // Modules
    import { NextPage } from 'next/types';
    
    const HomePage: NextPage = () => {
      return (
        <div>
          <div className="bubble company-1" />
          <div className="bubble company-2" />
          <div className="bubble company-3" />
        </div>
      );
    };
    
    export default HomePage;

    Placing the bubbles

    Stripe does a great work to beautifully place the bubbles in distinct positions. We will follow how they set the bubbles. As we can see, they hard-coded their positions and sizes. Let's tweak our styles/main.css file to display better UI.

    html,
    body,
    #__next {
      height: 100%;
      margin: 0;
      width: 100%;
    }
    
    .bubble {
      background-color: white;
      background-image: url(/images/spritesheet.png);
      background-size: 1076px 1076px;
      border-radius: 50%;
      box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1), 0 3px 10px rgba(0, 0, 0, 0.1);
      height: 152px;
      margin-right: 20px;
      position: absolute;
      transition: opacity ease-in-out 1s;
      width: 152px;
    }
    
    .bubbles {
      height: 600px;
      overflow: hidden;
      position: relative;
    }
    
    .bubbles-wrapper {
      background-color: salmon;
      display: flex;
      flex-direction: column;
      justify-content: center;
      height: 100%;
    }

    Then, let's copy and paste the values into our pages/index.tsx file and use it to generate all the bubbles on the fly.

    ...
    const bubbles = [
      {
        s: 0.6,
        x: 1134,
        y: 45,
      },
      {
        s: 0.6,
        x: 1620,
        y: 271,
      },
      {
        s: 0.6,
        x: 1761,
        y: 372,
      },
      {
        s: 0.6,
        x: 2499,
        y: 79,
      },
      {
        s: 0.8,
        x: 2704,
        y: 334,
      },
      {
        s: 0.6,
        x: 2271,
        y: 356,
      },
      {
        s: 0.6,
        x: 795,
        y: 226,
      },
      {
        s: 0.6,
        x: 276,
        y: 256,
      },
      {
        s: 0.6,
        x: 1210,
        y: 365,
      },
      {
        s: 0.6,
        x: 444,
        y: 193,
      },
      {
        s: 0.6,
        x: 2545,
        y: 387,
      },
      {
        s: 0.8,
        x: 1303,
        y: 193,
      },
      {
        s: 0.8,
        x: 907,
        y: 88,
      },
      {
        s: 0.8,
        x: 633,
        y: 320,
      },
      {
        s: 0.8,
        x: 323,
        y: 60,
      },
      {
        s: 0.8,
        x: 129,
        y: 357,
      },
      {
        s: 0.8,
        x: 1440,
        y: 342,
      },
      {
        s: 0.8,
        x: 1929,
        y: 293,
      },
      {
        s: 0.8,
        x: 2135,
        y: 198,
      },
      {
        s: 0.8,
        x: 2276,
        y: 82,
      },
      {
        s: 0.8,
        x: 2654,
        y: 182,
      },
      {
        s: 0.8,
        x: 2783,
        y: 60,
      },
      {
        s: 1.0,
        x: 1519,
        y: 118,
      },
      {
        s: 1.0,
        x: 1071,
        y: 233,
      },
      {
        s: 1.0,
        x: 1773,
        y: 148,
      },
      {
        s: 1.0,
        x: 2098,
        y: 385,
      },
      {
        s: 1.0,
        x: 2423,
        y: 244,
      },
      {
        s: 1.0,
        x: 901,
        y: 385,
      },
      {
        s: 1.0,
        x: 624,
        y: 111,
      },
      {
        s: 1.0,
        x: 75,
        y: 103,
      },
      {
        s: 1.0,
        x: 413,
        y: 367,
      },
      {
        s: 1.0,
        x: 2895,
        y: 271,
      },
      {
        s: 1.0,
        x: 1990,
        y: 75,
      },
    ];
    
    const backgroundPositions: string[] = [];
    
    for (let i = 0; i < 7; i++) {
      for (let j = 0; j < 7; j++) {
        backgroundPositions.push(`${-154 * j}px ${-154 * i}px`);
      }
    }
    
    const HomePage: NextPage = () => {
      return (
        <div className="bubbles-wrapper">
          <div className="bubbles">
            {bubbles.map((bubble, index) => (
              <div
                className="bubble"
                id={`bubble-${index}`}
                key={`${bubble.x} ${bubble.y}`}
                style={{
                  backgroundPosition: backgroundPositions[index],
                  transform: `translate(${bubble.x}px, ${bubble.y}px) scale(${bubble.s})`,
                }}
              />
            ))}
          </div>
        </div>
      );
    };
    ...

    You can try to run your application to see the result by running yarn dev in your terminal.

    Animating the bubbles

    Finally, we arrive at the last but most exciting part of this tutorial. We are going to animate the bubbles such that they move horizontally across the screen. We will also add some "noise" to make the bubbles move more naturally. To do that, we will utilise noisejs. Let's add noisejs into our project dependencies by running the following command.

    yarn add -E noisejs

    And then, import noisejs into our pages/index.tsx file.

    ...
    import { Noise } from 'noisejs';
    ...

    Perlin noise is an algorithm for generating "randomness". A normal Math.random() produces random values that have no relationship with the previously generated values. Perlin noise, on the other hand, allows us to create a sequence of "random" values that create a smooth and organic experience. As the main focus of this tutorial is to clone logo bubbles from Stripe, I will keep the explanation about perlin noise simple. If you'd like to learn more about perlin noise, you can read chapter 1.6 of The Nature of Code.

    Let's get back to our code. Add the following constants into our pages/index.tsx file.

    ...
    const CANVAS_WIDTH = 3000;
    // The amplitude. The amount the noise affects the movement.
    const NOISE_AMOUNT = 5;
    // The frequency. Smaller for flat slopes, higher for jagged spikes.
    const NOISE_SPEED = 0.004;
    // Pixels to move per frame. At 60fps, this would be 18px a sec.
    const SCROLL_SPEED = 0.3;
    
    const bubbles = [
    ...

    And then, initialise the noise object.

    ...
    const noise = new Noise();
    
    const HomePage: NextPage = () => {
    ...

    And then, update your page component with the following code.

    const HomePage: NextPage = () => {
      const animationRef = React.useRef<number>();
      const bubblesRef = React.useRef(
        bubbles.map((bubble) => ({
          ...bubble,
          noiseSeedX: Math.floor(Math.random() * 64000),
          noiseSeedY: Math.floor(Math.random() * 64000),
          xWithNoise: bubble.x,
          yWithNoise: bubble.y,
        })),
      );
    
      const [isReady, setReady] = React.useState(false);
    
      React.useEffect(() => {
        setTimeout(() => {
          setReady(true);
        }, 200);
    
        animationRef.current = requestAnimationFrame(animate);
    
        return () => {
          if (animationRef.current) {
            cancelAnimationFrame(animationRef.current);
          }
        };
      }, []);
    
      function animate() {
        bubblesRef.current = bubblesRef.current.map((bubble, index) => {
          const newNoiseSeedX = bubble.noiseSeedX + NOISE_SPEED;
          const newNoiseSeedY = bubble.noiseSeedY + NOISE_SPEED;
    
          const randomX = noise.simplex2(newNoiseSeedX, 0);
          const randomY = noise.simplex2(newNoiseSeedY, 0);
    
          const newX = bubble.x - SCROLL_SPEED;
    
          const newXWithNoise = newX + randomX * NOISE_AMOUNT;
          const newYWithNoise = bubble.y + randomY * NOISE_AMOUNT;
    
          const element = document.getElementById(`bubble-${index}`);
    
          if (element) {
            element.style.transform = `translate(${newXWithNoise}px, ${newYWithNoise}px) scale(${bubble.s})`;
          }
    
          return {
            ...bubble,
            noiseSeedX: newNoiseSeedX,
            noiseSeedY: newNoiseSeedY,
            x: newX < -200 ? CANVAS_WIDTH : newX,
            xWithNoise: newXWithNoise,
            yWithNoise: newYWithNoise,
          };
        });
    
        animationRef.current = requestAnimationFrame(animate);
      }
    
      return (
        <div className="bubbles-wrapper">
          <div className="bubbles">
            {bubbles.map((bubble, index) => (
              <div
                className="bubble"
                id={`bubble-${index}`}
                key={`${bubble.x} ${bubble.y}`}
                style={{
                  backgroundPosition: backgroundPositions[index],
                  opacity: isReady ? 1 : 0,
                  transform: `translate(${bubble.x}px, ${bubble.y}px) scale(${bubble.s})`,
                }}
              />
            ))}
          </div>
        </div>
      );
    };

    As you can see, instead of using useState, I use useRef to keep the positions. By using useRef, we will not cause the component to re-render due to state changes. Therefore, it has a better performance compared to useState in this scenario. I've been trying a couple of solutions before coming to this final and most performant solution. For example, I tried to make each bubble as a separate component which has its own state and does the animation individually. However, this solution consumes a lot of processing powers.

    You can try to run your application to see the result by running yarn dev in your terminal.

    I hope this tutorial is useful for you and has helped you to learn something new.