React Disclosure Interactions: Smooth Motion, Rapid Reversal, and Accessibility
Build collapsible regions with CSS Grid and retained DOM, handle rapid reversals, focus, inert, and reduced-motion preferences, and separate component tests from browser acceptance checks.
A workspace has many collapsible regions: file trees, field groups, attachment details, and sidebar panels. Each looks like a simple boolean state on its own. Together, inconsistent expansion speed, arrow feedback, and focus behavior noticeably disrupt the rhythm of use.
When standardizing these interactions, I wanted a simple shared implementation: content height could change, rapid clicks could reverse the transition, form state could survive, and hidden content could not accidentally receive keyboard focus. Animation was only one part of that work.
The following example is intended for ordinary content groups and uses modern React’s inert attribute syntax. Virtualized lists, nested scroll regions, and cases that need large amounts of content to unmount require separate evaluation.
First, decide whether to retain the content
open && <Content /> is straightforward, but closing removes the content from the DOM immediately, preventing an exit animation and losing internal state that has not been lifted. Keeping the DOM usually better matches user expectations for form groups that are repeatedly reopened.
The cost is real too: a closed component may retain subscriptions, timers, and data requests. Large lists should not keep all their content mounted indefinitely just for animation. Unmounting should depend on state-retention needs and resource costs; a collapsible container cannot make one decision for every scenario.
Let layout take part in the transition
For content with natural height, the outer element can use Grid and transition between 0fr and 1fr. Give the inner element min-height: 0 and clipping so the content’s minimum size cannot prop open the closed state.
.collapse-region {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition: grid-template-rows 160ms ease-out,
opacity 160ms ease-out, visibility 0s;
}
.collapse-region[data-state="closed"] {
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition: grid-template-rows 160ms ease-out,
opacity 160ms ease-out, visibility 0s 160ms;
}
.collapse-inner { min-height: 0; overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.collapse-region,
.collapse-region[data-state="closed"] { transition: none; }
}
Delaying visibility when closing allows the height and opacity changes to finish; opening restores visibility immediately. With reduced motion enabled, the layout goes directly to its target state. MDN’s prefers-reduced-motion documentation explains the user preference behind this media query.
Rapid clicks do not need a separate animation queue. A state change lets the browser transition from the current interpolated position toward the new target. This responds more directly to user action than waiting for the previous animation to finish. It still needs checking in target browsers, because layout, content size, and nested transitions can affect the result.
Handle visual hiding and interaction together
Zero opacity does not prevent interaction, and aria-hidden does not automatically remove internal controls from keyboard focus. The closed state must also make the whole region noninteractive.
inert removes descendants from focus and interaction paths and affects the accessibility tree. Its behavior and support are described in MDN’s inert reference. One important ordering detail remains: if focus is inside the content, move it back to the trigger before making the region inert.
Here is a self-contained component example that can be composed with other content:
import { useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
export function Disclosure({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const id = useId();
const trigger = useRef<HTMLButtonElement>(null);
const region = useRef<HTMLDivElement>(null);
function toggle() {
if (open && region.current?.contains(document.activeElement)) {
trigger.current?.focus();
}
setOpen(value => !value);
}
return <section>
<button ref={trigger} type="button" onClick={toggle}
aria-expanded={open} aria-controls={id}>
Specification details
</button>
<div ref={region} id={id} className="collapse-region"
data-state={open ? 'open' : 'closed'}
inert={!open} aria-hidden={!open}>
<div className="collapse-inner">{children}</div>
</div>
</section>;
}
A native button supplies keyboard activation, aria-expanded communicates the current state, and aria-controls connects it to the content region. An external “Collapse all” action must reuse the focus handling as well, rather than bypassing the button and changing state directly. This local example does not expose controlled state; adding that capability requires unifying all the ways the region can close.
The inner layout affects the result too
The example’s overflow: hidden suits ordinary content. A real workspace may need internal scrolling; with overflow: auto, check whether scrollbars flash during transitions and whether the scroll position survives closing. Popover menus and focus outlines can also be clipped and need component-specific handling.
Place spacing inside the wrapper that can shrink, and avoid external fixed heights or margins that leave gaps because they do not participate in the collapse. Arrow rotation and content transitions can share a duration, but every row does not need its own animation. In dense interfaces, continuous response matters more than elaborate effects.
Verify state and appearance separately
Component tests are useful for checking expanded state, associated IDs, inert, retained content, and focus returning after closing. Real browsers also need inspection of intermediate frames, rapid reversals, long content, scrolling, and reduced-motion preferences. These checks complement each other: correct DOM attributes do not prove the animation feels natural.
In previous work, I checked some controls in the browser but did not complete slow inspection of every collapsible control. That result therefore cannot be described as verification of the whole workspace. When expanding a shared component’s use, list representative scenarios and replace existing implementations gradually.
A good disclosure interaction should first move content and focus as expected. Brief, continuous motion can help users understand the change, while directly reaching the same state should remain fully usable when they request reduced motion.