DEV Community

Cover image for Use more HTML Elements - Expand/Collapse with details
Joseph
Joseph

Posted on

Use more HTML Elements - Expand/Collapse with details

Before you continue...

This will use JavaScript. A little bit of it.

This time I explore the details and summary elements.

  • Using JavaScript/CSS to transition max-height we can achieve auto-dimension height effect, while doing collapse/expand of the details element.
  • Even if JavaScript is disabled, the user will be able to see the hidden contents, without the auto-dimension effect.

More about these elements in the MDN page.

Sadly, these two elements are not supported in IE

Expectation

Auto-dimension height

Implementation

First thing, the HTML. The contents of the summary tags are always shown. Upon user interaction, the other children of details are shown.

For this demo I'll work with only two children, one of which is summary. However the implementation can be adapted to account for many children, or your HTML can be written so that you always have one child besides the summary tag.

<details>
    <summary>Details</summary>
    <p>Some content that reveals more details</p>
</details>
Enter fullscreen mode Exit fullscreen mode

Next the styling, this time the CSS will be very simple.

details {
  height: auto;
  overflow: hidden;
  transition: max-height ease-in-out;
  transition-duration: var(--duration, 0.3s);
}

summary {
    cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

Notice I am using a CSS variable with a default value of 0.3s.

Finally the magic, JavaScript.

  • Somehow gain access to the details element DOM node
  • Attach a click event listener

When the click event happens

  • Prevent the event default behavior
  • Calculate the initial height of the details element
  • Calculate the next value, flipping the current details.open value

If we are opening

  • Immediately open it! The hidden overflow CSS property and the max-height, will prevent the content from leaking.
  • Calculate the height of the hidden content, and add it to the initial height
  • Set this as the max-height of the details element, this triggers the transition

Else, if we are closing

  • set the max-height to the initial value
  • create a timeout with duration equal to the duration of the transition
  • when timeout runs its course, set the next value on the details element
const details = document.querySelector('details')

const initial = details.offsetHeight
const duration = 600
let height = 0

details.setAttribute(
    'style',
    `--duration: ${duration / 1000}s; max-height: ${initial}px;`
)

details.addEventListener('click', e => {
    e.preventDefault()

    const next = !details.open

    if (next) {
        details.open = next

        if (document.createRange) {
            let range = document.createRange()
            range.selectNodeContents(details.lastElementChild)
            if (range.getBoundingClientRect) {
                let rect = range.getBoundingClientRect()
                if (rect) {
                    height = rect.bottom - rect.top
                }
            }
        }

        details.setAttribute(
            'style',
            `--duration:${duration / 1000}s; max-height: ${initial + height}px;`
        )

    } else {
        details.setAttribute(
            'style',
            `--duration: ${duration / 1000}s; max-height: ${initial}px;`
        )
        setTimeout(() => {
            details.open = next
        }, duration)
    }
})
Enter fullscreen mode Exit fullscreen mode

That's a lot of code 🤯. Let's refactor. I am not a fan of wrapping native stuff, but I'll be using this quite a bit.

function setInlineAttribute({ element, duration, maxHeight }) {
    element.setAttribute(
        'style',
        `--duration: ${duration / 1000}s; max-height: ${maxHeight}px;`
    )
}
Enter fullscreen mode Exit fullscreen mode

Isolate the range bounding client rectangle bit. This one is incredibly important, because it allows us to have a precise measure of what the maximum height should be, ensuring the transitions last exactly the time we want. More on the range API.

Sadly the range API is also not supported in IE

function calculateContentHeight(element) {
    if (document.createRange) {
        let range = document.createRange()
        range.selectNodeContents(element.lastElementChild)
        if (range.getBoundingClientRect) {
            let rect = range.getBoundingClientRect()
            if (rect) {
                return rect.bottom - rect.top
            }
        }
    }
    return 0
}
Enter fullscreen mode Exit fullscreen mode

A function to collect initial values, set styles and attach the click event listener.

function animateDetailsElement(element, duration = 600) {
    const initial = element.offsetHeight
    let height = 0

    setInlineAttribute({ element, duration, maxHeight: initial })

    element.addEventListener('click', e => {
        e.preventDefault()

        const next = !element.open

        if (next) {
            element.open = next
            height = calculateContentHeight(element)
            setInlineAttribute({ element, duration, maxHeight: initial + height })

        } else {
            setInlineAttribute({ element, duration, maxHeight: initial })
            setTimeout(() => {
                element.open = next
            }, duration)
        }
    })
}

const details = document.querySelector('details')
animateDetailsElement(details)
Enter fullscreen mode Exit fullscreen mode

Why do we calculate the content height and apply it as an in-line style, containing max-height and duration CSS variable?

One of the easiest techniques to create expand/collapse, is to transition the max-height, but in this article on auto-dimensions author Brandon Smith, points out two disadvantages of it.

The obvious disadvantage is that we still have to hard-code a maximum height for the element, even if we don’t have to hard-code the height itself. The second, less obvious downside, is that the transition length will not actually be what you specify unless the content height works out to be exactly the same as max-height. - Brandon Smith

The approach taken here, has a few advantages.

  • Manages open/close state, through the details element
  • Helps you to calculate the maximum height needed for your content
  • Because you calculate the exact maximum height, the duration of the transition will be what you specify

And the downside that it requires JavaScript.

In this implementation I've also put effort into having the duration be declared in the JavaScript side, and then transmitted to the CSS using an in-line CSS variable. That's ugly, but it works.

Refactoring further to reduce the scope of the height variable, and have a means to remove the event listener.

function animateDetailsElement(element, duration = 600) {
    let initial = element.offsetHeight

    setInlineAttribute({ element, duration, maxHeight: initial })

    function handler(e) {
        e.preventDefault()

        const next = !element.open

        if (next) {
            element.open = next
            let height = initial + calculateContentHeight(element)
            setInlineAttribute({ element, duration, maxHeight: height })

        } else {
            setInlineAttribute({ element, duration, maxHeight: initial })
            setTimeout(() => {
                element.open = next
            }, duration)
        }
    }

    element.addEventListener('click', handler);

    return () => element.removeEventListener('click', handler);
}

const details = document.querySelectorAll("details");
details.forEach(el => animateDetailsElement(el)) 
// .forEach(animateDetailsElement) would cause the duration to equal the index of el
Enter fullscreen mode Exit fullscreen mode

We've accomplished a re-usable expand/collapse effect.

The details element fires a toggle event once the its value changes. Since this will fire when the element.open = next assignment happens, you could do further things, knowing the animation has finished.

Perhaps you don't like the triangle shown, the summary element can further be styled, though support is a little patchy.

details > summary {
    list-style: none;
}
/* Chrome fix */
details > summary::-webkit-details-marker {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

What do you think?

Sometimes JavaScript is necessary to create a smoother experience, but it shouldn't prevent the experience from happening if JavaScript is blocked by the user.

In this case, if the user deactivated JavaScript, we'd still have the expand/collapse functionality, though content would just pop into existence.

Happy Hacking!

Top comments (0)