Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <div class="container mt-5">
  <h2>CSS-Only Ripple Effects with Progressive JS Enhancement</h2>

  <h3>Works with Any Color</h3>
  <div class="row">
    <div class="col"> 
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-secondary btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-success btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-info btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-warning btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-danger btn-lg btn-block">
          Click Me
        </button>
      </div>
    </div>
  </div>
  <h3>Works with Inputs <small>(Doesn't require a pseudo-element)</small></h3>
  <div class="row">
    <div class="col">
      <div class="form-group">
        <input type="submit" class="btn btn-primary btn-lg btn-block mb-2" value="Submit Me"/>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <input type="submit" class="btn btn-primary btn-lg btn-block mb-2 disabled" value="Can't Submit Me"/>
      </div>
    </div>
  </div>
  <h3>Works without JS</h3>
  <div class="form-group">
    <button class="btn btn-primary btn-lg btn-block no-click-fx">
      Click Me
    </button>
  </div>
  <h3>Easy to Customize</h3>
  <div class="row">
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block dark-ripple">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block light-ripple">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block red-ripple">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block fuzzy-ripple">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block fast-ripple">
          Click Me
        </button>
      </div>
    </div>
    <div class="col">
      <div class="form-group">
        <button class="btn btn-primary btn-lg btn-block slow-ripple">
          Click Me
        </button>
      </div>
    </div>
  </div>
  <h3>Easy to Extend to Other Effects</h3>
  <div class="row">
    <div class="col-sm-4">
      <div class="form-group">
        <input class="form-control"/>
      </div>
    </div>
    <div class="col-sm-4">
      <div class="form-group">
        <input class="form-control"/>
      </div>
    </div>
    <div class="col-sm-4">
      <div class="form-group">
        <input class="form-control"/>
      </div>
    </div>
  </div>

  <h3>Why This Works</h3>

  <p>I created this ripple effect using <b>only</b> <code>background-image</code>, <code>background-size</code>, and <code>background-position</code>, which are all animateable properties.</p>

  <p>This allows the effect to work directly on elements without any wrappers or pseudo-elements, and without js needing to kick off the animation.</p>

  <h4>Break the Effect Down Into Stages</h4>

  <p>We want to create a ripple outward, and then fade back to the buttons normal color, so lets handle these two things separately.</p>

  <p class="alert alert-warning"><small ><b>Note:</b> Animations below are mocked up using a pseudo element so overflow is visible. The real ripple effect only uses the background css properties.</small></p>
  <p class="alert alert-danger"><small ><b>Note:</b> For some reason touching any of the animations in mobile safari scrolls you to the top. I'm sorry about that, I am not sure why that's happening.</small></p>

  <h5>1. The Ripple</h5>
  <p>The ripple outward can be accomplished with a <code>radial-gradient</code> that we start at size 0% and expand to cover the whole button. Our radial gradient should be drawn as a circle that touches the nearest side and stays in the center: <code>radial-gradient(circle closest-side at center, ...</code>. Now we just need to make a box that is large enough for the circle to encompass the button. Most buttons are wider than tall, and never wider than <code>100vw</code>, so if our ripple background grows to be <code>sqrt(2) * 100% = 141%</code> wide and <code>100vw</code> tall, the circle should cover the entire button.</p>

  <p><b>Ripple Gradient Visual</b></p>
  <div class="figure figure-ripple figure-hover-animated">
    <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
  </div>

  <h5>2. The Fade</h5>
  <p>The fade afterwards is tricky because we can't change background images opacity. However we can simulate that if we take a <code>linear-gradient</code> that starts with the ripple color and fades to transparent, and then move that over the button from the opaque end to the transparent end. If our gradient is long enough, it won't be obvious the background isn't fading uniformly. How long of a gradient do we need to make to convince you its uniform? Lets try a few different sizes.</p>

  <div class="row figure-set">
    <div class="col col-xs-12">
      <p><b>3 x height</b></p>
      <div class="figure figure-linear-fade figure-hover-animated" style="--bg-height-multiplier: 3;">
        <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
      </div>
    </div>
    <div class="col col-xs-12">
      <p><b>5 x height</b></p>
      <div class="figure figure-linear-fade figure-hover-animated" style="--bg-height-multiplier: 5;">
        <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
      </div>
    </div>
    <div class="col col-xs-12">
      <p><b>10 x height</b></p>
      <div class="figure figure-linear-fade figure-hover-animated" style="--bg-height-multiplier: 10;">
        <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
      </div>
    </div>
  </div>

  <p>10 x height looks the most uniform, but 5x is good enough when the animation is fast, and is probably easier on memory.</p>

  <h4>Putting It Together</h4>

  <p>So we have a trick to create a ripple and a trick to emulate a fade, and we want to combine them into one animation. Thankfully we can add multiple background-images and set their properties individually. So we add both images and hide one of them by making its background size zero. Then in the middle we can "switch" the images seamlessly by changing their background sizes over a tiny time period (like in a keyframe 0.001% after the previous one).</p>

  <div class="row figure-set">
    <div class="col col-xs-12">
      <p><b>With Overflow Visible</b></p>
      <div class="figure figure-hover-animated figure-combined">
        <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
      </div>
    </div>
    <div class="col col-xs-12">
      <p><b>With Overflow Hidden</b></p>
      <div class="figure figure-hover-animated figure-combined figure-overflow-hidden">
        <button class="btn btn-primary css-only">Button<span class="mockup"></span></button>
      </div>
    </div>
  </div>

  <h4>Improve with JS and CSS Variables</h4>
  <p>If we capture the click event on the button, we can calculate the offset of the click and inject them using css custom properties. Then we can access those in css using <code>var(name-of-var, default-value)</code>. If js isn't enabled, the default-value will be used and our animation will ripple outward from the center like normal. For this demonstration I calculate 5 things:</p>
  <ul>
    <li><b>--click-offset-x</b>: the distance of the click in px from the left edge of the button.</li>
    <li><b>--click-offset-y</b>: the distance of the click in px from the top edge of the button.</li>
    <li><b>--click-max-r</b>: the maximum distance from the click to any corner of the button, important to ensure our ripple becomes large enough to cover the button.</li>
    <li><b>--click-el-w</b>: the width of the element (this is necessary to position the center of the background-image relative to the edges of the button -- starting from 0% and adding the offset would align the left edge of the background and not the center).</li>
    <li><b>--click-el-h</b>: the height of the element (same reason as above).</li>
  </ul>

  <div class="row">
    <div class="col col-xs-12">
      <p><b>With Overflow Visible</b></p>
      <div class="figure figure-combined figure-click-animated">
        <button class="btn btn-primary linked-ripple">Button<span class="mockup"></span></button>
      </div>
    </div>
    <div class="col col-xs-12">
      <p><b>With Overflow Hidden</b></p>
      <div class="figure figure-combined figure-click-animated figure-overflow-hidden">
        <button class="btn btn-primary linked-ripple">Button<span class="mockup"></span></button>
      </div>
    </div>
  </div>

  <p>My script adds 3 helper classes during interaction:</p>
  <ul>
    <li><b>pre-click-fx</b>: Gets applied 1 frame before <code>click-fx</code>, allows you to reset the animation so that e.g. a focused button can be clicked again.</li>
    <li><b>click-fx</b>: Gets applied while the animation should happen and <b>after</b> the css variables have taken effect. Some browsers like Mobile Safari have latency between when a css variable is updated and when the stylesheet sees the new value.</li>
    <li><b>post-click-fx</b>: Gets applied after the animation finishes and does not get removed until the element loses focus. This allows you to prevent your text inputs from rippling again while a user is clicking around it to e.g. edit text.</li>
  </ul>
</div>
              
            
!

CSS

              
                // Note, mentions of "71%" are just 100% * sqrt(2) / 2, to
// make the radius of circle that would encompass the corners 
// of a 1:1 square button as the worst case when rippling from
// the center.

.btn {
  --ripple-color: rgba(0, 0, 0, 0.3);
  --ripple-duration: 1.5s;
  --ripple-fuzz: 1px;
  
  border-color: transparent !important; /* Just the ripple goes to the edges */
  
  &:focus,
  &:active,
  &.click-fx {
    &:not(:disabled):not(.disabled) {
      background-image: radial-gradient(circle closest-side at center, var(--ripple-color) 0%, var(--ripple-color) calc(~'100% - var(--ripple-fuzz, 0px)'), transparent 100%), linear-gradient(180deg, var(--ripple-color)10%, transparent 90%);
      background-size: 0% 0%, 0% 0%;
      background-repeat: no-repeat;
      background-origin: border-box;
      animation: button-ripple var(--ripple-duration) ease-in;
    }
  }

  &.pre-click-fx {
    animation: none !important; // Reset animation
  }
}

@keyframes button-ripple {
  0% {
    background-size: 0% 0%, 0% 0%;
    background-position: calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)') calc(~'50% - var(--click-el-h, 0px)/2 + var(--click-offset-y, 0px)'), 0% 0%;
  }
  33% {
    background-size: calc(~'2 * var(--click-max-r, 71%)') calc(~'2 * var(--click-max-r, 200vw)'), 0% 0%;
    background-size: calc(~'2 * var(--click-max-r, 71%)') 100vmax, 0% 0%;
    background-position: calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)') calc(~'50% - var(--click-el-h, 0px)/2 + var(--click-offset-y, 0px)'), 0% 0%;
  }
  33.1% {
    background-size: 0% 0%, 100% 1000%;
  }
  100% {
    background-size: 0% 0%, 100% 1000%;
    background-position: 0% 0%, 0% 100%;
  }
}

