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:

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.