Creating Material Design Ripple Effects with SVG

An in-depth tutorial on how to build the ripple effect outlined under Google Material Design’s Radial Action specification and combine it with the powers of SVG and GreenSock.

With the advent of Google’s Material Design came a visual language that set out to create a unified experience across platforms and devices. Google’s examples depicted through their Animation section of the Material Guidelines has become so identifiable in the wild that many have come to know these interactions as part of the Google brand.

In this tutorial we’ll show you one way of building the ripple effect specifically outlined under Radial Action of the Google Material Design specification by combining it with the powers of SVG and GreenSock.

Responsive Action

Google defines Responsive Interaction using Radial Action as follows:

Radial action is the visual ripple of ink spreading outward from the point of input.

The connection between an input event and on-screen action should be visually represented to tie them together. For touch or mouse, this occurs at the point of contact. A touch ripple indicates where and when a touch occurs and acknowledges that the touch input was received.

Transitions, or actions triggered by input events, should visually connect to input events. Ripple reactions near the epicenter occur sooner than reactions further away.

Google makes it very clear that input feedback should take place from it’s origin and spread outwards. For example, if a user clicks a button directly in the center, this ripple will expand outward from that point of initial contact. This is how we indicate where and when a touch occurs in order to acknowledge to the user that input was received.

Radial Action In SVG

The ripple technique has been authored by many developers using primarily CSS techniques such as @keyframes, transitions, transforms pseudo trickery, border-radius and even extra markup such as a span or div. Instead of using CSS, let’s take a look at how SVG can be used to create this radial action using GreenSock’s TweenMax library for the motion.

Creating The SVG

Believe it or not you don’t need a fancy application like Adobe Illustrator or even Sketch to author this effect. The markup for the SVG can be written using a few XML tags that might already be familiar and in use with your own work.

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <symbol viewBox="0 0 100 100"></symbol>
</svg>

For those using SVG sprites for icons, you’ll notice the use of <symbol>. The symbol element allows authors to match the correlating XML within individual symbol instances and subsequently instantiate them—or in other words—use them across an application like a stamp. Each instance stamped is identical to it’s sole creator; the symbol it resides within.

Symbol elements accept attributes such as viewBox and preserveAspectRatio that provide a symbol the scale-to-fit ability within a rectangular viewport defined by the referencing use element. Sara Soueidan wrote a wonderful article and built an interactive tool to help understand the viewBox coordinate system once and for all. Simply put, we’re defining the initial x and y coordinate values (0,0) and finally defining a width and height (100,100) of the SVG canvas.

The next piece to this XML puzzle is adding a shape we intend to animate as the ripple. This is where the circle element comes in.

<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <symbol viewBox="0 0 100 100">
    <circle />
  </symbol>
</svg>

The circle will need a bit more information then what it posseses in order to display correctly within the SVG’s viewBox.

<circle cx="1" cy="1" r="1"/>

The attributes cx and cy are coordinate positions relative to the viewBox of the SVG; symbol in our case. In order to make the click feel natural, we’ll need to make sure the trigger point rests directly underneath the user’s finger tip when input is received.

circle-coordinates-diagram

The attributes for the middle example of this diagram create a 2px x 2px circle with a radius of 1px. This will ensure our circle doesn’t crop like we see in the bottom example of the diagram.

<div style="height: 0; width: 0; position: absolute; visibility: hidden;" aria-hidden="true">
  <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" focusable="false">
    <symbol id="ripply-scott" viewBox="0 0 100 100">
      <circle id="ripple-shape" cx="1" cy="1" r="1"/>
    </symbol>
  </svg>
</div>

For the final touches, we’ll wrap it with a div containing inline CSS for brevity to hide the sprite. This prevents it from taking up space in the page when rendered.

As of this writing, an SVG sprite containing symbol blocks that reference it’s own gradient definition—as you’ll see in the demos— by ID cannot find the gradient and render it properly; the reason for the visibility property used in place of display: none as the entire gradient fails on Firefox and most other browsers.