.dark-ripple {
  --ripple-color: rgba(0, 0, 0, 0.6);
}

.light-ripple {
  --ripple-color: rgba(255, 255, 255, 0.3);
}

.red-ripple {
  --ripple-color: rgba(255, 0, 0, 0.3);
}

.fuzzy-ripple {
  --ripple-fuzz: 30px;
}

.fast-ripple {
  --ripple-duration: 1s; 
}

.slow-ripple {
  --ripple-duration: 3s;
}

.form-control {
  border-radius: 0;
  border: none;
  background-color: rgba(0,0,0,0.1);
  transition: background-size 0.5s, background-color 0.5s, border-color 0.25s, color 0.2s, box-shadow 0.25s;

  &,
  &:hover,
  &:focus,
  &:active {
    background-image: linear-gradient(0deg, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0.35) 100%), 
      linear-gradient(0deg, #007bff 0%, #007bff 100%);
    background-repeat: no-repeat;
    background-position: 50% 100%, 50% 100%;
    background-size: 100% 2px, 0% 2px;
  }

  &:focus,
  &:active,
  &.click-fx {
    background-size: 100% 2px, 100% 2px;
    animation: input-ripple 0.5s ease-in;
  }

  &.pre-click-fx:not(.post-click-fx) {
    animation: none !important;
  }
}

