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-heightwe can achieve auto-dimension height effect, while doing collapse/expand of the details element. - Even if
JavaScriptis 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
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>
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;
}
Notice I am using a CSS variable with a default value of 0.3s.
Finally the magic, JavaScript.
- Somehow gain access to the
detailselement DOM node - Attach a
clickevent listener
When the click event happens
- Prevent the event default behavior
- Calculate the
initialheight of thedetailselement - Calculate the
nextvalue, flipping the currentdetails.openvalue
If we are opening
- Immediately open it! The hidden overflow
CSSproperty and themax-height, will prevent the content from leaking. - Calculate the
heightof the hidden content, and add it to theinitialheight - Set this as the
max-heightof the details element, this triggers thetransition
Else, if we are closing
- set the max-height to the
initialvalue - create a timeout with duration equal to the duration of the transition
- when timeout runs its course, set the
nextvalue on thedetailselement
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)
}
})
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;`
)
}
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
}
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)
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
We've accomplished a re-usable expand/collapse effect.
The
detailselement fires atoggleevent once the its value changes. Since this will fire when theelement.open = nextassignment 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;
}
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)