Octocat

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 transition
  • isMounted (boolean): Whether the element should be rendered in the DOM
  • status ("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