The use of focusable="false" is required for all IE’s up to 11; with the exception of Edge as it has yet to be tested. It was a proposal from the SVG 1.2 specification describing how keyboard focus control should work. IE implemented this, but no one else did. For consistency with HTML, and also for greater control, SVG 2 is adopting tabindex instead. HT to Amelia Bellamy-Royds for the schooling on this tip.

Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.

Making Markup

Writing solid markup is the reason we all get up in the morning so let’s write a semantic button element to use as our object that will reveal this ripple.

<button>Click for Ripple</button>

The markup structure for the button that most are familiar with is straight forward including some filler text.

<button>
  Click for Ripple
  <svg>
    <use xlink:href="#ripply-scott"></use>
  </svg>
</button>

To take advantage of the symbol element created earlier, we’ll need a way to reference it by utilizing the use element inside the button’s SVG referencing the symbol’s ID attribute value.

<button id="js-ripple-btn" class="button styl-material">
  Click for Ripple
  <svg class="ripple-obj" id="js-ripple">
    <use width="100" height="100" xlink:href="#ripply-scott" class="js-ripple"></use>
  </svg>
</button>

The final markup possesses additional attributes for CSS and JavaScript hooks. Attribute values beginning with “js-” denote values only present in JavaScript and thereby removing them would hinder interaction, but not affect styling. This helps differentiate CSS selectors from JavaScript hooks in order to avoid one another from causing confusion when removal or updating is required in the future.

The use element must have a width and height defined otherwise it will not be visible to the viewer. You could also define this in CSS if you decided against having it directly on the element itself.

Styling The Joint

When it comes time for authoring the CSS, very little is required to achieve the desired result.

.ripple-obj {
  height: 100%;
  pointer-events: none;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 0;
  fill: #0c7cd5;
}

.ripple-obj use {
  opacity: 0;
}

Here’s what remains when removing the declarations used for general styling. The use of pointer-events eliminates the SVG ripple from becoming the target of mouse events as we only need the parent object to react; the button element.

The ripple must be invisible initially hence the opacity value set to zero. We’re also positioning the ripple object in the top left of the button. We could center the ripple shape, but since this event occurs based on user interaction it’s meaningless to fret over position.

Giving It Life

Breathing life into this interaction is what it’s all about and exactly what Material Design guidelines document as one of the most crucial parts of their visual language.

<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.17.0/TweenMax.min.js"></script>
<script src="js/ripple.js"></script>

To animate the ripple we’ll be using GreenSock’s TweenMax library because it’s one of the best libraries out there to animate objects using JavaScript; especially when it comes to the headaches involved with animating SVG cross-browser.

var ripplyScott = (function() {}
  return {
    init: function() {}
  };
})();

The pattern we’re going to be using is what’s called a module pattern as it helps to conceal and protect the global namespace.

var ripplyScott = (function() {}
  var circle = document.getElementById('js-ripple'),
      ripple = document.querySelectorAll('.js-ripple');

  function rippleAnimation(event, timing) {…}
})();

To kick things off we’ll grab a few elements and store them in variables; particularly the use element and it’s containing svg within button. The entire animation logic will reside within the rippleAnimation function. This function will accept arguments for timing of the animation sequence and event information.

var ripplyScott = (function() {}
  var circle = document.getElementById('js-ripple'),
      ripple = document.querySelectorAll('.js-ripple');

  function rippleAnimation(event, timing) {
    var tl           = new TimelineMax();
        x            = event.offsetX,
        y            = event.offsetY,
        w            = event.target.offsetWidth,
        h            = event.target.offsetHeight,
        offsetX      = Math.abs( (w / 2) - x ),
        offsetY      = Math.abs( (h / 2) - y ),
        deltaX       = (w / 2) + offsetX,
        deltaY       = (h / 2) + offsetY,
        scale_ratio  = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));
  }
})();

A ton of variables have been defined so let’s hit them one by one to discuss what they’re in charge of.

var tl = new TimelineMax();

This variable creates the timeline instance for the animation sequence and the way all timelines are instantiated in TweenMax.

var x = event.offsetX;
var y = event.offsetY;

An event’s offset is a read-only property that reports the offset value from the mouse pointer to the padding edge of the target node. In this case that would be our button. The event offset is calculated from left to right for x and top to bottom for y; both starting at zero.

