React: Add a ripple effect to buttons

Using React, Typescript and CSS Modules

ยท

6 min read

The difference between good user experience and a great one is in the details. Small animations can give the user invaluable feedback by acknowledging their actions.

We have all been to at least one website where we have clicked a button and after a second of having no feedback had a 'did that work?' moment. We then precede to click the button 600 times in the next few seconds before crashing the whole site ๐Ÿคฏ.

Having a ripple effect on a button was made famous by Material UI - when the user clicks the button, a small ripple effect expands out from the point the user clicks allowing them to see that their click did indeed register.

The Code

Lets start off with the code for our Basic button - here we are using Typescript and CSS modules but it should be easy to adapt it for your codebase.

Button.tsx

import React from "react";
import styles from "./Button.module.css";

export const Button = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => {
  const { children, onClick, ...rest } = props;

  return (
    <button type="button" onClick={onClick} className={styles.btn} {...rest}>
      <span className={styles.content}>{children}</span>
    </button>
  );
};

Button.module.css

.btn {
  border-radius: 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  background-color: #3857e3;
  font-weight: medium;
  color: #fff;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
  overflow: hidden;
  position: relative;
  padding: 8px 16px;
  font-size: 0.875rem;
}

.btn:hover {
  filter: brightness(115%);
  cursor: pointer;
}

 /* Make sure content sits on top of ripple */
.content {
  z-index: 2;
}

The Ripple Element

For the ripple, we will use an absolutely positioned span element. This will appear when the user clicks and will disappear after the animation is finished.

Lets start with creating our ripple container and span elements, which will be added and removed using an isRippling variable. We will set the position of the element using the x and y coordinates of the users click which we will calculate later:

  return (
    <button
      type="button"
      onClick={handleClick}
      className={styles.btn}
      {...rest}
    >
      <span className={styles.content}>{children}</span>
      {isRippling && (
        <div className={styles["btn-ripple-container"]}>
          <span
            className={styles["btn-ripple"]}
            style={{
              left: x,
              top: y,
            }}
          />
        </div>
      )}
    </button>
  )

Now, we need to add the corresponding styles to our CSS file in order to add the ripple effect.

There are 3 parts to this:

1) The container - this has an absolute position and is the same size as our button.

2) The keyframe animation - this gives the span the ripple effect. It will transition in and out using opacity and will expand outwards linearly

3) The ripple span - initially this has 0 width and height and is absolutely positioned. It then uses the keyframe animation to animate when it is rendered.

.btn-ripple {
  position: absolute;
  top: 50%;  /* Sensible fallback value for position */
  left: 50%;
  transform: translate(-50%, -50%);  /* Center element when it has width and height */
  opacity: 0;
  width: 0;  /* Start width and height */
  height: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.2);
  animation: ripple 0.4s ease-in;  /* Call ripple animation */
}

/* Container element that fills button. */
.btn-ripple-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: transparent;
}

@keyframes ripple {
  0% {
    opacity: 0;
  }
  25% {
    opacity: 1;  /* Fade the ripple in and out */
  }
  100% {
    width: 200%; /* Increase the width to make the ripple effect expand  */
    padding-bottom: 200%;
    opacity: 0;
  }
}

Next we need to write some javascript to determine the following:

  • When to render the ripple element, triggering the ripple effect

  • Where to render the ripple element

We will exctract this logic to a hook to keep our code tidy. This hook should do the following:

  • On a click event, take the x and y location of where the user clicked and set this in state

  • Then set the variable isRippling to true and use a timeout to reset it to false once the ripple has finished

  • Return isRippling , the x and y coordinates of the click and the handleRippleOnClick method

const useRippling = () => {
  // Have state of X and Y coordinates. When not rippling, the coords = -1
  const [{ x, y }, setCoordinates] = React.useState({ x: -1, y: -1 });

  // Set isRippling to true when coordinates are set
  const isRippling = x !== -1 && y !== -1;

  // On click, set coordinates to location of click
  const handleRippleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    const { left, top } = e.currentTarget.getBoundingClientRect();
    setCoordinates({
      x: e.clientX - left,
      y: e.clientY - top,
    });

    // Wait for ripple to finish then set coordinates back to -1
    // This will make isRippling to false
    setTimeout(() => {
      setCoordinates({ x: -1, y: -1 });
    }, 300);
  };

  return {
    x,
    y,
    handleRippleOnClick,
    isRippling,
  };
};

Lastly, we can call this hook in our app and integrate it with the onClick Method of our button:

export const Button = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => {
  const { children, onClick, ...rest } = props;

  const { x, y, handleRippleOnClick, isRippling } = useRippling();

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    handleRippleOnClick(e);
    onClick && onClick(e);
  };

  return (
    <button
      type="button"
      onClick={handleClick}
      className={styles.btn}
      {...rest}
    >
      <span className={styles.content}>{children}</span>
      {isRippling && (
        <div className={styles["btn-ripple-container"]}>
          <span
            className={styles["btn-ripple"]}
            style={{
              left: x,
              top: y,
            }}
          />
        </div>
      )}
    </button>
  );
};

And thats it! We now have a fully working ripple effect on our button ๐ŸŽ‰. Our Button.tsx and Button.module.css files should now look like this:

Button.tsx

import React from "react";
import styles from "./Button.module.css";

const useRippling = () => {
  const [{ x, y }, setCoordinates] = React.useState({ x: -1, y: -1 });

  const isRippling = x !== -1 && y !== -1;

  const handleRippleOnClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    const { left, top } = e.currentTarget.getBoundingClientRect();
    setCoordinates({
      x: e.clientX - left,
      y: e.clientY - top,
    });

    setTimeout(() => {
      setCoordinates({ x: -1, y: -1 });
    }, 300);
  };

  return {
    x,
    y,
    handleRippleOnClick,
    isRippling,
  };
};

export const Button = (
  props: React.DetailedHTMLProps<
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    HTMLButtonElement
  >
) => {
  const { children, onClick, ...rest } = props;

  const { x, y, handleRippleOnClick, isRippling } = useRippling();

  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    handleRippleOnClick(e);
    onClick && onClick(e);
  };

  return (
    <button
      type="button"
      onClick={handleClick}
      className={styles.btn}
      {...rest}
    >
      <span className={styles.content}>{children}</span>
      {isRippling && (
        <div className={styles["btn-ripple-container"]}>
          <span
            className={styles["btn-ripple"]}
            style={{
              left: x,
              top: y,
            }}
          />
        </div>
      )}
    </button>
  );
};

Button.module.css

.btn {
  border-radius: 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  background-color: #3857e3;
  font-weight: medium;
  color: #fff;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
  overflow: hidden;
  position: relative;
  padding: 8px 16px;
  font-size: 0.875rem;
}

.content {
  z-index: 2;
}

.btn:hover {
  filter: brightness(115%);
  cursor: pointer;
}

.btn-ripple {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  opacity: 0;
  width: 0;
  height: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.2);
  animation: ripple 0.4s ease-in;
}

.btn-ripple-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: transparent;
}

@keyframes ripple {
  0% {
    opacity: 0;
  }
  25% {
    opacity: 1;
  }
  100% {
    width: 200%;
    padding-bottom: 200%;
    opacity: 0;
  }
}
ย