Search code examples
javascriptcssperformancecss-animations

Efficient expand/collapse CSS animation issue with font text rendering


I'm trying to build expand/collapse section animations using this guide from Google for best performance. The article mentions in the end

A word of warning on this particular variant: Chrome has blurry text on low DPI screens during the animation because of rounding errors due to the scale and counter-scale of the text. If you’re interested in the details for that, there’s a bug filed that you can star and follow.

Tried manually fixing the frames with rounding errors, but no luck.

Screenshot from Firefox during animation (text is not smooth): text not smooth - FIrefox

Screenshot from Firefox after animation ends (flashes to smooth text): Smooth text

Some observations

  • On MacBook Pro screen there's no issue with Chrome at all.
  • On MacBook Pro screen, Firefox - text is smooth during animation but in end flashes to a bit bolder (this issue happens in Chrome if you omit will-change from the animated elements, but obviously doesn't do the trick for FFox).
  • On lower DPI monitor behaviour is pretty much the same (not smooth during animation), but with the exception that on Chrome it stays "not smooth" after the end of the animation if I put will-change, otherwise will flash to smooth.

Since code is longer I provided also Codepen for your convenience, also embedded here.

"use strict";

class Expando {
  constructor() {
    this._el = document.querySelector(".content");
    const toggleEl = document.querySelectorAll(".section-header");
    this._toggleBtn = document.querySelector(".toggle");
    this._sections = [...document.querySelectorAll(".expand-collapse-section")];
    this._isExpanded = new Array(this._sections.length).fill(false);

    this._createEaseAnimations();

    toggleEl.forEach((el) => {
      el.addEventListener("click", (e) => {
        el.querySelector(".toggle").classList.toggle("expanded");
        const section = e.target.closest(".expand-collapse-section");
        const content = section.querySelector(".content");
        const idx = this._sections.indexOf(section);
        this.toggle(content, idx);
      });
    });

    // measure single content element's margin-top (they all have the same margin in CSS)
    const rgx = /(.+)px/;
    const marginTopPx = window.getComputedStyle(this._el, null).marginTop;
    const results = rgx.exec(marginTopPx);
    this._contentTopMargin = +results[1] || 0;
  }

  expand(el, sectionIdx) {
    if (this._isExpanded[sectionIdx]) {
      return;
    }

    this._isExpanded[sectionIdx] = true;

    this._applyAnimation(el, { expand: true });
  }

  collapse(el, sectionIdx) {
    if (!this._isExpanded[sectionIdx]) {
      return;
    }

    this._isExpanded[sectionIdx] = false;

    this._applyAnimation(el, { expand: false });
  }

  toggle(el, sectionIdx) {
    const expanded = this._isExpanded[sectionIdx];

    if (expanded) {
      return this.collapse(el, sectionIdx);
    }

    this.expand(el, sectionIdx);
  }

  _applyAnimation(el, { expand } = opts) {
    function setTranslation(el, { height, start, expand } = opts) {
      const translation = start ? (expand ? -height : 0) : (expand ? 0 : -height);

      if (translation === 0) {
        el.removeAttribute("style");
      } else {
        el.style.transform = `translateY(${translation}px)`;
      }
    }

    const elInner = el.querySelector(".content-inner");
    el.classList.remove("item--expanded");
    el.classList.remove("item--collapsed");
    elInner.classList.remove("item__contents--expanded");
    elInner.classList.remove("item__contents--collapsed");

    const sectionEl = el.closest(".expand-collapse-section");
    const sectionContent = sectionEl.querySelector(".content");
    sectionContent.style.display = "block"; // block to expand, has no effect on collapse (in the end of animation it gets set to none)
    const index = this._sections.indexOf(sectionEl);
    const targetContentHeight = sectionContent.offsetHeight + this._contentTopMargin;

    for (let i = index + 1; i < this._sections.length; i++) {
      const curr = this._sections[i];
      // don't animate yet translation of adjacent sections, just set initial value for animation
      curr.classList.add("notransition"); 
      
      // setting section content to display block pushes the other items by its height as it has transform set, but it still occupies its original height
      // initial value for animation
      setTranslation(curr, { height: targetContentHeight, start: true, expand });
    }
    // the rest of the content below the expandable sections
    const lastSectionSibling = this._sections.slice(-1)[0].nextElementSibling;
    lastSectionSibling.classList.add("notransition");
    setTranslation(lastSectionSibling, { height: targetContentHeight, start: true, expand });

    requestAnimationFrame(() => {
      if (expand) {
        el.classList.add("item--expanded");
        elInner.classList.add("item__contents--expanded");
      } else {
        el.classList.add("item--collapsed");
        elInner.classList.add("item__contents--collapsed");
      }

      sectionEl.offsetHeight; // needed for Firefox on expand

      // sectionEl.offsetHeight; -> not needed in requestAnimationFrame

      for (let i = index + 1; i < this._sections.length; i++) {
        const curr = this._sections[i];

        // trigger translation animation of adjacent sections and rest of the content now
        curr.classList.remove("notransition");
        setTranslation(curr, { height: targetContentHeight, start: false, expand });
        sectionEl.offsetHeight; // needed for Firefox on expand
      }
      lastSectionSibling.classList.remove("notransition");
      setTranslation(lastSectionSibling, { height: targetContentHeight, start: false, expand });

      if (!expand) {
        sectionContent.addEventListener("animationend", () => {
          sectionContent.style.display = "none";
  
          for (let i = index + 1; i < this._sections.length; i++) {
            const curr = this._sections[i];
            // avoid unexpected animations when removing transform inline style in the end of the animation, needs reflow
            curr.classList.add("notransition"); 
            // could also be set to translateY(0)
            curr.removeAttribute("style"); 
            // should force reflow here otherwise there will be no net change in notransition class which would animate transform, which we don't want,
            // we're just removing the unnecessary style attribute
            sectionEl.offsetHeight;
            curr.classList.remove("notransition");
          }

          lastSectionSibling.classList.add("notransition");
          lastSectionSibling.removeAttribute("style");
          sectionEl.offsetHeight;
          lastSectionSibling.classList.remove("notransition");
        }, { once: true });
      }
    });
  }

  _createEaseAnimations() {
    let ease = document.querySelector(".ease");
    if (ease) {
      return ease;
    }

    ease = document.createElement("style");
    ease.classList.add("ease");

    console.log('------------- Expand animation --------------');
    const expandAnimation = [];
    const expandContentsAnimation = [];
    const collapseAnimation = [];
    const collapseContentsAnimation = [];
    for (let i = 0; i <= 100; i++) {
      const step = this._ease(i / 100);

      // Expand animation.
      this._append({
        i,
        step,
        start: 0,
        end: 1,
        outerAnimation: expandAnimation,
        innerAnimation: expandContentsAnimation,
      });

      // Collapse animation.
      this._append({
        i,
        step,
        start: 1,
        end: 0,
        outerAnimation: collapseAnimation,
        innerAnimation: collapseContentsAnimation,
      });
    }

    ease.textContent = `
      @keyframes expandAnimation {
        ${expandAnimation.join("")}
      }

      @keyframes expandContentsAnimation {
        ${expandContentsAnimation.join("")}
      }

      @keyframes collapseAnimation {
        ${collapseAnimation.join("")}
      }

      @keyframes collapseContentsAnimation {
        ${collapseContentsAnimation.join("")}
      }`;

    document.head.appendChild(ease);
    return ease;
  }

  _append({ i, step, start, end, outerAnimation, innerAnimation } = opts) {
    let scale = start + (end - start) * step;
    let invScale = scale === 0 ? 0 : 1 / scale;

    if (start === 0) {
      
      if (i === 11) {
        scale = 0.373;
        invScale = 2.680965147453083;
      }

      if (i === 23) {
        scale = 0.648;
        invScale = 1.5432098765432098;
      }

      if (i === 28) {
        scale = 0.7312;
        invScale = 1.3676148796498906;
      }

      if (i === 41) {
        scale = 0.879;
        invScale =1.1376564277588168;
      }

      if (i === 43) {
        scale = 0.894;
        invScale = 1.1185682326621924;
      }

      if (i === 44) {
        scale = 0.9;
        invScale = 1.1111111111111112;
      }

      if (i === 55) {
        scale = 0.959;
        invScale = 1.0427528675703859;
      }

      if (i === 56) {
        scale = 0.96;
        invScale = 1.0416666666666667;
      }

      if (i === 62) {
        scale = 0.97914;
        invScale = 1.0213044099924424;
      }

      if (i === 64) {
        scale = 0.983;
        invScale = 1.017293997965412;
      }

      if (i === 67) {
        scale = 0.988;
        invScale = 1.0121457489878543;
      }

      if (i === 69) {
        scale = 0.9907648;
        invScale = 1.0093212839212697;
      }

      if (i === 72) {
        scale = 0.99385;
        invScale = 1.0061880565477688;
      }

      if (i === 74) {
        scale = 0.99543;
        invScale = 1.0045909807821745;
      }

      if (i === 85) {
        scale = 0.99949;
        invScale = 1.0005102602327187;
      }

      if (i === 89) {
        scale = 0.9998536;
        invScale = 1.0001464214360982;
      }

      if (i === 90) {
        scale = 0.99995;
        invScale = 1.000050002500125;
      }

      console.log(`${i}: scale: ${scale}, inverse: ${invScale}, scale * inverse = ${scale * invScale}`);
    }

    outerAnimation.push(`
      ${i}% {
        transform: scaleY(${scale});
      }`);

    innerAnimation.push(`
      ${i}% {
        transform: scaleY(${invScale});
      }`);
  }

  _clamp(value, min, max) {
    return Math.max(min, Math.min(max, value));
  }

  _ease(v, pow = 4) {
    v = this._clamp(v, 0, 1);

    return 1 - Math.pow(1 - v, pow);
  }
}

new Expando();
* {
    box-sizing: border-box;
}

html,
body {
    padding: 0;
    font-family: Arial, Helvetica, sans-serif;
}

.expand-collapse-section,
.example-content-below-sections {
    transition: transform .7s ease-out;
    will-change: transform;
}

.section-header {
    border: 1px solid #efefef;
    border-radius: 4px;
    padding: 0.5em;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.toggle-wrapper {
    width: 36px;
    height: 36px;
    background: #f7f8f9;
    border-radius: 50%;
    display: flex;
    justify-content: center;
    align-items: center;
}

.toggle {
    background-image: url('data:image/svg+xml,<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23747878" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></svg>');
    display: inline-block;
    height: 24px;
    width: 24px;
    transition: transform 200ms;
}

.toggle.expanded {
    transform: rotateZ(180deg);
}

.content {
    transform: scaleY(0);
    transform-origin: top left;
    overflow: hidden;
    will-change: transform;
    margin-top: 0.5rem;
    border: 1px solid black;
    border-radius: 5px;
    /* padding: 1em; */
    display: none;
    overflow: hidden;
}

.content-inner {
    transform-origin: top left;
    overflow: hidden;
    transform: scaleY(0);
    will-change: transform;
}

/* need to be margin as with padding, transform makes it push to the top while animating collapse */
.content-inner-spacer {
    margin: 1em;
}

.item--expanded {
    animation-name: expandAnimation;
    animation-duration: 5.7s;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}

.item__contents--expanded {
    animation-name: expandContentsAnimation;
    animation-duration: 5.7s;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}

.item--collapsed {
    animation-name: collapseAnimation;
    animation-duration: .7s;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}

.item__contents--collapsed {
    animation-name: collapseContentsAnimation;
    animation-duration: .7s;
    animation-timing-function: linear;
    animation-fill-mode: forwards;
}

.notransition {
    -webkit-transition: none !important;
    -moz-transition: none !important;
    -o-transition: none !important;
    transition: none !important;
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width,minimum-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="style.css" />
    <script src="script.js" defer></script>
  </head>
  <body>
    <section class="expand-collapse-section first">
      <div class="section-header">
        <div class="section-header-title">Expand / collapse</div>
        <div class="toggle-wrapper">
          <div class="toggle"></div>
        </div>
      </div>
      <div class="content">
        <div class="content-inner">
          <div class="content-inner-spacer">
            Lorem ipsum dolor sit, amet consectetur adipisicing elit. Neque,
          reiciendis quasi laboriosam delectus accusantium aliquam! Alias
          voluptatum, dolore debitis aliquam maxime doloribus eligendi tempora
          amet laborum quis maiores reprehenderit deleniti impedit quo quas
          eius, fugiat atque non accusamus eum esse? Explicabo quam ea
          reprehenderit minus officiis vel et reiciendis ex omnis expedita, ab
          libero veritatis! Suscipit, magni maxime deserunt eaque laborum libero
          atque nesciunt labore consequatur provident. Aut dolor necessitatibus
          sint, dicta facilis sed molestiae laudantium incidunt repellat
          consequuntur, officiis maxime quam, dolorum possimus expedita minus.
          Error quaerat, esse magni quibusdam quis corporis, et tenetur, ullam
          ipsam a ratione fugit.
          </div>
        </div>
      </div>
    </section>

    <section class="expand-collapse-section">
      <div class="section-header">
        <div class="section-header-title">Expand / collapse</div>
        <div class="toggle-wrapper">
          <div class="toggle"></div>
        </div>
      </div>
      <div class="content">
        <div class="content-inner">
          <div class="content-inner-spacer">
            Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
            molestiae, quo ratione voluptas iure expedita dolor ad voluptate
            maxime, aspernatur error sequi hic? Harum nobis provident recusandae
            dolor, ea corporis minima animi deserunt, voluptate adipisci
            cupiditate repudiandae inventore quasi commodi dolorum odio saepe
            consequatur nulla. Fugit quo tenetur dolores veritatis!
          </div>
        </div>
      </div>
    </section>

    <section class="expand-collapse-section">
        <div class="section-header">
          <div class="section-header-title">Expand / collapse</div>
          <div class="toggle-wrapper">
            <div class="toggle"></div>
          </div>
        </div>
        <div class="content">
          <div class="content-inner">
            <div class="content-inner-spacer">
              Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
              molestiae, quo ratione voluptas iure expedita dolor ad voluptate
              maxime, aspernatur error sequi hic? Harum nobis provident recusandae
              dolor, ea corporis minima animi deserunt, voluptate adipisci
              cupiditate repudiandae inventore quasi commodi dolorum odio saepe
              consequatur nulla. Fugit quo tenetur dolores veritatis!
            </div>
          </div>
        </div>
      </section>

      <section class="expand-collapse-section">
        <div class="section-header">
          <div class="section-header-title">Expand / collapse</div>
          <div class="toggle-wrapper">
            <div class="toggle"></div>
          </div>
        </div>
        <div class="content">
          <div class="content-inner">
            <div class="content-inner-spacer">
              Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
              molestiae, quo ratione voluptas iure expedita dolor ad voluptate
              maxime, aspernatur error sequi hic? Harum nobis provident recusandae
              dolor, ea corporis minima animi deserunt, voluptate adipisci
              cupiditate repudiandae inventore quasi commodi dolorum odio saepe
              consequatur nulla. Fugit quo tenetur dolores veritatis!
            </div>
          </div>
        </div>
      </section>

      <section class="expand-collapse-section">
        <div class="section-header">
          <div class="section-header-title">Expand / collapse</div>
          <div class="toggle-wrapper">
            <div class="toggle"></div>
          </div>
        </div>
        <div class="content">
          <div class="content-inner">
            <div class="content-inner-spacer">
              Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
              molestiae, quo ratione voluptas iure expedita dolor ad voluptate
              maxime, aspernatur error sequi hic? Harum nobis provident recusandae
              dolor, ea corporis minima animi deserunt, voluptate adipisci
              cupiditate repudiandae inventore quasi commodi dolorum odio saepe
              consequatur nulla. Fugit quo tenetur dolores veritatis!
            </div>
          </div>
        </div>
      </section>

    <p class="example-content-below-sections">Bottom content</p>
  </body>
</html>

How can I overcome this issue? I see on Google's search result suggestions expand/collapse sections (scroll a bit to People also ask), they use transforms to animate, but don't have issues.


Solution

  • I ended up using perspective: 1px on the element I'm animating. Although there's a slight text blur during animation, this for me is a viable solution because:

    • I really couldn't figure out what Google are using in their search page, but it seems it's not perspective
    • Despite the slight blur, we're interested in the final state, that is, content's readability when everything is collapsed and animation is done (not intermediate state), and that's just fine.
    • Usually expand/collapse animations are fast and blur would be barely noticeable.
    • Blur is very little and there's text blur in standard scale animations in CSS as well, anyway (so it might not have anything to do with perspective).

    I'll be accepting my own answer, but I'm open to even better solutions. Here's also the original example with the fix applied (one line change for .content in the CSS).

    "use strict";
    
    class Expando {
      constructor() {
        this._el = document.querySelector(".content");
        const toggleEl = document.querySelectorAll(".section-header");
        this._toggleBtn = document.querySelector(".toggle");
        this._sections = [...document.querySelectorAll(".expand-collapse-section")];
        this._isExpanded = new Array(this._sections.length).fill(false);
    
        this._createEaseAnimations();
    
        toggleEl.forEach((el) => {
          el.addEventListener("click", (e) => {
            el.querySelector(".toggle").classList.toggle("expanded");
            const section = e.target.closest(".expand-collapse-section");
            const content = section.querySelector(".content");
            const idx = this._sections.indexOf(section);
            this.toggle(content, idx);
          });
        });
    
        // measure single content element's margin-top (they all have the same margin in CSS)
        const rgx = /(.+)px/;
        const marginTopPx = window.getComputedStyle(this._el, null).marginTop;
        const results = rgx.exec(marginTopPx);
        this._contentTopMargin = +results[1] || 0;
      }
    
      expand(el, sectionIdx) {
        if (this._isExpanded[sectionIdx]) {
          return;
        }
    
        this._isExpanded[sectionIdx] = true;
    
        this._applyAnimation(el, { expand: true });
      }
    
      collapse(el, sectionIdx) {
        if (!this._isExpanded[sectionIdx]) {
          return;
        }
    
        this._isExpanded[sectionIdx] = false;
    
        this._applyAnimation(el, { expand: false });
      }
    
      toggle(el, sectionIdx) {
        const expanded = this._isExpanded[sectionIdx];
    
        if (expanded) {
          return this.collapse(el, sectionIdx);
        }
    
        this.expand(el, sectionIdx);
      }
    
      _applyAnimation(el, { expand } = opts) {
        function setTranslation(el, { height, start, expand } = opts) {
          const translation = start ? (expand ? -height : 0) : (expand ? 0 : -height);
    
          if (translation === 0) {
            el.removeAttribute("style");
          } else {
            el.style.transform = `translateY(${translation}px)`;
          }
        }
    
        const elInner = el.querySelector(".content-inner");
        el.classList.remove("item--expanded");
        el.classList.remove("item--collapsed");
        elInner.classList.remove("item__contents--expanded");
        elInner.classList.remove("item__contents--collapsed");
    
        const sectionEl = el.closest(".expand-collapse-section");
        const sectionContent = sectionEl.querySelector(".content");
        sectionContent.style.display = "block"; // block to expand, has no effect on collapse (in the end of animation it gets set to none)
        const index = this._sections.indexOf(sectionEl);
        const targetContentHeight = sectionContent.offsetHeight + this._contentTopMargin;
    
        for (let i = index + 1; i < this._sections.length; i++) {
          const curr = this._sections[i];
          // don't animate yet translation of adjacent sections, just set initial value for animation
          curr.classList.add("notransition"); 
          
          // setting section content to display block pushes the other items by its height as it has transform set, but it still occupies its original height
          // initial value for animation
          setTranslation(curr, { height: targetContentHeight, start: true, expand });
        }
        // the rest of the content below the expandable sections
        const lastSectionSibling = this._sections.slice(-1)[0].nextElementSibling;
        lastSectionSibling.classList.add("notransition");
        setTranslation(lastSectionSibling, { height: targetContentHeight, start: true, expand });
    
        requestAnimationFrame(() => {
          if (expand) {
            el.classList.add("item--expanded");
            elInner.classList.add("item__contents--expanded");
          } else {
            el.classList.add("item--collapsed");
            elInner.classList.add("item__contents--collapsed");
          }
    
          sectionEl.offsetHeight; // needed for Firefox on expand
    
          // sectionEl.offsetHeight; -> not needed in requestAnimationFrame
    
          for (let i = index + 1; i < this._sections.length; i++) {
            const curr = this._sections[i];
    
            // trigger translation animation of adjacent sections and rest of the content now
            curr.classList.remove("notransition");
            setTranslation(curr, { height: targetContentHeight, start: false, expand });
            sectionEl.offsetHeight; // needed for Firefox on expand
          }
          lastSectionSibling.classList.remove("notransition");
          setTranslation(lastSectionSibling, { height: targetContentHeight, start: false, expand });
    
          if (!expand) {
            sectionContent.addEventListener("animationend", () => {
              sectionContent.style.display = "none";
      
              for (let i = index + 1; i < this._sections.length; i++) {
                const curr = this._sections[i];
                // avoid unexpected animations when removing transform inline style in the end of the animation, needs reflow
                curr.classList.add("notransition"); 
                // could also be set to translateY(0)
                curr.removeAttribute("style"); 
                // should force reflow here otherwise there will be no net change in notransition class which would animate transform, which we don't want,
                // we're just removing the unnecessary style attribute
                sectionEl.offsetHeight;
                curr.classList.remove("notransition");
              }
    
              lastSectionSibling.classList.add("notransition");
              lastSectionSibling.removeAttribute("style");
              sectionEl.offsetHeight;
              lastSectionSibling.classList.remove("notransition");
            }, { once: true });
          }
        });
      }
    
      _createEaseAnimations() {
        let ease = document.querySelector(".ease");
        if (ease) {
          return ease;
        }
    
        ease = document.createElement("style");
        ease.classList.add("ease");
    
        console.log('------------- Expand animation --------------');
        const expandAnimation = [];
        const expandContentsAnimation = [];
        const collapseAnimation = [];
        const collapseContentsAnimation = [];
        for (let i = 0; i <= 100; i++) {
          const step = this._ease(i / 100);
    
          // Expand animation.
          this._append({
            i,
            step,
            start: 0,
            end: 1,
            outerAnimation: expandAnimation,
            innerAnimation: expandContentsAnimation,
          });
    
          // Collapse animation.
          this._append({
            i,
            step,
            start: 1,
            end: 0,
            outerAnimation: collapseAnimation,
            innerAnimation: collapseContentsAnimation,
          });
        }
    
        ease.textContent = `
          @keyframes expandAnimation {
            ${expandAnimation.join("")}
          }
    
          @keyframes expandContentsAnimation {
            ${expandContentsAnimation.join("")}
          }
    
          @keyframes collapseAnimation {
            ${collapseAnimation.join("")}
          }
    
          @keyframes collapseContentsAnimation {
            ${collapseContentsAnimation.join("")}
          }`;
    
        document.head.appendChild(ease);
        return ease;
      }
    
      _append({ i, step, start, end, outerAnimation, innerAnimation } = opts) {
        let scale = start + (end - start) * step;
        let invScale = scale === 0 ? 0 : 1 / scale;
    
        if (start === 0) {
          
          if (i === 11) {
            scale = 0.373;
            invScale = 2.680965147453083;
          }
    
          if (i === 23) {
            scale = 0.648;
            invScale = 1.5432098765432098;
          }
    
          if (i === 28) {
            scale = 0.7312;
            invScale = 1.3676148796498906;
          }
    
          if (i === 41) {
            scale = 0.879;
            invScale =1.1376564277588168;
          }
    
          if (i === 43) {
            scale = 0.894;
            invScale = 1.1185682326621924;
          }
    
          if (i === 44) {
            scale = 0.9;
            invScale = 1.1111111111111112;
          }
    
          if (i === 55) {
            scale = 0.959;
            invScale = 1.0427528675703859;
          }
    
          if (i === 56) {
            scale = 0.96;
            invScale = 1.0416666666666667;
          }
    
          if (i === 62) {
            scale = 0.97914;
            invScale = 1.0213044099924424;
          }
    
          if (i === 64) {
            scale = 0.983;
            invScale = 1.017293997965412;
          }
    
          if (i === 67) {
            scale = 0.988;
            invScale = 1.0121457489878543;
          }
    
          if (i === 69) {
            scale = 0.9907648;
            invScale = 1.0093212839212697;
          }
    
          if (i === 72) {
            scale = 0.99385;
            invScale = 1.0061880565477688;
          }
    
          if (i === 74) {
            scale = 0.99543;
            invScale = 1.0045909807821745;
          }
    
          if (i === 85) {
            scale = 0.99949;
            invScale = 1.0005102602327187;
          }
    
          if (i === 89) {
            scale = 0.9998536;
            invScale = 1.0001464214360982;
          }
    
          if (i === 90) {
            scale = 0.99995;
            invScale = 1.000050002500125;
          }
    
          console.log(`${i}: scale: ${scale}, inverse: ${invScale}, scale * inverse = ${scale * invScale}`);
        }
    
        outerAnimation.push(`
          ${i}% {
            transform: scaleY(${scale});
          }`);
    
        innerAnimation.push(`
          ${i}% {
            transform: scaleY(${invScale});
          }`);
      }
    
      _clamp(value, min, max) {
        return Math.max(min, Math.min(max, value));
      }
    
      _ease(v, pow = 4) {
        v = this._clamp(v, 0, 1);
    
        return 1 - Math.pow(1 - v, pow);
      }
    }
    
    new Expando();
    * {
        box-sizing: border-box;
    }
    
    html,
    body {
        padding: 0;
        font-family: Arial, Helvetica, sans-serif;
    }
    
    .expand-collapse-section,
    .example-content-below-sections {
        transition: transform .7s ease-out;
        will-change: transform;
    }
    
    .section-header {
        border: 1px solid #efefef;
        border-radius: 4px;
        padding: 0.5em;
        cursor: pointer;
        display: flex;
        justify-content: space-between;
        align-items: center;
    }
    
    .toggle-wrapper {
        width: 36px;
        height: 36px;
        background: #f7f8f9;
        border-radius: 50%;
        display: flex;
        justify-content: center;
        align-items: center;
    }
    
    .toggle {
        background-image: url('data:image/svg+xml,<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="%23747878" d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></svg>');
        display: inline-block;
        height: 24px;
        width: 24px;
        transition: transform 200ms;
    }
    
    .toggle.expanded {
        transform: rotateZ(180deg);
    }
    
    .content {
        transform: scaleY(0);
        transform-origin: top left;
        overflow: hidden;
        will-change: transform;
        margin-top: 0.5rem;
        border: 1px solid black;
        border-radius: 5px;
        /* padding: 1em; */
        display: none;
        overflow: hidden;
        perspective: 1px;
    }
    
    .content-inner {
        transform-origin: top left;
        overflow: hidden;
        transform: scaleY(0);
        will-change: transform;
    }
    
    /* need to be margin as with padding, transform makes it push to the top while animating collapse */
    .content-inner-spacer {
        margin: 1em;
    }
    
    .item--expanded {
        animation-name: expandAnimation;
        animation-duration: .7s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
    }
    
    .item__contents--expanded {
        animation-name: expandContentsAnimation;
        animation-duration: .7s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
    }
    
    .item--collapsed {
        animation-name: collapseAnimation;
        animation-duration: .7s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
    }
    
    .item__contents--collapsed {
        animation-name: collapseContentsAnimation;
        animation-duration: .7s;
        animation-timing-function: linear;
        animation-fill-mode: forwards;
    }
    
    .notransition {
        -webkit-transition: none !important;
        -moz-transition: none !important;
        -o-transition: none !important;
        transition: none !important;
    }
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width,minimum-scale=1.0" />
        <title>Document</title>
        <link rel="stylesheet" href="style.css" />
        <script src="script.js" defer></script>
      </head>
      <body>
        <section class="expand-collapse-section first">
          <div class="section-header">
            <div class="section-header-title">Expand / collapse</div>
            <div class="toggle-wrapper">
              <div class="toggle"></div>
            </div>
          </div>
          <div class="content">
            <div class="content-inner">
              <div class="content-inner-spacer">
                Lorem ipsum dolor sit, amet consectetur adipisicing elit. Neque,
              reiciendis quasi laboriosam delectus accusantium aliquam! Alias
              voluptatum, dolore debitis aliquam maxime doloribus eligendi tempora
              amet laborum quis maiores reprehenderit deleniti impedit quo quas
              eius, fugiat atque non accusamus eum esse? Explicabo quam ea
              reprehenderit minus officiis vel et reiciendis ex omnis expedita, ab
              libero veritatis! Suscipit, magni maxime deserunt eaque laborum libero
              atque nesciunt labore consequatur provident. Aut dolor necessitatibus
              sint, dicta facilis sed molestiae laudantium incidunt repellat
              consequuntur, officiis maxime quam, dolorum possimus expedita minus.
              Error quaerat, esse magni quibusdam quis corporis, et tenetur, ullam
              ipsam a ratione fugit.
              </div>
            </div>
          </div>
        </section>
    
        <section class="expand-collapse-section">
          <div class="section-header">
            <div class="section-header-title">Expand / collapse</div>
            <div class="toggle-wrapper">
              <div class="toggle"></div>
            </div>
          </div>
          <div class="content">
            <div class="content-inner">
              <div class="content-inner-spacer">
                Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
                molestiae, quo ratione voluptas iure expedita dolor ad voluptate
                maxime, aspernatur error sequi hic? Harum nobis provident recusandae
                dolor, ea corporis minima animi deserunt, voluptate adipisci
                cupiditate repudiandae inventore quasi commodi dolorum odio saepe
                consequatur nulla. Fugit quo tenetur dolores veritatis!
              </div>
            </div>
          </div>
        </section>
    
        <section class="expand-collapse-section">
            <div class="section-header">
              <div class="section-header-title">Expand / collapse</div>
              <div class="toggle-wrapper">
                <div class="toggle"></div>
              </div>
            </div>
            <div class="content">
              <div class="content-inner">
                <div class="content-inner-spacer">
                  Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
                  molestiae, quo ratione voluptas iure expedita dolor ad voluptate
                  maxime, aspernatur error sequi hic? Harum nobis provident recusandae
                  dolor, ea corporis minima animi deserunt, voluptate adipisci
                  cupiditate repudiandae inventore quasi commodi dolorum odio saepe
                  consequatur nulla. Fugit quo tenetur dolores veritatis!
                </div>
              </div>
            </div>
          </section>
    
          <section class="expand-collapse-section">
            <div class="section-header">
              <div class="section-header-title">Expand / collapse</div>
              <div class="toggle-wrapper">
                <div class="toggle"></div>
              </div>
            </div>
            <div class="content">
              <div class="content-inner">
                <div class="content-inner-spacer">
                  Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
                  molestiae, quo ratione voluptas iure expedita dolor ad voluptate
                  maxime, aspernatur error sequi hic? Harum nobis provident recusandae
                  dolor, ea corporis minima animi deserunt, voluptate adipisci
                  cupiditate repudiandae inventore quasi commodi dolorum odio saepe
                  consequatur nulla. Fugit quo tenetur dolores veritatis!
                </div>
              </div>
            </div>
          </section>
    
          <section class="expand-collapse-section">
            <div class="section-header">
              <div class="section-header-title">Expand / collapse</div>
              <div class="toggle-wrapper">
                <div class="toggle"></div>
              </div>
            </div>
            <div class="content">
              <div class="content-inner">
                <div class="content-inner-spacer">
                  Lorem, ipsum dolor sit amet consectetur adipisicing elit. Facere
                  molestiae, quo ratione voluptas iure expedita dolor ad voluptate
                  maxime, aspernatur error sequi hic? Harum nobis provident recusandae
                  dolor, ea corporis minima animi deserunt, voluptate adipisci
                  cupiditate repudiandae inventore quasi commodi dolorum odio saepe
                  consequatur nulla. Fugit quo tenetur dolores veritatis!
                </div>
              </div>
            </div>
          </section>
    
        <p class="example-content-below-sections">Bottom content</p>
      </body>
    </html>