Agents (llms.txt)
Octocat

useElementTransition

A hook to manage the mounting, unmounting, and transition status of a DOM element with lifecycle-related CSS transitions

Dependencies

Source Code

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

PropDefaultTypeDescription
shouldMount *-booleanControls 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

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