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
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
details
element DOM node - Attach a
click
event listener
When the click event happens
- Prevent the event default behavior
- Calculate the
initial
height of thedetails
element - Calculate the
next
value, flipping the currentdetails.open
value
If we are opening
- Immediately open it! The hidden overflow
CSS
property and themax-height
, will prevent the content from leaking. - Calculate the
height
of the hidden content, and add it to theinitial
height - Set this as the
max-height
of the details element, this triggers thetransition
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 thedetails
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)
}
})
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
details
element fires atoggle
event once the its value changes. Since this will fire when theelement.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;
}
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)