useElementTransition
A hook to manage the mounting, unmounting, and transition status of a DOM element with lifecycle-related CSS transitions
Dependencies
Source Code
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { nextFrame } from '@/lib/dom/next-frame';
type Status = 'unmounted' | 'initial' | 'open' | 'closed';
interface UseElementTransitionReturn<T extends HTMLElement> {
ref: React.RefObject<T | null>;
isMounted: boolean;
status: Status;
}
/**
* React hook to manage the mounting, unmounting, and transition states of a DOM element,
* typically for animating the appearance and disappearance of UI components with CSS transitions.
*
* This hook ensures that the element remains mounted (`isMounted` is `true`) until all CSS transitions or animations have completed.
*
* Inspired by the `useTransitionStatus` hook from Floating UI:
* @see https://floating-ui.com/docs/useTransition#usetransitionstatus
*
* @example
* ```tsx
* const { ref, isMounted, status } = useElementTransition<HTMLDivElement>(open);
*
* if (!isMounted) return null;
*
* return (
* <div ref={ref} data-status={status} className="data-[status=closed]:opacity-0 transition-all">
* Content
* </div>
* );
* ```
*/
export const useElementTransition = <T extends HTMLElement>(
shouldMount: boolean
): UseElementTransitionReturn<T> => {
const ref = useRef<T>(null);
const [isMounted, setIsMounted] = useState(shouldMount);
const [status, setStatus] = useState<Status>(
shouldMount ? 'open' : 'unmounted'
);
if (shouldMount && !isMounted) {
setIsMounted(true);
}
useEffect(() => {
const element = ref.current;
if (!element) return;
if (shouldMount || !isMounted) return;
const triggerUnmount = () => {
setIsMounted(false);
setStatus('unmounted');
};
nextFrame(() => {
const hasTransitions = element.getAnimations().length > 0;
if (hasTransitions) {
const onTransitionEnd = () => {
const isFinished = element.getAnimations().length === 0;
if (isFinished) {
triggerUnmount();
}
};
element.addEventListener('transitionend', onTransitionEnd);
element.addEventListener('transitioncancel', onTransitionEnd);
return () => {
element.removeEventListener('transitionend', onTransitionEnd);
element.removeEventListener('transitioncancel', onTransitionEnd);
};
} else {
triggerUnmount();
}
});
}, [shouldMount, isMounted]);
useLayoutEffect(() => {
if (!ref.current) return;
if (shouldMount) {
setStatus('initial');
return nextFrame(() => {
flushSync(() => setStatus('open'));
}, 2); // firefox needs a second frame for the initial transition to work
}
setStatus('closed');
}, [shouldMount]);
return { ref, isMounted, status };
}; Features
- Non-interrupted Transitions: Ensures elements remain mounted until CSS transitions complete
- Status Tracking: Provides transition status states for fine-grained control
- Animation-Aware: Automatically detects CSS animations and transitions, supporting disabled animations via CSS
API Reference
| Prop | Default | Type | Description |
|---|---|---|---|
shouldMount * | - | boolean | Controls whether the element should be mounted or unmounted |
The hook returns an object containing:
ref(RefObject<T>): A ref object to attach to the element you want to transitionisMounted(boolean): Whether the element should be rendered in the DOMstatus("unmounted" | "initial" | "open" | "closed"): The current transition status
Examples
Basic Usage
'use client';
import { useState } from 'react';
import { Button } from '@/components/button';
import { cn } from '@/lib/utils/classnames';
import { useElementTransition } from '../use-element-transition';
function UseElementTransitionDefaultPreview() {
const [toggled, setToggled] = useState(false);
const { ref, isMounted, status } =
useElementTransition<HTMLDivElement>(toggled);
return (
<div className="relative">
<Button onClick={() => setToggled((v) => !v)}>Toggle Box</Button>
{isMounted && (
<div
ref={ref}
data-status={status}
className={cn(
'absolute mt-2 aspect-square w-full rounded-md bg-amber-200',
'transition-all duration-300 ease-in-out motion-reduce:transition-none',
'data-[status=initial]:translate-y-full data-[status=initial]:opacity-0',
'data-[status=closed]:rotate-180 data-[status=closed]:scale-0 data-[status=closed]:opacity-0'
)}
/>
)}
</div>
);
}
export default UseElementTransitionDefaultPreview; Previous
useDetectDevice
Next
useIntersectionObserver