@keyframes input-ripple {
  0% {
    background-position: 50% 100%, calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)') 100%; 
    background-size: 100% 2px, 0% 2px, 0% 0%;
  }
  100% {
    background-position: 50% 100%, calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)') 100%; 
    background-size: 100% 2px, calc(~'2 * var(--click-max-r, 71%)') 2px;
  }
}

.figure {
  position: relative;
  text-align: center;
  width: 100%;
  padding: 75px 75px;
  border: 2px dashed #ccc;
  margin-bottom: 24px;
  overflow: hidden;

  caption {
    position: absolute;
    top: 12px;
    left: 12px;
    right: 12px;
    color: inherit;
  }

  .btn {
    position: relative;
    z-index: 0;
    background-image: none !important;

    .mockup {
      content: '';
      display: block;
      position: absolute;
      top: 50%;
      left: 50%;
      border: 2px dashed #111;
      transform: translate(-50%, -50%);
      background-repeat: no-repeat;
      z-index: -1;
    }
  }

  &.figure-hover-animated,
  &.figure-click-animated {
    &:after {
      position: absolute;
      bottom: 0px;
      left: 0px;
      width: 100%;
      background: rgba(255, 255, 255, 0.5);
      text-align: center;
      color: inherit;
      transition: opacity 0.25s;
    }

    &:hover,
    .figure-set:hover & {
      &:after {
        opacity: 0;
      }
    }
  }

  &.figure-hover-animated {
    &:after {
      content: 'Hover for Animation';
    }
  }

  &.figure-click-animated {
    &:after {
      content: 'Click Button for Animation';
    }
  }

  &.figure-overflow-hidden {
    .btn {
      overflow: hidden !important;
      
      .mockup {
        border-width: 0px !important;
      }
    }
  }

  &.figure-ripple {
    .btn .mockup {
      width: 0px;
      height: 0px;
      border-color: transparent;
      background-image: radial-gradient(circle closest-side at center, rgba(0, 0, 0, 0.3) 0%, rgba(0,0,0,0.3) 99%, transparent 100%);
      transition: width 1s ease-in, height 1s ease-in, border-color 0.25s;
    }

    &.figure-hover-animated:hover .btn .mockup,
    &.figure-click-animated .btn:focus ,.mockup,
    &.figure-click-animated .btn:active .mockup {
      width: 141%;
      height: 240px;  
      border-color: #111;    
    }
  }  

  &.figure-linear-fade {
    .btn .mockup {
      width: calc(~'100% + 4px');
      height: calc(~'100% * var(--bg-height-multiplier)');
      top: 0;
      transform: translate(-50%, 0%) translateY(-2px);
      background: linear-gradient(180deg, rgba(0,0,0,0.3) calc(~'100% / var(--bg-height-multiplier)'), transparent calc(~'100% - 100% / var(--bg-height-multiplier)'));
      transition: transform 1s ease-in;
    }

    .figure-set:hover &.figure-hover-animated .btn,
    &.figure-hover-animated:hover .btn,
    &.figure-click-animated .btn:focus,
    &.figure-click-animated .btn:active {
      .mockup {
        transform: translate(-50%, calc(~'-1 * (100% - 100% / var(--bg-height-multiplier))')) translateY(2px);
      }
    }
  }

  &.figure-combined {
    .btn .mockup {
      width: 0%;
      height: 0%;
      background-image: radial-gradient(circle closest-side at center, rgba(0, 0, 0, 0.3) 0%, rgba(0,0,0,0.3) 99%, transparent 100%), linear-gradient(180deg, rgba(0,0,0,0.3) 20%, transparent 80%);
      opacity: 0;
    }

    .figure-set:hover &.figure-hover-animated .btn,
    &.figure-hover-animated:hover .btn,
    &.figure-click-animated .btn:focus,
    &.figure-click-animated .btn:active,
    .btn.click-fx {
      animation: empty-animation 5s ; // For purpose of showing pseudo element with css variables
       
      &.pre-click-fx {
        // Reset animation if still focused
        animation: none !important;
        
        .mockup {
          animation: none !important;
        }
      }
      
      .mockup {
        animation: figure-combined 5s ease-in forwards;
        background-size: 0px 0px;
      }
    }
  }
}

