Card Expansion Effect with SVG clipPath

A morphing card expansion effect enhanced by a low poly background animation, using SVG clipPath and Trianglify.

Today we are going to create a experimental grid layout with “cards” where we’ll animate the card expansion with a morphing effect on the cover image and follow its motion with a delightful animation of a low-poly pattern used as the background of the page.

We are using Trianglify by Quinn Rohlf to generate the background pattern, SVG’s clipPath to morph the card image, and GSAP to animate and control the whole sequence.

We could have used the CSS clip-path property to clip and morph the image but since the support is currently quite limited and inconsistent across browsers, we’ll be using SVG clipPath instead. This way we can make the demo work across all browsers, including IE9.

CardExpansionGif

Card Expansion

Let’s start by taking a look at how the card expansion works.

Since what we want is to only move the selected card without affecting the flow of other siblings, we’ll actually move the container inside by fixing it to a certain position on click and then animate its sizes and position to fill the screen. This way we’ll never cause a flickering of the cards.

As you can see on this screenshot the ‘card’ element is still at the same position while the card is open.

CardExpansion01

The very first thing that we need to do, is to build the markup and style for the card in its two states. We build the card the way it is in its opened state:


    <div class="card">
      <div class="card__container">
        <svg class="card__image">
          <!-- SVG image... -->
        </svg>
        <div class="card__content">
          <i class="card__btn-close fa fa-times"></i>
          <div class="card__caption">
            <h2 class="card__title">Title...</h2>
            <p class="card__subtitle">Subtitle...</p>
          </div>
          <p class="card__copy">
            Lorem ipsum dolor sit amet...
          </p>
        </div>
      </div>
    </div>
    

Here is the style for the card and the nested container.


.card {
  position: relative;
  float: left;
  width: 29%;
  height: 0;
  margin: 2%;
  padding-bottom: 20%;
}

.card__container {
  position: fixed;
  top: 0;
  left: 0;
  overflow-x: hidden;
  overflow-y: auto;
  width: 100%;
  height: 100%;
  -webkit-overflow-scrolling: touch;
}
/* You can check out the other styles in the CSS files */

For the closed state, we set the position of the card container to ‘absolute’:


.card__container--closed {
  position: absolute;
  overflow: hidden;
}

To animate the card between the two states, we’ll simply get the card position on the viewport, stick the container to it and then tween each property to the full screen size.

Notice that the ‘x’ property is tweened to the center of the screen. This is done because in order to animate the background, we need to keep track of the card’s center during each frame. We’ll go into more detail later on.

    
    /**
     * Float card to final position.
     * @param {Function} callback The callback `onCardMove`.
     * @private
     */
    Card.prototype._floatContainer = function(callback) {

      $(document.body).css('overflow', 'hidden');

      var TL = new TimelineLite;

      // Get the card position on the viewport.
      var rect = this._container.getBoundingClientRect();
      var windowW = window.innerWidth;

      var track = {
        width: 0,
        x: rect.left + (rect.width / 2),
        y: rect.top + (rect.height / 2),
      };

      // Fix the container to the card position (start point).
      TL.set(this._container, {
        width: rect.width,
        height: rect.height,
        x: rect.left,
        y: rect.top,
        position: 'fixed',
        overflow: 'hidden'
      });

      // Tween the container (and the track values) to full screen (end point).
      TL.to([this._container, track], 2, {
        width: windowW, // This value must be in px in order to correctly update the track width.
        height: '100%',
        x: windowW / 2,
        y: 0,
        xPercent: -50,
        ease: Expo.easeInOut,
        clearProps: 'all',
        className: '-=' + CLASSES.containerClosed,
        onUpdate: callback.bind(this, track)
      });

      return TL;
    };
    

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

Image Clip

In order to clip the image with cross browser support, we’re using the SVG clipPath. The respective image is set as an SVG Image Element with the definition of the clip as follow:

    
    <!-- In this case preserveAspectRatio set the image as background cover, like in CSS. -->
    <svg class="card__image" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1920 500" preserveAspectRatio="xMidYMid slice">
      <defs>
        <clipPath id="clipPath1">
          <!-- r = 992 = hyp = Math.sqrt(960*960+250*250) -->
          <circle class="clip" cx="960" cy="250" r="992"></circle>
        </clipPath>
      </defs>
      <!-- the clip-path url reference to the id above -->
      <image clip-path="url(#clipPath1)" width="1920" height="500" xlink:href="img/a.jpg"></image>
    </svg>
    

