Accordion/Collapsible Web Content and React

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]
}

References:

1 Like