Dice UI
Utilities

Presence

Manages element mounting and unmounting with animation support.

Installation

CLI

npx shadcn@latest add "https://diceui.com/r/presence"

Manual

Copy and paste the following code into your project.

"use client";
 
import * as React from "react";
 
interface StateMachineConfig<TState extends string, TEvent extends string> {
  initial: TState;
  states: Record<TState, Partial<Record<TEvent, TState>>>;
}
 
function useStateMachine<TState extends string, TEvent extends string>(
  config: StateMachineConfig<TState, TEvent>,
) {
  const [state, setState] = React.useState<TState>(config.initial);
 
  const send = React.useCallback(
    (event: TEvent) => {
      setState((currentState) => {
        const transition = config.states[currentState]?.[event];
        return transition ?? currentState;
      });
    },
    [config.states],
  );
 
  return [state, send] as const;
}
 
function getAnimationName(styles?: CSSStyleDeclaration) {
  return styles?.animationName ?? "none";
}
 
interface PresenceProps {
  present: boolean;
  children?:
    | React.ReactElement<{ ref?: React.Ref<HTMLElement> }>
    | ((props: { present: boolean }) => React.ReactElement<{
        ref?: React.Ref<HTMLElement>;
      }>);
  forceMount?: boolean;
  onExitComplete?: () => void;
}
 
function Presence({
  present,
  children,
  forceMount = false,
  onExitComplete,
}: PresenceProps) {
  const [node, setNode] = React.useState<HTMLElement | null>(null);
  const stylesRef = React.useRef<CSSStyleDeclaration>(
    {} as CSSStyleDeclaration,
  );
  const prevPresentRef = React.useRef(present);
  const prevAnimationNameRef = React.useRef<string>("none");
  const initialState = present ? "mounted" : "unmounted";
 
  const [state, send] = useStateMachine({
    initial: initialState,
    states: {
      mounted: {
        UNMOUNT: "unmounted",
        ANIMATION_OUT: "unmountSuspended",
      },
      unmountSuspended: {
        MOUNT: "mounted",
        ANIMATION_END: "unmounted",
      },
      unmounted: {
        MOUNT: "mounted",
      },
    },
  });
 
  React.useEffect(() => {
    const currentAnimationName = getAnimationName(stylesRef.current);
    prevAnimationNameRef.current =
      state === "mounted" ? currentAnimationName : "none";
  }, [state]);
 
  React.useLayoutEffect(() => {
    const styles = stylesRef.current;
    const wasPresent = prevPresentRef.current;
    const hasPresentChanged = wasPresent !== present;
 
    if (hasPresentChanged) {
      const prevAnimationName = prevAnimationNameRef.current;
      const currentAnimationName = getAnimationName(styles);
 
      if (present) {
        send("MOUNT");
      } else if (node) {
        const hasAnimation =
          (currentAnimationName !== "none" && styles?.display !== "none") ||
          (styles.transitionProperty !== "none" &&
            Number.parseFloat(styles.transitionDuration) > 0);
 
        if (!hasAnimation) {
          send("UNMOUNT");
        } else {
          const isAnimating = prevAnimationName !== currentAnimationName;
          if (wasPresent && isAnimating) {
            send("ANIMATION_OUT");
          } else {
            send("UNMOUNT");
          }
        }
      } else {
        send("UNMOUNT");
      }
      prevPresentRef.current = present;
    }
  }, [present, node, send]);
 
  React.useLayoutEffect(() => {
    if (!node) return;
 
    let timeoutId: number;
    const ownerWindow = node.ownerDocument.defaultView ?? window;
 
    function onAnimationEnd(event: AnimationEvent) {
      if (!node) return;
      const currentAnimationName = getAnimationName(stylesRef.current);
      const isCurrentAnimation = currentAnimationName.includes(
        event.animationName,
      );
 
      if (event.target === node && isCurrentAnimation) {
        send("ANIMATION_END");
        if (!prevPresentRef.current) {
          const currentFillMode = node.style.animationFillMode;
          node.style.animationFillMode = "forwards";
          timeoutId = ownerWindow.setTimeout(() => {
            if (node && node.style.animationFillMode === "forwards") {
              node.style.animationFillMode = currentFillMode;
            }
          });
        }
      }
    }
 
    function onTransitionEnd(event: TransitionEvent) {
      if (!node) return;
      if (event.target === node && !prevPresentRef.current) {
        send("ANIMATION_END");
      }
    }
 
    function onAnimationStart(event: AnimationEvent) {
      if (!node) return;
      if (event.target === node) {
        prevAnimationNameRef.current = getAnimationName(stylesRef.current);
      }
    }
 
    node.addEventListener("animationstart", onAnimationStart);
    node.addEventListener("animationend", onAnimationEnd);
    node.addEventListener("animationcancel", onAnimationEnd);
    node.addEventListener("transitionend", onTransitionEnd);
    node.addEventListener("transitioncancel", onTransitionEnd);
 
    return () => {
      ownerWindow.clearTimeout(timeoutId);
      if (!node) return;
      node.removeEventListener("animationstart", onAnimationStart);
      node.removeEventListener("animationend", onAnimationEnd);
      node.removeEventListener("animationcancel", onAnimationEnd);
      node.removeEventListener("transitionend", onTransitionEnd);
      node.removeEventListener("transitioncancel", onTransitionEnd);
    };
  }, [node, send]);
 
  React.useEffect(() => {
    if (state === "unmounted" && !present && onExitComplete) {
      onExitComplete();
    }
  }, [state, present, onExitComplete]);
 
  const isPresent = ["mounted", "unmountSuspended"].includes(state);
 
  if (!isPresent && !forceMount) return null;
 
  const child =
    typeof children === "function"
      ? children({ present: isPresent })
      : React.Children.only(children);
 
  if (!child) return null;
 
  return React.cloneElement(child, {
    ref: (node: HTMLElement | null) => {
      if (node) stylesRef.current = getComputedStyle(node);
      setNode(node);
    },
  });
}
 
