Search code examples
javascripthtmlcssanimationcss-animations

How to Create Sticky, Cohesive Shape-to-Shape (Containers) Morphing with CSS and JavaScript?


I'm trying to create an effect where two rectangular shapes (with rounded ends), each containing text, move toward each other, merge/morph into a singular rounded rectangle as I scroll down the page, and separate again when I scroll up. The shapes need to remain sticky to the viewport position during scrolling. enter image description here

What I've Tried:

  • Applying CSS translations and transformations along with JS adjustments of the border-radius properties.
  • Incorporating clip-path, but I end up with ellipses.
  • Using HTML Shapes, but I need them to be containers.

The closest I've gotten is merely getting the shapes to move toward each other and slightly overlap. Other attempts have resulted in ellipse shapes, unnatural border-radius, or a lack of cohesion between the two elements.

document.addEventListener('scroll', () => {
    const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);
   
    const movement = (window.innerWidth / 2 - 115) * scrollPercent;

    const shape1 = document.querySelector('.shape:nth-child(1)');
    const shape2 = document.querySelector('.shape:nth-child(2)');
    shape1.style.transform = `translateX(${movement}px)`;
    shape2.style.transform = `translateX(-${movement}px)`;
});
body {
    width: 90%;
    margin: 0 auto;
    padding: 20px;
    height: 2000px;
}

.container {
    position: sticky;
    top: 20px;
    display: flex;
    justify-content: space-between;
}

.shape {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100px;
    height: 30px;
    border-radius: 15px;
    background-color: #000;
    color: #fff;
    transition: transform 0.3s ease-out;
    transform: translateX(0%);
}
<body>
    <div class="container">
        <div class="shape" id="shape1">Shape 1</div>
        <div class="shape" id="shape2">Shape 2</div>
    </div>
</body>

Question: I feel like I'm missing something with clip-path and that this is the route I need to take. How can I improve my CSS and adjust my JavaScript to achieve the morphing effect shown in the image above while maintaining rounded outer edges and building cohesion between the two containers? I appreciate any suggestions or corrections to my current approach. Thank you.


Solution

  • A common way is to use an SVG filter:

    <svg class="morph-filter" viewbox="0 0 0 0">
      <filter id="morph">
        <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
        <feColorMatrix in="blur" mode="matrix" values="
          1 0 0 0 0
          0 1 0 0 0
          0 0 1 0 0
          0 0 0 64 -32" result="morph" />
        <feBlend in="SourceGraphic" in2="morph" />
      </filter>
    </svg>
    
    .container {
      filter: url(#morph);
    }
    

    document.addEventListener('scroll', () => {
      const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);
    
      const movement = (window.innerWidth / 2 - 115) * scrollPercent;
    
      const shape1 = document.querySelector('.shape:nth-child(1)');
      const shape2 = document.querySelector('.shape:nth-child(2)');
      shape1.style.transform = `translateX(${movement}px)`;
      shape2.style.transform = `translateX(-${movement}px)`;
    });
    body {
      width: 90%;
      margin: 0 auto;
      padding: 20px;
      height: 2000px;
    }
    
    .container {
      position: sticky;
      top: 20px;
      display: flex;
      justify-content: space-between;
      filter: url(#morph);
    }
    
    .shape {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 100px;
      height: 30px;
      border-radius: 15px;
      background-color: #000;
      color: #fff;
      transition: transform 0.3s ease-out;
      transform: translateX(0%);
    }
    <body>
      <div class="container">
        <div class="shape" id="shape1">Shape 1</div>
        <div class="shape" id="shape2">Shape 2</div>
      </div>
    
      <svg class="morph-filter" viewbox="0 0 0 0">
        <filter id="morph">
          <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
          <feColorMatrix in="blur" mode="matrix" values="
            1 0 0 0 0
            0 1 0 0 0
            0 0 1 0 0
            0 0 0 64 -32" result="morph" />
          <feBlend in="SourceGraphic" in2="morph" />
        </filter>
      </svg>
    </body>

    How it works

    First, it uses <feGaussianBlur /> to blur the two boxes. Much like filter: blur(10px), it makes the source image blurry, with a bigger size than its original shape, and stores the output image in channel blur.

    <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
    

    Then using <feColorMatrix />, without touching the source image's RGB channel, it only "sharpens" the image's alpha channel with relatively big values (64 -32), it makes alpha channels less than 0.5 invisible and alpha chanel more than 0.5 fully visible (alpha channel values are clamped from 0 to 1). Then stores the output in channel morph.

    <feColorMatrix in="blur" mode="matrix" values="
      1 0 0 0 0
      0 1 0 0 0
      0 0 1 0 0
      0 0 0 64 -32" result="morph" />
    

    Finally, it blends these channels with <feBlend />, that when the two boxes almost touches, their overlaping blurry boundaries' alpha channels are greater than 0.5, which creates the morphing effect.