'React: How to Animate Expanding and Collapsing Div When the Size of the Content is Not Knowable

Question:

I have a React functional component that recursively renders nested lists. That part is working fine.

The part that I am struggling with is getting the divs that contain the nested lists to have an expanding animation when the nested lists (of varying sizes) appear and disappear.

Because the amount of content is not known, simply animating the max-height property does not work. For example, when one level of lists is rendered, the height of the frame might expand to 100px. However, if you animate the max-height to 100px, then the div can not expand later on to accommodate more and more nested lists that get expanded.

The div that needs to be animated is:

<div className="frame"> [nested ordered lists] </div>

... and the function that is not working is the function named "collapseFrame."

Again, I previously tried using a CSS transition on the max-height property but that is much less than an ideal solution, because it does not work well on unpredictable heights. So, I don't want to do it that way, if at all possible.

I followed a tutorial and got it working with vanilla JavaScript, without React (parts of the code that are commented out), but when translating it to React, I am not sure why I can not get the code to work.

At the moment, the expanding animation is working, but it will stop working if the border of the is removed or changed to the color white. I don't know why. ??

** Also, the collapsing animation is not working at all. The 'transitioned' event does not always fire on the ref object, and sometimes fires after it is supposed to have been removed.

Is anyone able to help point me in the right direction?

This is my codepen.

(I tried to transfer it to JS Fiddle, but it's not working).

https://codepen.io/maiya-public/pen/ZEzoqjW

(another attempt on codepen, for reference. The nested lists are appearing and disappearing, but not transitioning). https://codepen.io/maiya-public/pen/MWgGzBE

And here is the raw code: (I think it is easier to understand on the codepen, but pasting here for good practice).

index.html

<div id="root">
</div>

style.css

.frame {
    overflow:hidden;
    transition: all 0.5s ease-out;
    height:auto;
//  for some reason,without the border, not all of them will transition AND it can't be white !?? 
    border: solid purple 1px; 
}

button {
   margin: 0.25rem;
}

Dummy data: (the nested lists will render based on this object)

let data = { 
 text: 'list',
  children: [        
  {
text: "groceries",
children: [
  {
    text: "sandwich",
    children: [
      {
        text: "peanut butter",
        children: [{text: 'peanuts', children: [{text: 'nut family'},{text: 'plant', children: [{text: 'earth'}]}] }]
      },
      {
        text: "jelly",
        children: [
          { text: "strawberries", children: null },
          { text: "sugar", children: null }
        ]
      }
    ]
  }
]
  },
    {
    text: "flowers",
    children: [
      {
        text: "long stems",
        children: [
          {
            text: "daisies",
            children: null
          },
          {
            text: "roses",
            children: [
              { text: "pink", children: null },
              { text: "red", children: null }
            ]
          }
        ]
      }
    ]
  }
] };

React code: index.js

// component recursively renders nested lists.  Every list item is a list.
const ListItem = ({item, depth}) => {
//   depth prop allows me to give a className depending on  how deeply it is nested, and do CSS style based on that
  let { text, children } = item
  let [showChildren, setShowChildren] = React.useState(false)
  let frame = React.useRef()

  const expandFrame = (frame) => {
      //let frameHeight = frame.style.height //was using this at one point, but not anymore b/c not working
      // was supposed to have frame.style.height = frameHeight + 'px'
      frame.style.height = 'auto'
      frame.addEventListener('transitionend', () =>{
        frame.removeEventListener('transitionend', arguments.callee)
        frame.style.height = null
     })
  }

  const collapseFrame = (frame) => {
   let frameHeight = frame.scrollHeight;

 // temporarily disable all css transitions
  let frameTransition = frame.style.transition;
  frame.style.transition = ''

  requestAnimationFrame(function() {
    frame.style.height = frameHeight + 'px';
    frame.style.transition = frameTransition;
    requestAnimationFrame(function() {
      frame.style.height = 0 + 'px';
    })
    })
      }

  return(
<ol> 
      <button onClick={(e)=>{
          if(!showChildren) {
              // console.log('children not showing/ expand')
             setShowChildren(true)
            expandFrame(frame.current)
          } else {
            // console.log('children showing/ collapse')
             setShowChildren(false)
             collapseFrame(frame.current)
          }
        }}
        >
        {text}-{depth}  
        {  children && <i className="fas fa-sort-down"></i> || children && <i className="fas fa-sort-up"></i>} 
    </button>
      
  {/*THIS IS THE ELEMENT BEING ANIMATED:*/}
  <div className={`frame depth-${depth}`} ref={frame}>
    
        {showChildren && children && children.map(item => {
          return (
            <li key={uuid()}>
                <ListItem item={item} depth={depth + 1}/>
            </li>)
          })
      }
    
    </div>
 </ol>
  )
}

class App extends React.Component {


  render() {
    return (
      <div>
         <ListItem key={uuid()} item={data} depth={0} />
     </div>
    );
  }
}

function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}