@keyframes figure-combined {
  // Transform values hard-coded for Safari which has a bug when animating both width/height and transform
  0% {
    top: calc(~'50% - var(--click-el-h, 0px)/2 + var(--click-offset-y, 0px)');
    left: calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)');
    width: 0%;
    height: 0%;
    background-size: 100% 100%, 0% 0%;
    opacity: 1;
    transform: translate(0px, 0px);
  }
  20% {
    width: calc(~'2 * var(--click-max-r, 71%)');
    height: calc(~'2 * var(--click-max-r, 95px)');
    border-color: #111;
    transform: translate(calc(~'-1 * var(--click-max-r, 51px)'), calc(~'-1 * var(--click-max-r, 95px)'));
  }
  30% {
    border-color: transparent;
  }
  40% {
    top: calc(~'50% - var(--click-el-h, 0px)/2 + var(--click-offset-y, 0px)');
    left: calc(~'50% - var(--click-el-w, 0px)/2 + var(--click-offset-x, 0px)');
    width: calc(~'2 * var(--click-max-r, 71%)');
    height: calc(~'2 * var(--click-max-r, 95px)');
    background-size: 100% 100%, 0% 0%;
    border-color: transparent;
    transform: translate(calc(~'-1 * var(--click-max-r, 51px)'), calc(~'-1 * var(--click-max-r, 95px)'));
  }
  40.001% {
    top: 0%;
    left: 50%;
    width: calc(~'100% + 4px');
    height: 500%;
    background-size: 0% 0%, 100% 100%;
    transform: translate(-38px, 0px) translateY(-2px);
  }
  45% {
    border-color: transparent;
  }
  55% {
    border-color: #111;
  }
  60% {
    transform: translate(-38px, 0px) translateY(-2px);
  }
  80% {
    transform: translate(-38px, -144px) translateY(2px);
    opacity: 1;
  }
  100% {
    top: 0%;
    width: calc(~'100% + 4px');
    height: 500%;
    background-size: 0% 0%, 100% 100%;
    transform: translate(-38px, -144px) translateY(2px);
    opacity: 0;
  }
}

