<toggle-section open="false">
    <h2>Section 1</h2>
    <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam non lectus sit amet nunc facilisis molestie. Praesent quis libero et mauris facilisis dignissim at sed nisi.</p> 
    <p>Nullam efficitur porttitor lectus, ac finibus nibh fermentum ac. Phasellus aliquam, nibh non efficitur pharetra, tellus diam posuere lectus, a consequat elit ex nec ligula.</p>
  <toggle-section open="true">
    <h2>Section 2</h2>
    <p>Aliquam erat volutpat. Nulla facilisi. Nunc porttitor, elit non eleifend aliquam, est leo scelerisque nibh, nec faucibus odio urna ac nulla.</p> 
    <p>Maecenas laoreet in metus eget convallis. Vivamus at eleifend felis. Proin non vehicula neque. Etiam eleifend sapien ut nulla malesuada, ac condimentum nisl efficitur.</p>
html {
  font-family: Arial, sans-serif;

body {
  max-width: 40rem;
  margin: 0 auto;
  padding: 1em;

.controls {
 text-align: right;
 margin-bottom: 1em;

.controls li {
  display: inline;

button {
  background: #000;
  color: #fff;
  border: 0;
  font-size: 0.85rem;
  border-radius: 0.25rem;

Custom elements are inline by default
toggle-section {
  display: block;

Only applies if script runs and 
`role="region"` is added
toggle-section[role="region"] {
  border-width: 2px 0;
  border-style: solid;

toggle-section[role="region"] + toggle-section {
  border-top: 0;
(function() {
  // Check for <template> support
  if ('content' in document.createElement('template')) {
    const tmpl = document.createElement('template')

    // Create the web component's template
    // featuring a <slot> for the Light DOM content
    tmpl.innerHTML = `
        <button aria-expanded="false">
          <svg aria-hidden="true" focusable="false" viewBox="0 0 10 10">
            <rect class="vert" height="8" width="2" y="1" x="4"/>
            <rect height="2" width="8" y="4" x="1"/>
      <div class="content" hidden>
        h2 {
          margin: 0;

        h2 button {
          all: inherit;
          box-sizing: border-box;
          display: flex;
          justify-content: space-between;
          width: 100%;
          padding: 0.5em 0;

        h2 button:focus svg {
          outline: 2px solid;

        button svg {
          height: 1em;
          margin-left: 0.5em;

        [aria-expanded="true"] .vert {
          display: none;

        [aria-expanded] rect {
          fill: currentColor;
    // Check for latest Shadow DOM syntax support
    if (document.head.attachShadow) {
      class ToggleSection extends HTMLElement {
        constructor() {

          // Make the host element a region
          this.setAttribute('role', 'region')

          // Create a `shadowRoot` and populate from template 
          this.attachShadow({ mode: 'open' })

          // Assign the toggle button
          this.btn = this.shadowRoot.querySelector('h2 button')

          // Get the first element in Light DOM
          // and cast its heading level (which should, but may not, exist)
          const oldHeading = this.querySelector(':first-child')
          let level = parseInt(oldHeading.tagName.substr(1))

          // Get the Shadow DOM <h2>
          this.heading = this.shadowRoot.querySelector('h2')
           // If there is no level, there is no heading.
          // Add a warning.
          if (!level) {
            console.warn('The first element inside each <toggle-section> should be a heading of an appropriate level.')
          // If the level is a real integer and not 2
          // set `aria-level` accordingly
          if (level && level !== 2) {
            this.heading.setAttribute('aria-level', level)

          // Add the Light DOM heading label to the innerHTML of the toggle button
          // and remove the now unwanted Light DOM heading
          this.btn.innerHTML = oldHeading.textContent + this.btn.innerHTML

          // The main state switching function
          this.switchState = () => {
            let expanded = this.getAttribute('open') === 'true' || false

            // Toggle `aria-expanded`
            this.btn.setAttribute('aria-expanded', expanded)
            // Toggle the `.content` element's visibility
            this.shadowRoot.querySelector('.content').hidden = !expanded

          // Change the component's `open` attribute value on click
          // (which will, in turn, trigger switchState(), see below)
          this.btn.onclick = () => { 
            this.setAttribute('open', this.getAttribute('open') === 'true' ? 'false' : 'true')

        // Identify just the `open` attribute as an observed attribute
        static get observedAttributes() {
          return ['open']

        // When `open` changes value, execute switchState()
        attributeChangedCallback(name) {
          if (name === 'open') {

      // Add our new custom element to the window for use
      window.customElements.define('toggle-section', ToggleSection) 
      // Define the expand/collapse all template
      const buttons = document.createElement('div')
      buttons.innerHTML = `
        <ul class="controls" aria-label="section controls">
          <li><button id="expand">expand all</button></li>
          <li><button id="collapse">collapse all</button></li>

      // Get the first `toggle-section` on the page
      // and all toggle sections as a node list
      const first = document.querySelector('toggle-section')
      const all = document.querySelectorAll('toggle-section')

      // Insert the button controls before the first <toggle-section>
      first.parentNode.insertBefore(buttons, first)

      // Place the click on the parent <ul>...
      buttons.addEventListener('click', (e) => {
        // ...then determine which button was the target 
        let expand = e.target.id === 'expand' ? true : false

        // Iterate over the toggle sections to switch
        // each one's state uniformly
        Array.prototype.forEach.call(all, (t) => {
          t.setAttribute('open', expand)

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.