On many websites, one might find a nifty little component called the “accordion” where you can expand/collapse content that lies within. But, oddly enough, at the time of this writing, I could not find a fully-featured collapsible / expandable accordion element for React.
A “Good” Accordion Component?
When I set out looking for an Accordion React component, I found dozens of options, but none fulfilled all of my requirements.
A “good” accordion component needs to:
- Have a button that expands / collapses content;
- Work when the content changes size or user resizes the browser;
- Allow me to customize / style it (i.e. the button can be chevron that rotates when expanding / collapsing);
- Provide a nice, smooth transition when expanding / collapsing the content. No jittery re-renders. No re-scaling of content that is visually unpleasing.
The final requirement of animating the content is what makes this difficult. Naive approaches might alter the height
CSS property, but CSS transitions don’t work for the CSS height
property. Other approaches try to scale the content and animate that, but in my opinion it looks worse than no animation at all.
The only trick I’ve seen that works is setting overflow: hidden
on the content along with max-height
to 0
when collapsed and to el.scrollHeight
when expanded. This means that some JavaScript is required to keep the max-height
in sync with the element’s scrollHeight
.
Here’s a quick tutorial on how to accomplish this: How to build a React accordion menu from scratch - LogRocket Blog
But… the plot thickens…
What if the content height changes after accordion is expanded?
It’s easy enough to create a component that sets the max-height
based on the content’s scrollHeight
using DOM references in React, but what if the user resizes the browser window? Or what if the content inside of the accordion changes, affecting the height of the content? Now, it seems we need to abandon our beautiful CSS transition on max-height
? Here comes ResizeObserver
to the rescue!
ResizeObserver
is a little-known API that allows one to monitor changes to the dimensions of an element’s content, while avoiding infinite callback loops and cyclic dependencies that are often created when resizing. Can you use it? Yes, it works in modern browsers!
useResizeObserver
Hook in React
With a little bit of code, one can write a React hook useResizeObserver
and use it like this:
const [expand, setExpand] = useState(false) // initially collapsed
// Create a `contentRef` that gets attached to a `ResizeObserver`. `target` is updated whenever
// the observer detects a change in the dimensions of the `contentRef`.
const [contentRef, { target }] = useResizeObserver()
...
<div ref={contentRef} style={expand && target ? { maxHeight: target.scrollHeight } : null}>
...
And then, the useResizeObserver
implementation looks like this (adapted from https://tobbelindstrom.com/blog/resize-observer-hook/):
// Returns a `[ref, observerEntry]` pair. `observerEntry` is automatically
// updated when the observed `ref` reports size changes
// Adapted from: https://tobbelindstrom.com/blog/resize-observer-hook/
// `observerEntry` is documented here:
// https://w3c.github.io/csswg-drafts/resize-observer/#resizeobserverentry
const useResizeObserver = () => {
const [node, setNode] = useState(null)
const [observerEntry, setObserverEntry] = useState({})
// `observer` needs to persist for full lifetime of hook
const observer = useRef(null)
// Clean up the `observer` (triggered when `node` changes)
const disconnect = useCallback(
() => observer.current && observer.current.disconnect(),
[]
)
// Create new observer when `node` changes
const observe = useCallback(() => {
try {
observer.current = new ResizeObserver(([entry]) =>
setObserverEntry(entry)
)
if (node) observer.current.observe(node)
} catch (err) {
// Fallback to unobserved behavior using initial node and size
setObserverEntry(
node
? {
target: node,
contentRect: node.getBoundingClientRect(),
}
: {}
)
}
}, [node])
// Whenever `node` changes, create a new observer and disconnect the old one
useLayoutEffect(() => {
observe()
return () => disconnect()
}, [disconnect, observe])
return [setNode, observerEntry]
}