Utilities
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.
Prop | Type | Default |
---|---|---|
onExitComplete? | (() => void) | - |
forceMount? | boolean | false |
children | ReactElement<{ ref?: Ref<HTMLElement> | undefined; }, string | JSXElementConstructor<any>> | ((props: { present: boolean; }) => ReactElement<...>) | - |
present | boolean | - |
Credits
- Radix's Presence - For the core presence management and animation state handling
- Motion's AnimatePresence - For the animation presence patterns handling