render();


Solution 1:[1]

I think if you use an animation library it is much easier. I refactored your code to use react-spring. The code is much cleaner this way.

ListItem component:

const ListItem = ({ item, depth }) => {
  //   depth prop allows me to give a className depending on  how deeply it is nested, and do CSS style based on that
  let { text, children } = item;
  let [showChildren, setShowChildren] = React.useState(false);
  const transition = useTransition(
    showChildren ? children : [],
    item => item.text,
    {
      from: { opacity: 0, transform: 'scaleY(0)', maxHeight: '0px' },
      enter: { opacity: 1, transform: 'scaleY(1)', maxHeight: '1000px' },
      leave: { opacity: 0, transform: 'scaleY(0)', maxHeight: '0px' }
    }
  );

  return (
    <ol>
      <button
        onClick={e => {
          item.children && setShowChildren(!showChildren);
        }}
      >
        {text}-{depth}
        {(children && <i className="fas fa-sort-down" />) ||
          (children && <i className="fas fa-sort-up" />)}
      </button>

      <div className={`frame depth-${depth}`}>
        {transition.map(({ item, key, props }) => (
          <animated.li key={key} style={props}>
            <ListItem item={item} depth={depth + 1} />
          </animated.li>
        ))}
      </div>
    </ol>
  );
};

A little explanation: You can specify enter and leave styles. Enter called when new nodes added to a list. Leave called when it removed. And all styles are animated. One problem remained, if I set the maxHeight value too high the height animation is too quick. If I set it too low then in could constraint the appareance of the list element with too much children. The 1000px is a compromise and it works with this example. It could be solved either with animating other style property or calculating a maxHeight value with a recursive function for each node.

Working example: https://codesandbox.io/s/animated-hierarchical-list-5hjhz

Solution 2:[2]

I will demonstrate a solution but I highly recommend using a library like react-transition-group or react-spring. in collpase situation the trick is use a wrapper element around the children and then transition the parent height from 0 to full and vice versa.we set the parent component (in this case div with frame class) height to 0.then calculate the child height base on the wrapper div clientHeight.onTransitionEnd we have to set the height to auto so when a user click on a button other elements set their heights accordingly. you can check out a working example on codesandbox

listComonent.js

import React from "react";

const ListItem = ({ item, depth }) => {
  let { text, children } = item;
  let frame = React.useRef();
  let wrapper = React.useRef();
  let timerId = React.useRef(null);
  const handleTransitionEnd = () => {
    if (Number(frame.current.clientHeight) > 0) {
      frame.current.style.height = "auto";
    }
    if (timerId.current) {
      clearTimeout(timerId.current);
      timerId.current = null;
    }
  };

  console.log(text);
  return (
    <ol>
      <button
        onClick={(e) => {
          if (frame.current.style.height === "auto") {
            frame.current.style.height = `${wrapper.current.clientHeight}px`;
            timerId.current = setTimeout(() => {
              frame.current.style.height = '0px';
            }, 100);
          } else {
            frame.current.style["height"] = `${wrapper.current.clientHeight}px`;
          }
        }}
      >
        {text}-{depth}
        {(children && <i className="fas fa-sort-down"></i>) ||
          (children && <i className="fas fa-sort-up"></i>)}
      </button>

      <div
        className={`frame depth-${depth}`}
        ref={frame}
        onTransitionEnd={handleTransitionEnd}
      >
        <div className="wrapper" ref={wrapper}>
          {children &&
            children.map((item) => {
              console.log("inside map", item.text);
              return (
                <li key={item.text}>
                  <ListItem item={item} depth={depth + 1} />
                </li>
              );
            })}
        </div>
      </div>
    </ol>
  );
};

class App extends React.Component {


  render() {
    return (
      <div>
         <ListItem item={data} depth={0} />
     </div>
    );
  }
}

function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}

render();

notice I have used a setTimeout inside onClick event. it is to some extend similar to libraries that uses a timeout prop on their components.

css

the frame element start with height:0.elements are present in DOM but not visible (overflow:hidden)

.frame {
  overflow: hidden;
  height: 0;
  transition: all 200ms ease-in-out;
}

button {
  margin: 0.25rem;
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 KeyvanKh