export { Presence };

Usage

import { Presence } from "@/components/presence"
 
export default function App() {
  const [open, setOpen] = React.useState(false)
 
  return (
    <Presence present={open}>
      <div
        data-state={open ? "open" : "closed"}
        className="data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in"
      >
        This content will animate in and out
      </div>
    </Presence>
  )
}

Render Function Pattern

Access presence state through a render function:

import { Presence } from "@/components/presence"
 
export default function App() {
  const [isOpen, setIsOpen] = React.useState(false)
 
  return (
    <Presence present={isOpen}>
      {({ present }) => (
        <div className={present ? "animate-in fade-in-0" : "animate-out fade-out-0"}>
          This content will animate based on presence state
        </div>
      )}
    </Presence>
  )
}

Force Mounting

Use forceMount to keep elements mounted regardless of presence state. Useful for accessibility requirements and focus management:

import { Presence } from "@/components/presence"
 
export default function App() {
  const [isOpen, setIsOpen] = React.useState(false)
 
  return (
    <Presence present={isOpen} forceMount>
      <div 
        className={cn(
          "transition-opacity duration-200",
          isOpen ? "opacity-100" : "opacity-0"
        )}
      >
        This content will always be mounted but will fade in/out
      </div>
    </Presence>
  )
}

API Reference

Presence

A component that manages the presence state of elements with support for animations. It handles mounting, unmounting, and animation states automatically.

PropTypeDefault
onExitComplete?
(() => void)
-
forceMount?
boolean
false
children
ReactElement<{ ref?: Ref<HTMLElement> | undefined; }, string | JSXElementConstructor<any>> | ((props: { present: boolean; }) => ReactElement<...>)
-
present
boolean
-

Credits

On this page