var w = event.target.offsetWidth;
var h = event.target.offsetHeight;

These variables are returning the width and height of the button. Final calculations will include the size of the element’s border’s and padding. We’ll need this value to know how large our element is so we can spread the ripple to the farthest edge.

var offsetX = Math.abs( (w / 2) - x );
var offsetY = Math.abs( (h / 2) - y );

The offset values are the click’s offset distance away from the element’s center. In order to fill the entire area of our target, the ripple must be large enough to cover from the point of contact to the farthest corner. Using our initial x and y coordinates won’t cut it as once again the values start from zero going from left to right for x and top to bottom for y. This approach lets us use those values, but detects the distance no matter what side is clicked of the target’s central point.

Notice how the circle will cover the entire element each click no matter where the initial point of input takes place. To cover the entire surface according to the initiated point of interaction we need to do some maths.

Here’s how the offset is calculated using 464 x 82 as our width and height, 391 and 45 as our x and y coordinates:

var offsetX = (464 / 2) - 391 = -159
var offsetY = (82 / 2) - 45 = -4

We find the center by dividing the width and height in half then subtract the values reported detected by our x and y coordinates.

The Math.abs() method returns the absolute value of a number. Using our arithmetic above that would make the values 159 and 4.

var deltaX  = 232 + 159 = 391;
var deltaY  = 41 + 4 = 45;

Delta calculates the entire distance of our click instead of the distance to the center. The reason for delta is that x and y always start at zero going from left to right so we need a way to detect the click when it’s in the opposite direction; right to left.

triangles

For those that participated in basic math courses throughout highschool will recognize the Pythagorean Theorem. This formula takes the altitude squared(a) plus the base squared(b) and results in the hypotenuse squared(c).

a2 + b2 = c2

var scale_ratio = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

Using this formula let’s run through the calculations.

var scale_ratio = Math.sqrt(Math.pow(391, 2) + Math.pow(45, 2));

The method Math.pow() returns the power of the first argument; in this case doubled. The value 391 to the power of 2 would be 152881. The last value of 45 to power of 2 equals 2025. Adding both these values and taking the square root of the result would leave us with 393.58099547615353 which is the ratio we need to scale the ripple by.

var ripplyScott = (function() {
  var circle = document.getElementById('js-ripple'),
      ripple = document.querySelectorAll('.js-ripple');

  function rippleAnimation(event, timing) {
    var tl           = new TimelineMax();
        x            = event.offsetX,
        y            = event.offsetY,
        w            = event.target.offsetWidth,
        h            = event.target.offsetHeight,
        offsetX      = Math.abs( (w / 2) - x ),
        offsetY      = Math.abs( (h / 2) - y ),
        deltaX       = (w / 2) + offsetX,
        deltaY       = (h / 2) + offsetY,
        scale_ratio  = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

    tl.fromTo(ripple, timing, {
      x: x,
      y: y,
      transformOrigin: '50% 50%',
      scale: 0,
      opacity: 1,
      ease: Linear.easeIn
    },{
      scale: scale_ratio,
      opacity: 0
    });

    return tl;
  }
})();

Using the fromTo method in TweenMax we’re passing the target—ripple shape—and setting up object literals that contain our directions for the entire motion sequence. Seeing as we’d like to animate form the center outwards, the SVG needs the transform-origin set to the middle position. The scaling also needs to be adjusted to it’s smallest position, given an ease for feeling and setting opacity to 1 since we’d like to animate in then out. If you recall earlier we set the use element with an opacity of 0 in CSS and the reason why we’d like to go from a value of 1 and return to zero. The final part is returning our timeline instance.