@keyframes empty-animation {}
              
            
!

JS

              
                // If you want to use this in your projects, feel free 
// to copy and paste it. It is dependency free and 
// should be cross-browser compatible. ^^

;(function() {
  // BEGIN POLYFILLS
  
  // .matches() Polyfill

  if (!Element.prototype.matches) {
    Element.prototype.matches = Element.prototype.msMatchesSelector || 
      Element.prototype.webkitMatchesSelector;
  }
  
  // .closest Polyfill

  if (!Element.prototype.closest) {
    Element.prototype.closest = function(s) {
      var el = this;
      if (!document.documentElement.contains(el)) return null;
      do {
        if (el.matches(s)) return el;
        el = el.parentElement || el.parentNode;
      } while (el !== null && el.nodeType === 1); 
      return null;
    };
  }
  
  // END POLYFILLS

  // BEGIN MICRO EVENT LIBRARY

  var eventHandlers = {};

  window.clickFxHandlers = eventHandlers;

  function decorateHandler(fn, selector) {
    return function(e) {
      e = e || window.event;

      if (selector && !e.target.matches(selector)) {
        return;
      }

      fn(e);
    };
  }

  function bind(el, eventsOrSelector, eventsOrFn, fnOrUndefined) {
    var selector = false;
    var events = eventsOrSelector;
    var fn = eventsOrFn;

    if (arguments.length === 4) {
      selector = eventsOrSelector;
      events = eventsOrFn;
      fn = fnOrUndefined;
    }

    var eventList = events.split(' ');
    var handler = decorateHandler(fn, selector);

    for (var i in eventList) {
      var event = eventList[i];;

      eventHandlers[event] = eventHandlers[event] || [];

      eventHandlers[event].push({
        'original': fn,
        'decorated': handler,
        'el': el,
        'selector': selector 
      });

      el.addEventListener(event, handler);
    }
  }

  function unbind(el, eventsOrSelector, eventsOrFn, fnOrUndefined) {
    var selector = false;
    var events = eventsOrSelector;
    var fn = eventsOrFn;

    if (arguments.length === 4) {
      selector = eventsOrSelector;
      events = eventsOrFn;
      fn = fnOrUndefined;
    }

    var eventList = events.split(' ');

    for (var i in eventList) {
      var event = eventList[i];

      if ('undefined' !== typeof eventHandlers[event]) {
        var handlers = eventHandlers[event];
        var hIndex = handlers.findIndex(function(handler) {
          return handler.original === fn && 
            handler.selector === selector && 
            handler.el === el;
        });

        if (-1 !== hIndex) {
          el.removeEventListener(event, handlers[hIndex].decorated);

          handlers.splice(hIndex, 1);
        }
      }
    }
  }

  function bindOnce(el, eventsOrSelector, eventsOrFn, fnOrUndefined) {
    var selector = false;
    var events = eventsOrSelector;
    var fn = eventsOrFn;

    if (arguments.length === 4) {
      selector = eventsOrSelector;
      events = eventsOrFn;
      fn = fnOrUndefined;
    }

    bind(el, selector, events, fn);
    bind(el, selector, events, function oneFn() {
      unbind(el, selector, events, fn);
      unbind(el, selector, events, oneFn); 
    });
  }
  
  // END MICRO EVENT LIBRARY
  
  // BEGIN CLICK-FX

  // Detect css animations

  function hasCssAnimation(el) {

    // get a collection of all children including self
    var items = [el].concat(Array.prototype.slice.call(el.getElementsByTagName("*")));

    // go through each item in reverse (faster)
    for (var i = items.length; i--;) {

      // get the applied styles
      var style = window.getComputedStyle(items[i], null);

      // read the animation duration - defaults to 0
      var animDuration = parseFloat(style.getPropertyValue('animation-duration') || '0');

      // if we have any duration greater than 0, an animation exists
      if (animDuration > 0) {
        return true;
      }
    }

    return false;
  }

  // Calculate and apply click-fx

  function applyClickFx(el, clickCoords) {
    if ('string' === typeof el) {
      document.querySelectorAll(el).forEach(function (oneEl) {
        applyClickFx(oneEl, clickCoords);
      });
      
      return;
    }
    
    if (el.classList.contains('click-fx') || el.classList.contains('pre-click-fx') || el.classList.contains('no-click-fx')) {
      return;
    }

    var cssVars = {};

    if (clickCoords) {
      let elOffset = el.getBoundingClientRect();
      let clickOffset = {
        x: Math.round(clickCoords.x - elOffset.left),
        y: Math.round(clickCoords.y - elOffset.top)
      };

      cssVars['--click-offset-x'] = clickOffset.x + 'px';
      cssVars['--click-offset-y'] = clickOffset.y + 'px';
      cssVars['--click-max-r'] = Math.round(Math.sqrt(
        Math.pow(Math.max(clickOffset.x, el.offsetWidth - clickOffset.x), 2) + 
        Math.pow(Math.max(clickOffset.y, el.offsetHeight - clickOffset.y), 2)
      )) + 'px';
      cssVars['--click-el-w'] = el.offsetWidth + 'px';
      cssVars['--click-el-h'] = el.offsetHeight + 'px';
    }

    for (var cssVar in cssVars) {
      el.style.setProperty(cssVar, cssVars[cssVar]);
    }

    el.classList.add('pre-click-fx');

    requestAnimationFrame(function(){
      // If its ignoring reset, exit early
      if (hasCssAnimation(el)) {
        el.classList.remove('pre-click-fx');
        return;   
      }

      el.classList.add('click-fx');
      el.classList.remove('pre-click-fx');

      bindOnce(el, 'animationend webkitAnimationEnd oAnimationEnd animationcancel webkitAnimationCancel oAnimationCancel', function(e) {
        el.classList.remove('click-fx');
        for (var cssVar in cssVars) {
          el.style.removeProperty(cssVar);
        }

        if (el.matches('input:not([type=submit]):not([type=button]):not([type=reset]), textarea, select') && el === document.activeElement) {
          // This is a stateful element and so might have a
          // stateful effect
          
          el.classList.add('post-click-fx');

          // Give other plugins a chance to do their magic to
          // the element (e.g. select2 hidden input) before checking
          // if it is still focused
          
          bind(document.querySelector('body'), 'mouseup touchend keyup', function longBlurHandler(e) {
            setTimeout(function() {
              if (el !== document.activeElement) {
                // No longer focused
                el.classList.remove('post-click-fx');
                unbind(document.querySelector('body'), 'mouseup touchend keyup', longBlurHandler);
              }
            }, 0);
          });
        }
      });
    });
  }
  
  // API

  window.clickFx = function (selector) {
    var lastEvent = null;

    bind(document.querySelector('body'), selector + ',' + selector.replace(/,/g, ' *,'), 'mousedown touchstart touchend focusin', function(e) {
      var ignoreEvent = false;

      if (
        lastEvent &&
        'mousedown' === e.type && 'touchend' === lastEvent.type && 
        e.target === lastEvent.target
      ) {
        // This is a mousedown fired automatically after a touchend on same target
        ignoreEvent = true;
      } else if ('touchend' === e.type) {
        ignoreEvent = true;
      }

      lastEvent = e;

      if (ignoreEvent) {
        return;
      }

      var el = e.target;

      if (!el.matches(selector)) {
        el = el.closest(selector);
      }

      var clickCoords = false;

      if ('focusin' !== e.type) {
        // This is a click event
        
        clickCoords = {
          x: e.clientX,
          y: e.clientY
        };

        if (e.changedTouches && e.changedTouches[0]) {
          clickCoords.x = e.changedTouches[0].clientX;
          clickCoords.y = e.changedTouches[0].clientY;
        }
      }
      
      // Apply actual effect

      applyClickFx(el, clickCoords);

      // Check if this el should trigger effect on other els

      if ('undefined' !== typeof el.dataset.applyClickFx) {
        document.querySelectorAll(el.dataset.applyClickFx).forEach(function(otherEl) {
          applyClickFx(otherEl, clickCoords);
        });

        let parent = el.parent;

        while (parent) {
          if ('undefined' !== parent.dataset.applyClickFx) {
            document.querySelectorAll(parent.dataset.applyClickFx).forEach(function(otherEl) {
              applyClickFx(otherEl, clickCoords);
            });
          }

          parent = parent.parent;
        }
      }

      // Check if other els should be triggered by this el

      document.querySelectorAll('[data-subscribe-click-fx]').forEach(function(otherEl) {
        if (el.matches(otherEl.dataset.subscribeClickFx)) {
          applyClickFx(otherEl, clickCoords);
        }
      });
    });
  };
  
  // END CLICK-FX

  // BEGIN DEMO (You can get rid of this)

  bind(document, 'DOMContentLoaded', function() {
    clickFx('a, input, textarea, button, .btn');
  });

  bind(document.querySelector('body'), '.linked-ripple, .linked-ripple *', 'mousedown touchstart touchend', function(e) {
    var targetEl = e.target;
    var clientX = e.clientX;
    var clientY = e.clientY;
    var targetElRect = targetEl.getBoundingClientRect();
    var relativeOffset = {
      x: Math.round(clientX - targetElRect.left),
      y: Math.round(clientY - targetElRect.top)
    };

    document.querySelectorAll('.linked-ripple').forEach(function(linkedEl) {
      if (linkedEl === targetEl) {
        return;
      }
      
      var elRect = linkedEl.getBoundingClientRect();

      applyClickFx(linkedEl, {
        x: elRect.left + relativeOffset.x,
        y: elRect.top + relativeOffset.y
      });
    });
  });

  if ('undefined' !== typeof window._l) {
    // Catch peoples eye when they see this in the preview
    
    Array.from(document.querySelectorAll('.btn:not(.css-only)')).slice(0, 14).forEach(function(el, i) {
      var elRect = el.getBoundingClientRect();

      setTimeout(function() {
        applyClickFx(el, {
          x: elRect.left + 10,
          y: elRect.top + 10
        });
      }, i * 100); 
    });
  }

  // END DEMO
})();
              
            
!
999px

Console