<div id="app"></div>
.collapsible {
  background-color: rgba(0,0,255,0.2);
  
  > .collapsible-contents {
    height: auto;
    transition: height 150ms ease-out;
  }
}
/**
 * Get the transitionEnd event name
 *
 * Creates an element temporarily to read CSS properties and
 * determine what the name of the "transition end" event name
 * is so it can be bound to.
 * 
 * @return String
 */
const getTransitionEnd = () => {
  const el = document.createElement('_')
  
  if ('webkitTransition' in el.style) {
    return 'webkitTransitionEnd'
  }

  return 'transitionend'
}

// Define the transitionEnd event name
const transitionEnd = getTransitionEnd()

class Collapsible extends React.Component {
  constructor(props) {
    super(props)

    // Setup initial state as collapsed
    this.state = {
      height: 0,
      open: false,
      overflow: 'hidden'
    }

    this.handleSubToggle = this.handleSubToggle.bind(this)
    this.resetHeight = this.resetHeight.bind(this)
  }

  /**
   * Toggle sub-navigation
   *
   * Sets the scrollHeight of the element being expanded or collapsed
   * so that it can be animated using a CSS transition rule.
   */
  handleSubToggle() {
    const { open } = this.state
    const newOpen = !open

    // If opening, make sure to remove the fixed height after animating open
    if (newOpen) {
      this.collapsible.addEventListener(transitionEnd, this.resetHeight)
    }

    this.setState(
      {
        open: newOpen,
        // Always start the state with the calculated height so we can animate
        height: this.collapsible.scrollHeight
      },
      () => {
        // If closing, wait one frame to ensure the calculated height is rendered,
        // then set the height to 0 so it can animate closed
        if (!newOpen) {
          // Wait until the height is applied frame before applying the 0 height state
          // so the calculated height is able to be applied and transitioned from. We
          // use setTimeout instead of requestAnimationFrame because sometimes the browser
          // can render frames faster than it can paint new heights
          setTimeout(() => {
            this.setState({
              height: 0,
              overflow: 'hidden'
            })
          }, 0)
        }
      }
    )
  }

  resetHeight(event) {
    // Transitions bubble, make sure its only the transition on the collapsible that we're
    // dealing with before reseting the height and unbinding
    if (event.target === this.collapsible) {
      this.setState({
        // Setting height to null removes the inline style so the stylesheet 
        // definition of auto is inherited
        height: null,
        // Allow overflow visible so any sub-sub navigation, tooltips, draggables, etc. 
        // can also be displayed and affect height
        overflow: 'visible'
      })

      // Once height change has been applied, make sure and clean up the
      // event listener (it will be re-applied when toggled again)
      this.collapsible.removeEventListener(transitionEnd, this.resetHeight)
    }
  }

  render() {
    // containerElement is converted to a capitalized variable so Babel/React will see
    // it as a componentized element allowing you to dynamically assign the containing
    // native HTML element tag. This ONLY works for HTML elements, not user generated
    // React components. If you don't specify an actual HTML element, React will yell
    // at you in the console.
    const { label, containerElement: ContainerElement } = this.props
    const { height, open, overflow } = this.state

    return (
      <div className={`collapsible ${open ? 'open' : ''}`}>
        <button onClick={this.handleSubToggle}>
          <span>{label}</span>
          <span>{open ? "-" : "+"}</span>
        </button>
        <ContainerElement className="collapsible-contents" ref={(elem) => this.collapsible = elem} style={{ height, overflow }}>
          {this.props.children}
        </ContainerElement>
      </div>
    )
  }
}

Collapsible.defaultProps = {
  label: 'Toggle Me',
  containerElement: 'ul'
}

ReactDOM.render(
  <Collapsible>
    <li>Level 1</li>
    <li>Level 1</li>
    <li>Level 1</li>
    <li>Level 1</li>
    <li>
      <Collapsible label="Moar..." containerElement="ol">
        <li>Level 2</li>
        <li>Level 2</li>
        <li>Level 2</li>
        <li>Level 2</li>
        <li>
          <Collapsible label="Even Moar..." containerElement="dl">
            <dt>You</dt>
            <dd>get</dd>
            <dt>the</dt>
            <dd>idea</dd>
            <dd>
              <Collapsible label="Or maybe not..." containerElement="div">
                <p>Because who doesn't love XKCD?</p>
                <img src="https://imgs.xkcd.com/comics/model_rail.png" />
              </Collapsible>
            </dd>
          </Collapsible>
        </li>
      </Collapsible>
    </li>
  </Collapsible>,
	document.getElementById('app')
)
View Compiled

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0/css/bootstrap.css

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/umd/react.development.js
  2. https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.2.0/umd/react-dom.development.js