When using a circle, we just need to tween the ‘radius’ attribute to a smaller value (60px in this case) with TweenLite:


    /**
     * Clip image in.
     * @private
     */
    Card.prototype._clipImageIn = function() {

      // Circle.
      var tween = TweenLite.to(this._clip, 1, {
        attr: {
          r: 60
        },
        ease: Expo.easeInOut
      });

      return tween;
    };
    

If instead we want to use a polygon like we do in demo 2, 3 and 4, we need to tween each point of the polygon. In the ‘start and ‘end’ array you can see a sub-array of coordinates [x, y] for each point.

As shown here we attach an ‘onUpdate’ callback to each end point, and populate an initially empty array called ‘points’ with the values for each point. Once the length is equal to 4 (the number of points that we’re using) the SVG clipPath is updated and the ‘points’ array is reset.

    
    var TL = new TimelineLite; // Polygon

    var start = [
      [0, 500],
      [0, 0],
      [1920, 0],
      [1920, 500]
    ];

    var end = [
      [1025, 330],
      [1117, 171],
      [828, 206],
      [913, 260]
    ];

    var points = [];

    // Create a tween for each point

    start.forEach(function(point, i) {

      var tween = TweenLite.to(point, 1.5, end[i]);

      end[i].onUpdate = function() {

        points.push(point.join());

        // Every 4 point update clip-path.
        if (points.length === end.length) {
          $(this._clip).attr('points', points.join(' '));
          // Reset.
          points = [];
        };

      }.bind(this);

      tween.vars.ease = Expo.easeInOut;

      // Add the tween for each point at the start of the timeline.
      TL.add(tween, 0);

    }, this);

    return TL;
    

For demo purposes and to keep things simple, the points are hardcoded. It would be interesting to push this effect forward and set a different clipping shape for each card, or dynamically get the points from the pattern on the background.

Polygonal Background

To complete the effect let’s combine the card floating animation with the background pattern. To animate the SVG we just use CSS transitions (opacity and scale transform).

Unfortunately Firefox doesn’t support transform-origin on SVG elements, so we just change the polygon opacity otherwise the pattern transition will slow down the whole animation since the browser has to translate each path from the upper left corner through the whole screen.

In demo.js you’ll find the full code. This are the main steps:

  • Generate an SVG pattern with Trianglify.
  • Append the generated element to the container provided. Notice that the container is already hidden with a CSS class, so we’ll not show the pattern until all of its polygons are set for the animation.
  • Loop the SVG children paths and get the center position of each element on the viewport.
  • Store a reference of the path element and its points which we’ll use later to find out which polygon is hovered by the card.
    
    /**
     * Map of svg paths and points.
     */
    var polygonMap = {
      paths: null,
      points: null
    };

    ...

    /**
     * Store path elements, map coordinates and sizes.
     * @param {Element} pattern The SVG Element generated with Trianglify.
     * @private
     */
    function _mapPolygons(pattern) {

      // Append SVG to pattern container.
      $(SELECTORS.pattern).append(pattern);

      // Convert nodelist to array,
      // Used `.childNodes` because IE doesn't support `.children` on SVG.
      polygonMap.paths = [].slice.call(pattern.childNodes);

      polygonMap.points = [];

      polygonMap.paths.forEach(function(polygon) {

        // Hide polygons by adding CSS classes to each svg path (used attrs because of IE).
        $(polygon).attr('class', CLASSES.polygon + ' ' + CLASSES.polygonHidden);

        var rect = polygon.getBoundingClientRect();

        var point = {
          x: rect.left + rect.width / 2,
          y: rect.top + rect.height / 2
        };

        polygonMap.points.push(point);
      });

      // All polygons are hidden now, display the pattern container.
      $(SELECTORS.pattern).removeClass(CLASSES.patternHidden);
    };

    ...
    

Notice that we do all this computation ahead, so during the animation we’ll not look at the DOM anymore but instead we’ll just loop the ‘polygonMap’ to find out to which element we need to add or remove CSS classes.

Consistent Choreography

Now, the key to make the animation choreographically consistent is to have a hierarchy among the animated elements, since the primary elements is the card we’ll let the floating drive the background animation.

To do so we’ll pass a callback ‘_onCardMove’ (demo.js) to the ‘_floatContainer’ method (Card.js) that will be called during the tween ‘onUpdate’ event.

In order to detect which polygon needs to be animated, we test if the center of the path polygon previously stored in ‘polygonMap.points’ is inside an imaginary circle where its radius is defined by the card width and the center by the card center x, y.

