Animated UI with react-transition-group

Visualization Framework/Library

We will build this animation on slider value change using React and build the leaf animation with react-transition-group library.

Checkout out the demo - Link


There are two parts to this animation-

  • The branch growing upward.
  • The leaf appearing and disappearing.

Branch Animation

The branch is a div whose growth is controlled by changing the height property. We initialize a ref branch and use it to change the height of the branch based on the prop yearIndex. This prop has been passed from the Slider component, and it's the index of the label array - [0, 3, 5, 8, 10].

This is the basic structure of the Tree component that illustrates branch functionality-

import { useEffect,useState, useRef } from 'react'
import { OFFSET } from '../utils/constant'

const Tree = ({ yearIndex }) => {
  const branch = useRef(null)

  useEffect(() => {
    branch.current.style.height = `${(yearIndex * OFFSET) + 45}px`
  }, [yearIndex])
  
  return (
    <div className="tree-wrapper">
      <div className="tree">
        <div ref={branch} className="branch">
          {/*
          .....
          */}
        </div>
        <svg className="pot">
          ....
        </svg>
      </div>
    </div>
  )
}

The constant OFFSET is dependent on the size of the leaf and the static value 45 is the initial height of the branch. The branch grows upward since the position has been set absolute with bottom value.

.branch {
  position: absolute;
  bottom: 50px; /* placing it above the pot */
  left: 30px;
  transition: 0.4s height ease-out;
  will-change: height;
}

Leaf Animation

As the slider value increases, we add two new leaves in the branch on the left and right. The leaf at the top remains fixed.

const Tree = ({ yearIndex }) => {
  ....
  const createArray = (n) => Array.from({ length: n + 1 })
  const [leaves, setLeaves] = useState(createArray(yearIndex))
  
  return (
    <div className="tree-wrapper">
      <div className="tree">
        <div ref={branch} className="branch">
          {leaves.map((_, l) => (
            <Leaf
              key={l}
              index={l}
            />
          ))}
        </div>
        <svg className="pot">
          ....
        </svg>
      </div>
    </div>
  )
}

We create an array based on the value of yearIndex and generate leaves based on that. The Leaf component generates two leaves on each side in each loop.

const Leaf = ({ index }) => {
  const directions = ['left', 'right']
    return (
      <>
        {directions.map((direction, d) => (
          <div
            key={d}
            style={{transform: `${setLeafPosition(index, direction)}`}}
            className={`leaf ${direction}`}
          >
            <LeafStyle />
          </div>
        ))}
      </>
    )
}

LeafStyle component contains the icon for leaf. We are using only one icon, and based on the direction, we are setting the position and angle of the leaf.

const setLeafPosition = (index, direction) => {
  const isLeft = direction === 'left'
  const deg = isLeft ? 75 : 5
  const yPosition = index * OFFSET
  return `translate(0, ${-yPosition}px) rotate(${deg}deg)`
}

The position of each leaf has also been set as absolute. The left leaf has been raised a little bit from right to give an illusion of alternate ordering.

.leaf {
  position: absolute;
  bottom: 0;
  transition: 0.8s transform ease;
  
  &.right {
    left: 0;
    bottom: 2px;
    transform-origin: bottom left; /* To scale from the corner */
  }
  
  &.left {
    right: 0;
    bottom: -8px;
    transform-origin: top right;
  }
}

Now, to add the appearance animation of leaf, we need to import the react-transition-group package in Leaf component.

...
import { CSSTransition } from 'react-transition-group'
...

const Leaf = ({ index }) => {
  const [isEntered, setIsEntered] = useState(false)
  const nodeRef = useRef(null)
  ....
  useEffect(() => {
    // Trigger the animation after the initial render
    setIsEntered(true)
  }, [])
  
  return (
    <CSSTransition
      in={isEntered}
      nodeRef={nodeRef}
      timeout={0}
    >
      {(state) => (
        <div ref={nodeRef}>
          {directions.map((direction, d) => (
            <div
              key={d}
              style={{
                transform: `${setLeafPosition(index, direction)} scale(${
                  state === 'entered' ? 1 : 0
                })`,
              }}
              className={`leaf ${direction}`}
            >
              <LeafStyle />
            </div>
          ))}
        </div>
      )}
    </CSSTransition>
  )
}

We wrap the leaf div with CSSTransition group and control the appearance of leaf based on the scale transform. The scale is set to 1 based on the isEntered prop, which is set to true on component mounts. The animation works perfectly when new leaves are added, but there is no animation when leaves are removed.

To fix the disappearance animation issue, we set a timeout to delay the new array assignment process in Tree component.

const Tree = ({ yearIndex }) => {
  ....
  useEffect(() => {
    ...
    let timer = setTimeout(() => {
      setLeaves(createArray(yearIndex));
    // if the previous value is greater than current one we set delay
    }, leaves.length > yearIndex + 1 ? 1500 : 0)
    return () => {
        clearTimeout(timer)
    }
  }, [yearIndex, leaves])
  
  return (
    ...
    <Leaf index={l} key={l} visible={l < yearIndex + 1} />
    ...
  )
}

We also set a prop visible in Leaf component which will clear the leaf if the Slider value has been decreased. So, when slider value is less than before, the visible prop becomes false and since the array assignment is set to delay in this scenario, the disappearance animation now works and the array is reassigned after the delay.

...
const Leaf = ({ index, visible }) => {
  ....
  return (
    <CSSTransition
      in={isEntered && visible}
      ...
    >
      ....
    </CSSTransition>
  )
}

That's mostly the basic idea of how this animation has been built. If you want to see the full code, you can checkout out here