Collapse

VanillaJS collapse with transition in 60 lines of code

by Arnold Daniels

HTML

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">
<section>
  <details>
    <summary>Lorem Ipsum</summary>
    <p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>
  </details>
</section>

<section>
  <aside class="menu">
    <p class="menu-label">
      General
    </p>
    <ul class="menu-list">
      <li><a>Dashboard</a></li>
      <li><a>Customers</a></li>
    </ul>
    <p class="menu-label">
      Administration
    </p>
    <ul class="menu-list collapse">
      <li><a>Team Settings</a></li>
      <li>
        <a class="is-active">Manage Your Team</a>
        <ul>
          <li><a>Members</a></li>
          <li>
            <a>Plugins</a>
            <ul>
              <li><a>Payments</a></li>
              <li><a>Transfers</a></li>
              <li><a>Balance</a></li>
            </ul>
          </li>
          <li><a>Add a member</a></li>
        </ul>
      </li>
      <li><a>Invitations</a></li>
      <li><a>Cloud Storage Environment Settings</a></li>
      <li><a>Authentication</a></li>
    </ul>
    <p class="menu-label">
      Transactions
    </p>
    <ul class="menu-list">
      <li><a>Payments</a></li>
      <li><a>Transfers</a></li>
      <li><a>Balance</a></li>
    </ul>
  </aside>
</section>

<section>
  <button type="button" class="button" id="lorem-button">Lorem Ipsum</button>
  
  <div id="lorem" class="collapse">
    <p>Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.</p>
  </div>
</section>

SCSS

details {
  transition: height 0.5s;
  overflow: hidden;
}

summary {
  cursor: pointer;

  &:focus {
    outline: none;
  }
}

ul.collapse {
  li {
    transition: all 0.4s;
    overflow: hidden;
  }

  li:not(.is-open) > ul {
    display: none;
  }
}

div.collapse {
  transition: height 0.2s;
  overflow: hidden;
  
  &:not(.is-open) {
    display: none;
  }
}

/* Spacing, has nothing to do with collapse */
body {
  padding: 20px;
}

section+section {
  margin-top: 40px;
}

JavaScript

function delegateEvent(type, selector, handler) {
  document.addEventListener(type, function(event) {
    for (var target = event.target; target && target != this; target = target.parentNode) {
      if (target.matches(selector)) {
        handler.call(target, event);
        break;
      }
    }
  }, false);
}

function toggleCollapse(details, summary, event) {
  if (details.style.height !== '') {
    if (event !== undefined) event.preventDefault();
    return; // Still in transition
  }

  const isDetailsNode = details.tagName.toLowerCase() === 'details';

  if (!hasTransition(details, 'height')) {
  	if (isDetailsNode) return; // <details> does this natively
    details.classList.toggle('is-open'); // Simple toggle
    return;
  }

  const isOpen = isDetailsNode ? details.open : details.classList.contains('is-open');
  details.style.height = details.offsetHeight + "px";

  if (isDetailsNode && isOpen && event !== undefined) event.preventDefault(); // Don't hide during transition
  if (!isDetailsNode && !isOpen) details.classList.add('is-open');

  setTimeout(function() {
  	const toHeight = isOpen
    	? (summary !== undefined ? summary.offsetHeight : 0)
      : details.scrollHeight;

		if (details.style.height === toHeight + "px") { // No transition
      details.dispatchEvent(new Event('transitionend'));
      return;
    }
    
    details.style.height = toHeight + "px";
  });

  details.addEventListener('transitionend', function() {
    if (isDetailsNode && isOpen) details.open = false;
    if (!isDetailsNode && isOpen) details.classList.remove('is-open');

    details.style.height = '';
  }, {once: true});
}

function hasTransition(element, property) {
  const transitions = getComputedStyle(element).transition.split(/\s*,\s*/);
  const transition = transitions.find((trans) => [property, 'all'].includes(trans.split(/\s+/)[0]));
  
  return transition !== null && !/^\S+\s+0(\.0)?[a-z\s]/.test(transition);
}

delegateEvent('click', 'summary', function(event)...