var ripplyScott = (function() {
  var circle = document.getElementById('js-ripple'),
      ripple = document.querySelectorAll('.js-ripple');

  function rippleAnimation(event, timing) {
    var tl           = new TimelineMax();
        x            = event.offsetX,
        y            = event.offsetY,
        w            = event.target.offsetWidth,
        h            = event.target.offsetHeight,
        offsetX      = Math.abs( (w / 2) - x ),
        offsetY      = Math.abs( (h / 2) - y ),
        deltaX       = (w / 2) + offsetX,
        deltaY       = (h / 2) + offsetY,
        scale_ratio  = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

    tl.fromTo(ripple, timing, {
      x: x,
      y: y,
      transformOrigin: '50% 50%',
      scale: 0,
      opacity: 1,
      ease: Linear.easeIn
    },{
      scale: scale_ratio,
      opacity: 0
    });

    return tl;
  }

  return {
    init: function(target, timing) {
      var button = document.getElementById(target);

      button.addEventListener('click', function(event) {
        rippleAnimation.call(this, event, timing);
      });
    }
  };
})();

This object literal returned will control our ripple by attaching an event listener to the desired target, calling upon our rippleAnimation and finally passing arguments we’ll discuss in our next step.

ripplyScott.init('js-ripple-btn', 0.75);

The final call is made to our button by using our module and passing the init function that passes our button and the timing for the sequence. Voilà!

We hope you enjoy this little experiment and find it inspiring! Don’t forget to check out the demos with the different shapes and take a look at the source code. Try new shapes, layer shapes, but most importantly stay creative.

Attention: Some of these techniques are very experimental and will only work in modern browsers.
Browser Support:
  • ChromeSupported
  • FirefoxSupported
  • Internet ExplorerSupported from version 9+
  • SafariSupported
  • OperaSupported

Dennis Gaebel

Design Technologist passionate for Open Source, SVG, Typography, Web Animation, Interaction Development & Pattern Based Design.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 27

