diff --git a/docs/src/app/(private)/experiments/collapsible/_icons.tsx b/docs/src/app/(private)/experiments/collapsible/_icons.tsx
new file mode 100644
index 0000000000..e85233ea4b
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/_icons.tsx
@@ -0,0 +1,21 @@
+'use client';
+import * as React from 'react';
+
+export default function Nothing() {
+ return
This is just a dummy file to hold icons
;
+}
+
+export function ExpandMoreIcon(props: React.SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/animation-old.tsx b/docs/src/app/(private)/experiments/collapsible/animation-old.tsx
new file mode 100644
index 0000000000..e79c53a790
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/animation-old.tsx
@@ -0,0 +1,40 @@
+'use client';
+import * as React from 'react';
+import { Collapsible } from '@base-ui-components/react/collapsible';
+import styles from './animation.module.css';
+
+export default function CollapsibleCssAnimation() {
+ return (
+
+
+ Trigger
+
+
+
+ He rubbed his eyes, and came close to the picture, and examined it
+ again. There were no signs of any change when he looked into the actual
+ painting, and yet there was no doubt that the whole expression had
+ altered. It was not a mere fancy of his own. The thing was horribly
+ apparent.
+
+
+
+
+
+
+ Trigger
+
+
+
+ He rubbed his eyes, and came close to the picture, and examined it
+ again. There were no signs of any change when he looked into the actual
+ painting, and yet there was no doubt that the whole expression had
+ altered. It was not a mere fancy of his own. The thing was horribly
+ apparent.
+
+
+
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/animation.module.css b/docs/src/app/(private)/experiments/collapsible/animation.module.css
new file mode 100644
index 0000000000..e719704c6d
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/animation.module.css
@@ -0,0 +1,96 @@
+@keyframes slide-down {
+ from {
+ height: 0;
+ }
+
+ to {
+ height: var(--collapsible-panel-height);
+ }
+}
+
+@keyframes slide-up {
+ from {
+ height: var(--collapsible-panel-height);
+ }
+
+ to {
+ height: 0;
+ }
+}
+
+.Panel {
+ overflow: hidden;
+ box-sizing: border-box;
+ width: 100%;
+
+ &[data-open] {
+ animation: slide-down var(--duration) ease-out;
+ }
+
+ &[data-closed] {
+ animation: slide-up var(--duration) ease-in;
+ }
+}
+
+/* the styles below are irrelevant to the features */
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-gap: 5rem;
+}
+
+.wrapper {
+ font-family: system-ui, sans-serif;
+ line-height: 1.4;
+ display: flex;
+ flex-flow: column nowrap;
+ align-items: stretch;
+ gap: 1rem;
+ align-self: flex-start;
+}
+
+.Root {
+ --width: 320px;
+ --duration: 900ms;
+
+ width: var(--width);
+
+ & + .Root {
+ margin-top: 2rem;
+ }
+}
+
+.Trigger {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-200);
+ color: var(--color-gray-900);
+
+ & svg {
+ transform: rotate(-90deg);
+ transition: transform var(--duration) ease-in;
+ }
+
+ &[data-panel-open] svg {
+ transform: rotate(0);
+ transition: transform var(--duration) ease-out;
+ }
+}
+
+.Content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-200);
+ cursor: text;
+
+ & p {
+ overflow-wrap: break-word;
+ }
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/animation.tsx b/docs/src/app/(private)/experiments/collapsible/animation.tsx
new file mode 100644
index 0000000000..8f7a4eab29
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/animation.tsx
@@ -0,0 +1,154 @@
+'use client';
+import * as React from 'react';
+import {
+ useEnhancedEffect,
+ // useTransitionStatus,
+} from '@base-ui-components/react/utils';
+import classes from './animation.module.css';
+
+import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
+import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
+import { useOnMount } from '../../../../../../packages/react/src/utils/useOnMount';
+import { ExpandMoreIcon } from './_icons';
+
+function Collapsible(props: {
+ defaultOpen?: boolean;
+ keepMounted?: boolean;
+ id?: string;
+}) {
+ const { keepMounted = true, defaultOpen = false, id } = props;
+
+ const [open, setOpen] = React.useState(defaultOpen);
+
+ const [visible, setVisible] = React.useState(open);
+
+ const [mounted, setMounted] = React.useState(open);
+
+ const [height, setHeight] = React.useState(0);
+
+ const latestAnimationNameRef = React.useRef(null);
+ const shouldCancelInitialOpenAnimationRef = React.useRef(open);
+
+ const isHidden = !visible;
+
+ const panelRef: React.RefObject = React.useRef(null);
+
+ const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);
+
+ const handleTrigger = useEventCallback(() => {
+ const nextOpen = !open;
+
+ const panel = panelRef.current;
+
+ if (panel) {
+ panel.style.removeProperty('animation-name');
+ }
+
+ if (!keepMounted) {
+ if (!visible && nextOpen) {
+ setVisible(true);
+ }
+ if (!mounted && nextOpen) {
+ setMounted(true);
+ }
+ }
+ setOpen(nextOpen);
+ });
+
+ useEnhancedEffect(() => {
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+
+ latestAnimationNameRef.current =
+ panel.style.animationName || latestAnimationNameRef.current;
+
+ panel.style.animationName = 'none';
+
+ setHeight(panel.scrollHeight);
+
+ if (!shouldCancelInitialOpenAnimationRef.current) {
+ panel.style.removeProperty('animation-name');
+ }
+
+ if (open) {
+ setMounted(true);
+ }
+
+ runOnceAnimationsFinish(() => {
+ setVisible(open);
+ });
+ }, [open, visible, runOnceAnimationsFinish]);
+
+ useOnMount(() => {
+ const frame = requestAnimationFrame(() => {
+ shouldCancelInitialOpenAnimationRef.current = false;
+ });
+ return () => cancelAnimationFrame(frame);
+ });
+
+ return (
+
+
+
+ Trigger {id}
+
+
+ {(keepMounted || (!keepMounted && mounted)) && (
+
+
+
+ He rubbed his eyes, and came close to the picture, and examined it
+ again. There were no signs of any change when he looked into the actual
+ painting, and yet there was no doubt that the whole expression had
+ altered. It was not a mere fancy of his own. The thing was horribly
+ apparent.
+
+
+
+ )}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
keepMounted: true
+
+
+
+
+
———
+
+
+
keepMounted: false
+
+
+
+
———
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/new.tsx b/docs/src/app/(private)/experiments/collapsible/new.tsx
new file mode 100644
index 0000000000..b1ff6e47e4
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/new.tsx
@@ -0,0 +1,465 @@
+'use client';
+import * as React from 'react';
+import {
+ useEnhancedEffect,
+ useTransitionStatus,
+} from '@base-ui-components/react/utils';
+import classes from './animation.module.css';
+import { ExpandMoreIcon } from './_icons';
+
+import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
+import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
+import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef';
+import { useOnMount } from '../../../../../../packages/react/src/utils/useOnMount';
+import { warn } from '../../../../../../packages/react/src/utils/warn';
+
+const STARTING_HOOK = { 'data-starting-style': '' };
+const ENDING_HOOK = { 'data-ending-style': '' };
+
+type AnimationType = 'css-transition' | 'css-animation' | 'none' | null;
+
+function Collapsible(props: {
+ defaultOpen?: boolean;
+ keepMounted?: boolean;
+ id?: string;
+ hiddenUntilFound?: boolean;
+}) {
+ const {
+ keepMounted = true,
+ defaultOpen = false,
+ id,
+ hiddenUntilFound: hiddenUntilFoundProp = true,
+ } = props;
+
+ const [open, setOpen] = React.useState(defaultOpen);
+
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
+
+ const [visible, setVisible] = React.useState(open);
+
+ const styleHooks = React.useMemo(() => {
+ if (transitionStatus === 'starting') {
+ return STARTING_HOOK;
+ }
+ if (transitionStatus === 'ending') {
+ return ENDING_HOOK;
+ }
+ return null;
+ }, [transitionStatus]);
+
+ const [height, setHeight] = React.useState(undefined);
+
+ const shouldCancelInitialOpenTransitionRef = React.useRef(open);
+ const latestAnimationNameRef = React.useRef(null);
+ const shouldCancelInitialOpenAnimationRef = React.useRef(open);
+
+ const isBeforeMatchRef = React.useRef(false);
+
+ const animationTypeRef = React.useRef(null);
+
+ const isHidden = React.useMemo(() => {
+ if (animationTypeRef.current === 'css-animation') {
+ return !visible;
+ }
+
+ if (keepMounted) {
+ return !open;
+ }
+
+ return !open && !mounted;
+ }, [keepMounted, open, mounted, visible]);
+
+ const panelRef: React.RefObject = React.useRef(null);
+
+ /**
+ * When `keepMounted` is `true` this runs once as soon as it exists in the DOM
+ * regardless of initial open state.
+ *
+ * When `keepMounted` is `false` this runs on every mount, typically every
+ * time it opens. If the panel is in the middle of a close transition that is
+ * interrupted and re-opens, this won't run as the panel was not unmounted.
+ */
+ const handlePanelRef = useEventCallback((element: HTMLElement) => {
+ if (!element) {
+ return;
+ }
+ /**
+ * This ref is safe to read in render because it's only ever set once here
+ * during the first render and never again.
+ * https://react.dev/learn/referencing-values-with-refs#best-practices-for-refs
+ */
+ if (animationTypeRef.current == null) {
+ const panelStyles = getComputedStyle(element);
+ if (
+ panelStyles.animationName !== 'none' &&
+ panelStyles.transitionDuration !== '0s'
+ ) {
+ warn('CSS transitions and CSS animations both detected');
+ } else if (
+ panelStyles.animationName === 'none' &&
+ panelStyles.transitionDuration !== '0s'
+ ) {
+ animationTypeRef.current = 'css-transition';
+ } else if (
+ panelStyles.animationName !== 'none' &&
+ panelStyles.transitionDuration === '0s'
+ ) {
+ animationTypeRef.current = 'css-animation';
+ } else {
+ animationTypeRef.current = 'none';
+ }
+ }
+
+ if (animationTypeRef.current !== 'css-transition') {
+ return;
+ }
+
+ /**
+ * Explicitly set `display` to ensure the panel is actually rendered before
+ * measuring anything. `!important` is to needed to override a conflicting
+ * Tailwind v4 default that sets `display: none !important` on `[hidden]`:
+ * https://github.com/tailwindlabs/tailwindcss/blob/cd154a4f471e7a63cc27cad15dada650de89d52b/packages/tailwindcss/preflight.css#L320-L326
+ */
+ element.style.setProperty('display', 'block', 'important'); // TODO: maybe this can be set more conditionally
+
+ if (height === undefined) {
+ /**
+ * When `keepMounted={false}` and the panel is initially closed, the very
+ * first time it opens (not any subsequent opens) `data-starting-style` is
+ * off or missing by a frame so we need to set it manually. Otherwise any
+ * CSS properties expected to transition using [data-starting-style] may
+ * be mis-timed and appear to be complete skipped.
+ */
+ if (!shouldCancelInitialOpenTransitionRef.current && !keepMounted) {
+ element.setAttribute('data-starting-style', '');
+ }
+
+ setHeight(element.scrollHeight);
+ element.style.removeProperty('display');
+
+ if (shouldCancelInitialOpenTransitionRef.current) {
+ element.style.setProperty('transition-duration', '0s');
+ }
+ }
+
+ requestAnimationFrame(() => {
+ shouldCancelInitialOpenTransitionRef.current = false;
+ requestAnimationFrame(() => {
+ /**
+ * This is slightly faster than another RAF and is the earliest
+ * opportunity to remove the temporary `transition-duration: 0s` that
+ * was applied to cancel opening transitions of initially open panels.
+ * https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/
+ */
+ setTimeout(() => {
+ element.style.removeProperty('transition-duration');
+ });
+ });
+ });
+ });
+
+ const mergedRef = useForkRef(panelRef, handlePanelRef);
+
+ const abortControllerRef = React.useRef(null);
+
+ const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);
+
+ const handleTrigger = useEventCallback(() => {
+ const nextOpen = !open;
+
+ const panel = panelRef.current;
+
+ if (animationTypeRef.current === 'css-animation' && panel != null) {
+ panel.style.removeProperty('animation-name');
+ }
+
+ if (!hiddenUntilFoundProp && !keepMounted) {
+ if (animationTypeRef.current === 'css-transition') {
+ if (!mounted && nextOpen) {
+ setMounted(true);
+ }
+ }
+
+ if (animationTypeRef.current === 'css-animation') {
+ if (!visible && nextOpen) {
+ setVisible(true);
+ }
+ if (!mounted && nextOpen) {
+ setMounted(true);
+ }
+ }
+ }
+ setOpen(nextOpen);
+
+ /**
+ * When `keepMounted={false}` and when opening, the element isn't inserted
+ * in the DOM at this point so bail out here and resume in an effect.
+ */
+ if (!panel || animationTypeRef.current !== 'css-transition') {
+ return;
+ }
+
+ panel.style.setProperty('display', 'block', 'important');
+
+ if (nextOpen) {
+ /* opening */
+ if (abortControllerRef.current != null) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ panel.style.removeProperty('display');
+ panel.style.removeProperty('content-visibility');
+ panel.style.setProperty('height', '0px');
+
+ requestAnimationFrame(() => {
+ panel.style.removeProperty('height');
+ setHeight(panel.scrollHeight);
+ });
+ } else {
+ if (hiddenUntilFoundProp) {
+ panel.style.setProperty('content-visibility', 'visible');
+ }
+ /* closing */
+ requestAnimationFrame(() => {
+ setHeight(0);
+ });
+
+ abortControllerRef.current = new AbortController();
+
+ runOnceAnimationsFinish(() => {
+ panel.style.removeProperty('display');
+ panel.style.removeProperty('content-visibility');
+ abortControllerRef.current = null;
+ }, abortControllerRef.current.signal);
+ }
+ });
+
+ /**
+ * This only handles CSS transitions when `keepMounted={false}` as we may not
+ * have access to the panel element in the DOM in the trigger event handler.
+ */
+ useEnhancedEffect(() => {
+ if (animationTypeRef.current !== 'css-transition' || keepMounted) {
+ return;
+ }
+
+ const panel = panelRef.current;
+
+ if (!panel) {
+ return;
+ }
+
+ if (open) {
+ if (abortControllerRef.current != null) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ /* opening */
+ panel.style.height = '0px';
+
+ requestAnimationFrame(() => {
+ /**
+ * When `keepMounted={false}` this is the earliest opportunity to unset
+ * the temporary `display` property that was set in `handlePanelRef`
+ */
+ panel.style.removeProperty('display');
+
+ panel.style.removeProperty('height');
+ setHeight(panel.scrollHeight);
+ });
+ } else {
+ /* closing */
+ requestAnimationFrame(() => {
+ setHeight(0);
+ });
+
+ abortControllerRef.current = new AbortController();
+
+ runOnceAnimationsFinish(() => {
+ panel.style.removeProperty('content-visibility');
+ setMounted(false);
+ abortControllerRef.current = null;
+ }, abortControllerRef.current.signal);
+ }
+ }, [
+ keepMounted,
+ open,
+ mounted,
+ setMounted,
+ runOnceAnimationsFinish,
+ hiddenUntilFoundProp,
+ ]);
+
+ useEnhancedEffect(() => {
+ if (!hiddenUntilFoundProp) {
+ return;
+ }
+
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+
+ if (open && isBeforeMatchRef.current) {
+ panel.style.transitionDuration = '0s';
+ setHeight(panel.scrollHeight);
+ requestAnimationFrame(() => {
+ isBeforeMatchRef.current = false;
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ panel.style.removeProperty('transition-duration');
+ });
+ });
+ });
+ }
+ }, [hiddenUntilFoundProp, open]);
+
+ useEnhancedEffect(() => {
+ if (animationTypeRef.current !== 'css-animation') {
+ return;
+ }
+
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+
+ latestAnimationNameRef.current =
+ panel.style.animationName || latestAnimationNameRef.current;
+
+ panel.style.setProperty('animation-name', 'none');
+
+ setHeight(panel.scrollHeight);
+
+ if (!shouldCancelInitialOpenAnimationRef.current && !isBeforeMatchRef.current) {
+ panel.style.removeProperty('animation-name');
+ }
+
+ if (open) {
+ setMounted(true);
+ setVisible(true);
+ } else {
+ runOnceAnimationsFinish(() => {
+ setMounted(false);
+ setVisible(false);
+ });
+ }
+ }, [open, visible, runOnceAnimationsFinish, setMounted]);
+
+ useOnMount(() => {
+ const frame = requestAnimationFrame(() => {
+ shouldCancelInitialOpenAnimationRef.current = false;
+ });
+ return () => cancelAnimationFrame(frame);
+ });
+
+ useEnhancedEffect(() => {
+ const panel = panelRef.current;
+
+ if (panel && hiddenUntilFoundProp && isHidden) {
+ /**
+ * React only supports a boolean for the `hidden` attribute and forces
+ * legit string values to booleans so we have to force it back in the DOM
+ * when necessary: https://github.com/facebook/react/issues/24740
+ */
+ panel.setAttribute('hidden', 'until-found');
+ /**
+ * Set data-starting-style here to persist the closed styles, this is to
+ * prevent transitions from starting when the `hidden` attribute changes
+ * to `'until-found'` as they could have different `display` properties:
+ * https://github.com/tailwindlabs/tailwindcss/pull/14625
+ */
+ if (animationTypeRef.current === 'css-transition') {
+ panel.setAttribute('data-starting-style', '');
+ }
+ }
+ }, [hiddenUntilFoundProp, isHidden]);
+
+ React.useEffect(
+ function registerBeforeMatchListener() {
+ const panel = panelRef.current;
+ if (!panel) {
+ return undefined;
+ }
+
+ function handleBeforeMatch() {
+ isBeforeMatchRef.current = true;
+ setOpen(true);
+ }
+
+ panel.addEventListener('beforematch', handleBeforeMatch);
+
+ return () => {
+ panel.removeEventListener('beforematch', handleBeforeMatch);
+ };
+ },
+ [setOpen],
+ );
+
+ return (
+
+
+
+ Trigger {id}
+
+
+ {(keepMounted || hiddenUntilFoundProp || (!keepMounted && mounted)) && (
+
+
+
+ He rubbed his eyes, and came close to the picture, and examined it
+ again. There were no signs of any change when he looked into the actual
+ painting, and yet there was no doubt that the whole expression had
+ altered. It was not a mere fancy of his own. The thing was horribly
+ apparent.
+
+
+
+ )}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
keepMounted: true
+
+
+
+
+
———
+
+
+
keepMounted: false
+
+
+
+
———
+
+
+ );
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/transition.module.css b/docs/src/app/(private)/experiments/collapsible/transition.module.css
new file mode 100644
index 0000000000..1cc18c1f44
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/transition.module.css
@@ -0,0 +1,77 @@
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-gap: 5rem;
+}
+
+.wrapper {
+ font-family: system-ui, sans-serif;
+ line-height: 1.4;
+ display: flex;
+ flex-flow: column nowrap;
+ align-items: stretch;
+ gap: 1rem;
+ align-self: flex-start;
+}
+
+.Root {
+ --width: 320px;
+ --duration: 1000ms;
+
+ width: var(--width);
+
+ & + .Root {
+ margin-top: 2rem;
+ }
+}
+
+.Trigger {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-200);
+ color: var(--color-gray-900);
+
+ & svg {
+ transform: rotate(-90deg);
+ transition: transform var(--duration) ease-in;
+ }
+
+ &[data-panel-open] svg {
+ transform: rotate(0);
+ transition: transform var(--duration) ease-out;
+ }
+}
+
+.Panel {
+ overflow: hidden;
+ box-sizing: border-box;
+ width: 100%;
+
+ height: var(--collapsible-panel-height);
+
+ transition: all var(--duration) ease-out;
+
+ &[data-starting-style],
+ &[data-ending-style] {
+ height: 0;
+ opacity: 0;
+ }
+}
+
+.Content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-top: 0.25rem;
+ padding: 0.5rem;
+ border-radius: 0.25rem;
+ background-color: var(--color-gray-200);
+ cursor: text;
+
+ & p {
+ overflow-wrap: break-word;
+ }
+}
diff --git a/docs/src/app/(private)/experiments/collapsible/transition.tsx b/docs/src/app/(private)/experiments/collapsible/transition.tsx
new file mode 100644
index 0000000000..cae940d135
--- /dev/null
+++ b/docs/src/app/(private)/experiments/collapsible/transition.tsx
@@ -0,0 +1,301 @@
+'use client';
+import * as React from 'react';
+import {
+ useEnhancedEffect,
+ useTransitionStatus,
+} from '@base-ui-components/react/utils';
+import classes from './transition.module.css';
+
+import { useAnimationsFinished } from '../../../../../../packages/react/src/utils/useAnimationsFinished';
+import { useEventCallback } from '../../../../../../packages/react/src/utils/useEventCallback';
+import { useForkRef } from '../../../../../../packages/react/src/utils/useForkRef';
+import { warn } from '../../../../../../packages/react/src/utils/warn';
+import { ExpandMoreIcon } from './_icons';
+
+const STARTING_HOOK = { 'data-starting-style': '' };
+const ENDING_HOOK = { 'data-ending-style': '' };
+
+type AnimationType = 'css-transition' | 'css-animation' | 'none' | null;
+
+function Collapsible(props: {
+ defaultOpen?: boolean;
+ keepMounted?: boolean;
+ id?: string;
+}) {
+ const { keepMounted = true, defaultOpen = false, id } = props;
+
+ const [open, setOpen] = React.useState(defaultOpen);
+
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
+
+ const styleHooks = React.useMemo(() => {
+ if (transitionStatus === 'starting') {
+ return STARTING_HOOK;
+ }
+ if (transitionStatus === 'ending') {
+ return ENDING_HOOK;
+ }
+ return null;
+ }, [transitionStatus]);
+
+ const [height, setHeight] = React.useState(undefined);
+
+ const shouldCancelInitialOpenTransitionRef = React.useRef(open);
+
+ const isHidden = React.useMemo(() => {
+ if (keepMounted) {
+ return !open;
+ }
+
+ return !open && !mounted;
+ }, [keepMounted, open, mounted]);
+
+ const animationTypeRef = React.useRef(null);
+ const panelRef: React.RefObject = React.useRef(null);
+ /**
+ * When `keepMounted` is `true` this runs once as soon as it exists in the DOM
+ * regardless of initial open state.
+ *
+ * When `keepMounted` is `false` this runs on every mount, typically every
+ * time it opens. If the panel is in the middle of a close transition that is
+ * interrupted and re-opens, this won't run as the panel was not unmounted.
+ */
+ const handlePanelRef = useEventCallback((element: HTMLElement) => {
+ if (!element) {
+ return;
+ }
+ if (animationTypeRef.current == null) {
+ const panelStyles = getComputedStyle(element);
+ if (
+ panelStyles.animationName !== 'none' &&
+ panelStyles.transitionDelay !== '0s'
+ ) {
+ warn('CSS transitions and CSS animations both detected');
+ } else if (
+ panelStyles.animationName === 'none' &&
+ panelStyles.transitionDuration !== '0s'
+ ) {
+ animationTypeRef.current = 'css-transition';
+ } else if (
+ panelStyles.animationName !== 'none' &&
+ panelStyles.transitionDuration === '0s'
+ ) {
+ animationTypeRef.current = 'css-animation';
+ } else {
+ animationTypeRef.current = 'none';
+ }
+ }
+ // console.log('animationType', animationTypeRef.current);
+
+ /**
+ * Explicitly set `display` to ensure the panel is actually rendered before
+ * measuring anything. `!important` is to needed to override a conflicting
+ * Tailwind v4 default that sets `display: none !important` on `[hidden]`:
+ * https://github.com/tailwindlabs/tailwindcss/blob/cd154a4f471e7a63cc27cad15dada650de89d52b/packages/tailwindcss/preflight.css#L320-L326
+ */
+ element.style.setProperty('display', 'block', 'important'); // TODO: maybe this can be set more conditionally
+
+ if (height === undefined) {
+ /**
+ * When `keepMounted={false}` and the panel is initially closed, the very
+ * first time it opens (not any subsequent opens) `data-starting-style` is
+ * off or missing by a frame so we need to set it manually. Otherwise any
+ * CSS properties expected to transition using [data-starting-style] may
+ * be mis-timed and appear to be complete skipped.
+ */
+ if (!shouldCancelInitialOpenTransitionRef.current && !keepMounted) {
+ element.setAttribute('data-starting-style', '');
+ }
+
+ setHeight(element.scrollHeight);
+ element.style.removeProperty('display');
+
+ if (shouldCancelInitialOpenTransitionRef.current) {
+ element.style.transitionDuration = '0s';
+ }
+ }
+
+ requestAnimationFrame(() => {
+ shouldCancelInitialOpenTransitionRef.current = false;
+ requestAnimationFrame(() => {
+ /**
+ * This is slightly faster than another RAF and is the earliest
+ * opportunity to remove the temporary `transition-duration: 0s` that
+ * was applied to cancel opening transitions of initially open panels.
+ * https://nolanlawson.com/2018/09/25/accurately-measuring-layout-on-the-web/
+ */
+ setTimeout(() => {
+ element.style.removeProperty('transition-duration');
+ });
+ });
+ });
+ });
+
+ const mergedRef = useForkRef(panelRef, handlePanelRef);
+
+ const abortControllerRef = React.useRef(null);
+
+ const runOnceAnimationsFinish = useAnimationsFinished(panelRef, false);
+
+ const handleTrigger = useEventCallback(() => {
+ const nextOpen = !open;
+
+ if (!keepMounted) {
+ if (!mounted && nextOpen) {
+ setMounted(true);
+ }
+ }
+ setOpen(nextOpen);
+
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+
+ panel.style.setProperty('display', 'block', 'important');
+
+ if (nextOpen) {
+ if (abortControllerRef.current != null) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ panel.style.removeProperty('display');
+
+ /* opening */
+ panel.style.height = '0px';
+
+ requestAnimationFrame(() => {
+ panel.style.removeProperty('height');
+ setHeight(panel.scrollHeight);
+ });
+ } else {
+ /* closing */
+ requestAnimationFrame(() => {
+ setHeight(0);
+ });
+
+ abortControllerRef.current = new AbortController();
+
+ runOnceAnimationsFinish(() => {
+ // TODO: !important may be needed
+ panel.style.setProperty('display', 'none');
+ abortControllerRef.current = null;
+ }, abortControllerRef.current.signal);
+ }
+ });
+
+ /**
+ * This only handles `keepMounted={false}` as the state changes can't be done
+ * in the event handler
+ */
+ useEnhancedEffect(() => {
+ if (keepMounted) {
+ return;
+ }
+
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+
+ if (open) {
+ if (abortControllerRef.current != null) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+
+ /* opening */
+ panel.style.height = '0px';
+
+ requestAnimationFrame(() => {
+ /**
+ * When `keepMounted={false}` this is the earliest opportunity to unset
+ * the temporary `display` property that was set in `handlePanelRef`
+ */
+ panel.style.removeProperty('display');
+
+ panel.style.removeProperty('height');
+
+ setHeight(panel.scrollHeight);
+ });
+ } else {
+ /* closing */
+ requestAnimationFrame(() => {
+ setHeight(0);
+ });
+
+ abortControllerRef.current = new AbortController();
+
+ runOnceAnimationsFinish(() => {
+ setMounted(false);
+ abortControllerRef.current = null;
+ }, abortControllerRef.current.signal);
+ }
+ }, [keepMounted, open, mounted, setMounted, runOnceAnimationsFinish]);
+
+ return (
+
+
+
+ Trigger {id}
+
+
+ {(keepMounted || (!keepMounted && mounted)) && (
+
+
+
+ He rubbed his eyes, and came close to the picture, and examined it
+ again. There were no signs of any change when he looked into the actual
+ painting, and yet there was no doubt that the whole expression had
+ altered. It was not a mere fancy of his own. The thing was horribly
+ apparent.
+
+
+
+ )}
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
keepMounted: true
+
+
+
+
+
———
+
+
+
keepMounted: false
+
+
+
+
———
+
+
+ );
+}
diff --git a/packages/react/src/collapsible/panel/useCollapsiblePanel.ts b/packages/react/src/collapsible/panel/useCollapsiblePanel.ts
index ee1072fa95..24954fc0c2 100644
--- a/packages/react/src/collapsible/panel/useCollapsiblePanel.ts
+++ b/packages/react/src/collapsible/panel/useCollapsiblePanel.ts
@@ -283,7 +283,7 @@ export function useCollapsiblePanel(
}, [hiddenUntilFound, isOpen]);
const hidden = hiddenUntilFound ? 'until-found' : 'hidden';
-
+ console.log('isOpen', isOpen);
const getRootProps: useCollapsiblePanel.ReturnValue['getRootProps'] = React.useCallback(
(externalProps = {}) =>
mergeProps<'button'>(
diff --git a/packages/react/src/collapsible/root/useCollapsibleRoot.ts b/packages/react/src/collapsible/root/useCollapsibleRoot.ts
index 6fe3a73ad7..91e55ef24c 100644
--- a/packages/react/src/collapsible/root/useCollapsibleRoot.ts
+++ b/packages/react/src/collapsible/root/useCollapsibleRoot.ts
@@ -17,7 +17,7 @@ export function useCollapsibleRoot(
state: 'open',
});
- const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, true);
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
const [panelId, setPanelId] = React.useState(useBaseUiId());
diff --git a/packages/react/src/utils/useAnimationsFinished.ts b/packages/react/src/utils/useAnimationsFinished.ts
index b76224a76c..81d74a9ab2 100644
--- a/packages/react/src/utils/useAnimationsFinished.ts
+++ b/packages/react/src/utils/useAnimationsFinished.ts
@@ -23,38 +23,54 @@ export function useAnimationsFinished(
React.useEffect(() => cancelTasks, [cancelTasks]);
- return useEventCallback((fnToExecute: () => void) => {
- cancelTasks();
-
- const element = ref.current;
-
- if (!element) {
- return;
- }
-
- if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
- fnToExecute();
- } else {
- frameRef.current = requestAnimationFrame(() => {
- function exec() {
- if (!element) {
- return;
+ return useEventCallback(
+ (
+ /**
+ * A function to execute once all animations have finished.
+ */
+ fnToExecute: () => void,
+ /**
+ * An optional [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) that
+ * can be used to abort `fnToExecute` before all the animations have finished.
+ * @default null
+ */
+ signal: AbortSignal | null = null,
+ ) => {
+ cancelTasks();
+
+ const element = ref.current;
+
+ if (!element) {
+ return;
+ }
+
+ if (typeof element.getAnimations !== 'function' || globalThis.BASE_UI_ANIMATIONS_DISABLED) {
+ fnToExecute();
+ } else {
+ frameRef.current = requestAnimationFrame(() => {
+ function exec() {
+ if (!element) {
+ return;
+ }
+
+ Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
+ if (signal != null && signal.aborted) {
+ return;
+ }
+ // Synchronously flush the unmounting of the component so that the browser doesn't
+ // paint: https://github.com/mui/base-ui/issues/979
+ ReactDOM.flushSync(fnToExecute);
+ });
}
- Promise.allSettled(element.getAnimations().map((anim) => anim.finished)).then(() => {
- // Synchronously flush the unmounting of the component so that the browser doesn't
- // paint: https://github.com/mui/base-ui/issues/979
- ReactDOM.flushSync(fnToExecute);
- });
- }
-
- // `open: true` animations need to wait for the next tick to be detected
- if (waitForNextTick) {
- timeoutRef.current = window.setTimeout(exec);
- } else {
- exec();
- }
- });
- }
- });
+ // `open: true` animations need to wait for the next tick to be detected
+ if (waitForNextTick) {
+ timeoutRef.current = window.setTimeout(exec);
+ } else {
+ exec();
+ }
+ });
+ }
+ },
+ );
}
diff --git a/packages/react/src/utils/useTransitionStatus.ts b/packages/react/src/utils/useTransitionStatus.ts
index d33ea23c5d..539338fa73 100644
--- a/packages/react/src/utils/useTransitionStatus.ts
+++ b/packages/react/src/utils/useTransitionStatus.ts
@@ -2,25 +2,20 @@
import * as React from 'react';
import { useEnhancedEffect } from './useEnhancedEffect';
-export type TransitionStatus = 'starting' | 'ending' | undefined;
+export type TransitionStatus = 'starting' | 'ending' | 'idle' | undefined;
/**
* Provides a status string for CSS animations.
* @param open - a boolean that determines if the element is open.
- * @param delayStartingStatus - a boolean that set the `starting` status one
- * tick later. Example use-case: collapsible needs an extra frame in order
- * to measure the panel contents.
* @ignore - internal hook.
*/
-export function useTransitionStatus(open: boolean, delayStartingStatus = false) {
+export function useTransitionStatus(open: boolean) {
const [transitionStatus, setTransitionStatus] = React.useState();
const [mounted, setMounted] = React.useState(open);
if (open && !mounted) {
setMounted(true);
- if (transitionStatus !== 'starting' && !delayStartingStatus) {
- setTransitionStatus('starting');
- }
+ setTransitionStatus('starting');
}
if (!open && mounted && transitionStatus !== 'ending') {
@@ -35,19 +30,18 @@ export function useTransitionStatus(open: boolean, delayStartingStatus = false)
if (!open) {
return undefined;
}
-
- if (delayStartingStatus) {
+ if (open && mounted && transitionStatus !== 'idle') {
setTransitionStatus('starting');
}
const frame = requestAnimationFrame(() => {
- setTransitionStatus(undefined);
+ setTransitionStatus('idle');
});
return () => {
cancelAnimationFrame(frame);
};
- }, [open, delayStartingStatus]);
+ }, [open, mounted, setTransitionStatus, transitionStatus]);
return React.useMemo(
() => ({