These parameters are computed in the variable ‘track’ inside the ‘_floatContainer’ method and passed as an argument to the callback (_onCardMove).

    
      /**
       * Callback to be executed on Tween update, whatever a polygon
       * falls into a circular area defined by the card width the path's
       * CSS class will change accordingly.
       * @param {Object} track The card sizes and position during the floating.
       * @private
       */
      function _onCardMove(track) {

        var radius = track.width / 2;

        var center = {
          x: track.x,
          y: track.y
        };

        polygonMap.points.forEach(function(point, i) {

          if (_detectPointInCircle(point, radius, center)) {

            // Notice that since the points array has been previously generated
            // from the paths array we can safely use the index to get the correct path.
            $(polygonMap.paths[i]).attr('class', CLASSES.polygon);

          } else {
            $(polygonMap.paths[i]).attr('class', CLASSES.polygon + ' ' + CLASSES.polygonHidden);
          }
        });
      }

      /**
       * Detect if a point is inside a circle area.
       * @param {object} point The point to test.
       * @param {number} radius The width of the card.
       * @param {object} center The center of the card.
       * @private
       */
      function _detectPointInCircle(point, radius, center) {

        var xp = point.x;
        var yp = point.y;

        var xc = center.x;
        var yc = center.y;

        var d = radius * radius;

        var isInside = Math.pow(xp - xc, 2) + Math.pow(yp - yc, 2) <= d;

        return isInside;
      };
    

In the method ‘_onCardMove’ we can make variations to the background animation. For example, by swapping the if/else statement we inverted the pattern in demos 3 and 4.

To help you understand the background animation in the source files you’ll find a prototype with just the background animation and the circle displayed that you can move around with the mouse (hover.html).

Have a look at the demos and dig into the source, we hope you find this effect useful and inspiring!

Claudio Calautti

Claudio is a freelance creative developer based in London. He develops modern, highly interactive websites with cutting edge technologies. In his spare time he has fun with web experiments and enjoys travelling.

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 85

Comments are closed.
  1. hi thanks for this demo is very useful and and i relay like it
    but i think something wrong with demo 4 the detail of card can’t show after click it , i hope from you fixed it thinks again.

  2. I have the same issue with Cash.js not being compatible with my existing jQuery framework – is it possible to update the code to include jQuery compatibility? Please do this, I’ll love you forever and ever.

    • Just add $.noConflict(); at the top of the demo.js file to avoid conflict with jQuery

  3. Great!! But how can I solve the scrolling issue? In demo 4 there is only the browser scrollbar but it is fixed. Only method is to scroll with the mouse wheel.

    What edit in the code with allow me to scroll down the articles?

    Thnx!

  4. When i use this code in my page and open a card it produces two scroll bars. How could I fix this?

  5. People who have a CASH conflict with jQuery can either use the jQuery.noConflict() method as described here:
    https://learn.jquery.com/using-jquery-core/avoid-conflicts-other-libraries/

    ORRR

    If your code is too complex and it doesn’t work, a quick fix is to place the HTML code of the card-expansion demo in a separate .html file on your server and inject it into your DOM with tag … this way the iframe will use CASH without seeing the $ directive of jQuery ..

  6. Hi Claudio good work, I’m having an issue if you’re in Firefox, you scroll down and click refresh inside a “card__copy” the image of “card__container” doesn’t appear and you can´t click on it again.

  7. Would there be any method to link to a specific open card?
    Not sure if my question is clear enough, but since all the content is hidden on the same page, can we track a specific URL if one card is open so one can link directly to it?

    Thank you, marvelous job right here!

  8. Is there any way that I can load the details in separate page without losing the animation?

  9. Hi,

    Nice work ! I would like to implement this with the pages rather than on the same. For eg : cards on parent page and when they click on parent page the content on child page opens. Is that possible ?

  10. Hi,
    When I add the script “cash.min.js” I have issues about jQuery…
    How can I replace this script ?

  11. There are some issues integrating it with materialize.css When we click on the card nothing happens probably because the target is changed to html.no-js. Can someone please help?

  12. A great solution, it helped me a lot in creating a portfolio page for my site! For the lazy loading of images, I had to use the lazyload library, otherwise a large number of images greatly increases the page load time. To facilitate the weight of the page, all photos are converted to SVG format. The page folding button has been changed, now it follows scrolling. You can see the result http://dubyaga.com/stories/

  13. He intentado integrarlo en una web, y no me funciona, le doy click a las cards y no pasa nada, no me suelta error de js, me podrian explicar un poco el como integrarlo, es en una pagina de shopify, agradecería si alguien me responde.

  14. Do you mind if I make a WordPress plugin from this portfolio and put it in the repository? I will point out a link to this page and the author