Comments are closed.
  1. This looks awesome 😀 Will have to give it a try. Thank you for writing such a thorough and easy to understand tutorial 🙂

    • You made my heart warm. Makes me feel good to hear the tut was easy to understand. My objective was met! Thanks again for reading.

  2. Good tutorial! Very welcome to nowadays. 🙂 Congratulations and thanks for the contribution!

  3. Uh, i’m waiting for more days similar articles on codrops! This is very nice effect and i think to use in my work! Thanks for sharing 🙂

  4. Very nice! The one think I would like to see is using `mousedown` and `touchstart` instead of `click` so the the ripple appears immediately when it’s clicked or touched, instead of when the mouse button is released.

    • Hey Eden. Great points. The idea of the article was to show how SVG can be used and the rest is for others to run with it. I hope you take it to 11 as they say 🙂

  5. Cool experiment, but it’s a rather expensive solution considering GASP is a such heavy library (even at minimum) for something so insignificant. In a real world situation I would opt for something that is using vanilla JS, a single HTML element as a circle (border-radius) and scale for transformation. Yes, you probably won’t be able to make it as fancy as it is in the demo, but at least it eliminates the need of yet another library, especially when we should care about speed and performance on mobile devices.

    • Hi Andy,

      TweenMax is 34.8kb gzipped so I don’t really consider it such an expensive operation especially when other parts of a project could potentially be using it too. SVG is super tough to animate with strictly CSS and yes you could do it with HTML elements, but you will notice pixelation as a result when transformations occur such as scaling+border-radius. I use TweenMax quite a bit for projects so I don’t consider it a huge overhead. As with all demo’s here you’re welcome to expand on them and make them suit your needs or build upon them to improve. Appreciate the feedback and thanks for reading 🙂

  6. Hi Dennis is it possible to have multiple instances on the same page? What would be the easiest way?

    • It’s a fairly simple change to get it working with multiple buttons. First you need to move the querySelector for the ripple effect into rippleAnimation and pass the button in to the function too. You can then do a querySelector on the button itself to get the relevant ripple. I’ve changed the init to do a querySelector on a target which has been passed in rather than getting the element by ID.

      var ripplyScott = (function () {
      function rippleAnimation(button, event, timing) {
      var ripple = button.querySelectorAll(‘.js-ripple’),
      tl = new TimelineMax(),
      x = event.offsetX,
      y = event.offsetY,
      w = event.target.offsetWidth,
      h = event.target.offsetHeight,
      offsetX = Math.abs((w / 2) – x),
      offsetY = Math.abs((h / 2) – y),
      deltaX = (w / 2) + offsetX,
      deltaY = (h / 2) + offsetY,
      scale_ratio = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

      tl.fromTo(ripple, timing, {
      x: x,
      y: y,
      transformOrigin: ‘50% 50%’,
      scale: 0,
      opacity: 1,
      ease: Linear.easeIn
      }, {
      scale: scale_ratio,
      opacity: 0
      });

      return tl;
      }

      return {
      init: function (target, timing) {
      var buttons = document.querySelectorAll(target);

      [].forEach.call(buttons, function (button) {
      button.addEventListener(‘click’, function (event) {
      rippleAnimation.call(this, button, event, timing);
      });
      });
      }
      };
      })();

      Then you just need to call the init function and pass in the target for the query selector:
      ripplyScott.init(‘.button’, 0.75);

      The animation will now be bound to any elements with the .button class. Obviously it’s slightly less efficient because you’re doing a DOM lookup each time you run the function but it’s more versatile so it’s a small tradeoff.

  7. Just tried this demo on Cent OS 6.7 Firefox ESR 38.3.0 and this does not work. So far the only SVG tutorial that is not working for me here. Would love to be able to start implementing more SVG components as this makes this more ‘flexible’ IMO.

    The console outputs:

    x is:undefined
    ripple-config.js (line 17)
    y is:undefined
    ripple-config.js (line 18)
    offsetX is:NaN
    ripple-config.js (line 19)
    offsetY is:NaN
    ripple-config.js (line 20)
    deltaX is:NaN
    ripple-config.js (line 21)
    deltaY is:NaN
    ripple-config.js (line 22)
    width is:268
    ripple-config.js (line 23)
    height is:87
    ripple-config.js (line 24)
    scale ratio is:NaN

  8. Good tutorial, I am trying to do it with angular, it works… but I don’t see svg effect, js changing elements attrs and thats it(

  9. Great tutorial but ripple effect is not working offline. Does anyone have a solution for this?

  10. Hey! There is a little bug when you have an svg icon in the button. To fix it, you just need to get the offsetWidth and the offsetHeight of the button directly instead of getting the event.target offsetWhidth and offsetHeight.
    Here’s the code:

    var ripplyScott = (function() {
    var circle = document.getElementById(‘js-ripple’),
    ripple = document.querySelectorAll(‘.js-ripple’);

    function rippleAnimation(event, timing, target) {
    var tl = new TimelineMax();
    x = event.offsetX,
    y = event.offsetY,
    w = target.offsetWidth,
    h = target.offsetHeight,
    offsetX = Math.abs( (w / 2) – x ),
    offsetY = Math.abs( (h / 2) – y ),
    deltaX = (w / 2) + offsetX,
    deltaY = (h / 2) + offsetY,
    scale_ratio = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2));

    console.log(‘x is:’ + x);
    console.log(‘y is:’ + y);
    console.log(‘offsetX is:’ + offsetX);
    console.log(‘offsetY is:’ + offsetY);
    console.log(‘deltaX is:’ + deltaX);
    console.log(‘deltaY is:’ + deltaY);
    console.log(‘width is:’ + w);
    console.log(‘height is:’ + h);
    console.log(‘scale ratio is:’ + scale_ratio);

    tl.fromTo(ripple, timing, {
    x: x,
    y: y,
    transformOrigin: ‘50% 50%’,
    scale: 0,
    opacity: 1,
    ease: Linear.easeIn
    },{
    scale: scale_ratio,
    opacity: 0
    });

    return tl;
    }

    return {
    init: function(target, timing) {
    var button = document.getElementById(target);

    button.addEventListener(‘click’, function(event) {
    rippleAnimation.call(this, event, timing, button);
    });
    }
    };
    })();

    ripplyScott.init(‘js-ripple-btn’, 0.75);

  11. Thanks for this great tutorial, but a question came into my mind once I saw the TweenMax library, do you think we do need this? do we need to load a full library for this?

    From a performance point of view I think its has a lot of downsides. However, amazing!

  12. because of the “;” at the end of the line

    var tl = new TimelineMax();

    makes all the following variables “x”, “y”, … global!