Repository: tailwindlabs/headlessui
Pull request #3273 (2024-06-06 11:19:55)

Introduce CSS based transitions
This PR adds a new way of dealing with transitions. Instead of using the `<Transition />` component and using the `enter`, `enterFrom`, `enterTo`, `leave`, `leaveFrom` and `leaveTo` props, we can now define all of that in CSS.

Most components that are rendered conditionally based on the state of the component:

- `ComboboxOptions`
- `DisclosurePanel`
- `ListboxOptions`
- `MenuItems`
- `PopoverPanel`

Will now accept a `transition` prop. The moment that is enabled then we will expose `data-from`, `data-enter`, `data-exit` and `data-transition` data attributes at the correct time. You can use these data attributes in your CSS or Tailwind Classes to style these components.

The lifecycle of the data attributes look like this:

```
┌─────┐              │       ┌────────────┐
│From │              │       │From        │
└─────┘              │       └────────────┘
┌─────┐┌─────┐┌─────┐│┌─────┐┌─────┐┌─────┐
│Frame││Frame││Frame│││Frame││Frame││Frame│
└─────┘└─────┘└─────┘│└─────┘└─────┘└─────┘
┌───────────────────┐│┌───────────────────┐
│Enter              │││Exit               │
└───────────────────┘│└───────────────────┘
┌───────────────────┐│┌───────────────────┐
│Transition         │││Transition         │
├───────────────────┘│└───────────────────┘
│
└─ Applied when `Enter` or `Exit` is applied.
```

- The `data-transition` attribute is added if an `enter` or `exit` transition is happening.
- The `data-enter` attribute is added during the full `enter` transition from start to finish.
- The `data-exit` attribute is added during the full `exit` transition from start to finish.

The `data-from` attribute is applied at different times depending on the `enter` or `exit` transition. The styles applied using `data-from` are for describing the `from` state or the `initial` state.

- When there is an `enter` transition we will add the `data-from` attribute briefly to make sure that state is present in the DOM. Then, we take it away and let the browser transition the DOM node to it's default state (e.g.: opacity-100).
- When there is an `exit` transition we will add the `data-from` attribute (and keep it there) _after_ the transition starts because then we can go from the current state to the `data-from` state until finished. Once finished, we will unmount the DOM node.

For example, let's say you want to fade in the `MenuItems`, previously you would use the `<Transition />` component for that:

```tsx
<Transition
  enter="transition duration-200 ease-out"
  enterFrom="opacity-0"
  enterTo="opacity-100"
  leave="transition duration-200 ease-out"
  leaveFrom="opacity-100"
  leaveTo="opacity-0"
>
  <MenuItems />
</Transition>
```

There are a few things you might notice:

1. The `enterFrom` and `leaveTo` are the same
2. The `enterTo` and `leaveFrom` are the same
3. The `enterTo` and `leaveFrom` are the default state of an element
4. The `enter` and `leave` are the same

To use the data attribute approach, you can represent this like:

```tsx
<MenuItems transition className="transition duration-200 ease-out data-[from]:opacity-0" />
```

The `transition duration-200 ease-out` were the same, so we can always define them.

The `data-from` data attribute represents the initial state, so we can define it once and it will be applied at the correct time.

If the `duration` or `easing` function is different depending on whether we are
`entering` or `exiting`, then we can do that explicitly as well:

Before:

```tsx
<Transition
  enter="transition duration-200 ease-out"
  enterFrom="opacity-0"
  enterTo="opacity-100"
  leave="transition duration-75 ease-in"
  leaveFrom="opacity-100"
  leaveTo="opacity-0"
>
  <MenuItems />
</Transition>
```

After:

```tsx
<MenuItems
  transition
  className="transition data-[from]:opacity-0 data-[enter]:duration-200 data-[exit]:duration-75 data-[enter]:ease-out data-[exit]:ease-in"
/>
```

If the `enter` and `leave` states are different, e.g.: when entering from the left, and exiting to the right. Then we can combine the data attributes:

```tsx
<Transition
  enter="transition duration-200 ease-out"
  enterFrom="-translate-x-full"
  enterTo="translate-x-0"
  leave="transition duration-200 ease-out"
  leaveFrom="translate-x-0"
  leaveTo="translate-x-full"
>
  <MenuItems />
</Transition>
```

After:

```tsx
<MenuItems
  transition
  className={clsx([
    // Defaults
    'transition duration-200 ease-out',

    // Enter specific
    'data-[enter]:data-[from]:-translate-x-full',

    // Exit specific
    'data-[exit]:data-[from]:translate-x-full',
  ])}
/>
```

Notice that we didn't specify the `translate-x-0` case because that's again the default state of an element.

---

The existing `<Transition>` and `<TransitionChild>` will also expose these data attributes to the underlying DOM node so that you can use them in your CSS for components or elements that don't have a `transition` prop.


vercel[bot]: [vc]: #2gDpjt1QmD1S+eOdGQV03McjwLAYubtxxkjcRLkZiIM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktcmVhY3QtZ2l0LWZlYXQtY3NzLWJhc2VkLXRyYW5zaXRpb25zLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIn0sImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90YWlsd2luZGxhYnMvaGVhZGxlc3N1aS1yZWFjdC9EYmVwNGV3c2Z4RkFlamZRUEN2MTNYMlBHQ0VFIiwicHJldmlld1VybCI6ImhlYWRsZXNzdWktcmVhY3QtZ2l0LWZlYXQtY3NzLWJhc2VkLXRyYW5zaXRpb25zLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoiaGVhZGxlc3N1aS12dWUiLCJyb290RGlyZWN0b3J5IjoicGxheWdyb3VuZHMvdnVlIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktdnVlLWdpdC1mZWF0LWNzcy1iYXNlZC10cmFuc2l0aW9ucy10YWlsd2luZGxhYnMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlL0ZuNXVjUERYand0U0pDUGpqNGdHd1lob0FWOXciLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZlYXQtY3NzLWJhc2VkLXRyYW5zaXRpb25zLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **headlessui-react** | ✅ Ready ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/Dbep4ewsfxFAejfQPCv13X2PGCEE)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-feat-css-based-transitions-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-feat-css-based-transitions-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 6, 2024 11:19am |
| **headlessui-vue** | ✅ Ready ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/Fn5ucPDXjwtSJCPjj4gGwYhoAV9w)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-feat-css-based-transitions-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-feat-css-based-transitions-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 6, 2024 11:19am |




Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 4322c31..e912d70 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 
 - Add ability to render multiple `<Dialog />` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
+- Add CSS based transitions using `data-*` attributes ([#3273](https://github.com/tailwindlabs/headlessui/pull/3273))
 
 ### Fixed
 
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 40a34f6..89008c7 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -41,6 +41,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
 import { useScrollLock } from '../../hooks/use-scroll-lock'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
 import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
+import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
 import { useTreeWalker } from '../../hooks/use-tree-walker'
 import { useWatch } from '../../hooks/use-watch'
 import { useDisabled } from '../../internal/disabled'
@@ -446,6 +447,7 @@ type _Actions = ReturnType<typeof useActions>
 let VirtualContext = createContext<Virtualizer<any, any> | null>(null)
 
 function VirtualProvider(props: {
+  slot: OptionsRenderPropArg
   children: (data: { option: unknown; open: boolean }) => React.ReactElement
 }) {
   let data = useData('VirtualProvider')
@@ -523,8 +525,8 @@ function VirtualProvider(props: {
             <Fragment key={item.key}>
               {React.cloneElement(
                 props.children?.({
+                  ...props.slot,
                   option: options[item.index],
-                  open: data.comboboxState === ComboboxState.Open,
                 }),
                 {
                   key: `${baseKey}-${item.key}`,
@@ -1561,7 +1563,7 @@ let DEFAULT_OPTIONS_TAG = 'div' as const
 type OptionsRenderPropArg = {
   open: boolean
   option: unknown
-}
+} & TransitionData
 type OptionsPropsWeControl = 'aria-labelledby' | 'aria-multiselectable' | 'role' | 'tabIndex'
 
 let OptionsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -1575,6 +1577,7 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
     anchor?: AnchorProps
     portal?: boolean
     modal?: boolean
+    transition?: boolean
   }
 >
 
@@ -1589,6 +1592,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     anchor: rawAnchor,
     portal = false,
     modal = true,
+    transition = false,
     ...theirProps
   } = props
   let data = useData('Combobox.Options')
@@ -1606,13 +1610,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   let ownerDocument = useOwnerDocument(data.optionsRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return data.comboboxState === ComboboxState.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    data.optionsRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : data.comboboxState === ComboboxState.Open
+  )
 
   // Ensure we close the combobox as soon as the input becomes hidden
   useOnDisappear(visible, data.inputRef, actions.closeCombobox)
@@ -1660,8 +1664,9 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     return {
       open: data.comboboxState === ComboboxState.Open,
       option: undefined,
+      ...transitionData,
     } satisfies OptionsRenderPropArg
-  }, [data])
+  }, [data.comboboxState, transitionData])
 
   // When the user scrolls **using the mouse** (so scroll event isn't appropriate)
   // we want to make sure that the current activation trigger is set to pointer.
@@ -1706,7 +1711,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   if (data.virtual && visible) {
     Object.assign(theirProps, {
       // @ts-expect-error The `children` prop now is a callback function that receives `{ option }`.
-      children: <VirtualProvider>{theirProps.children}</VirtualProvider>,
+      children: <VirtualProvider slot={slot}>{theirProps.children}</VirtualProvider>,
     })
   }
 
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
index c27d8f5..fcf624f 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -24,6 +24,7 @@ import { useEvent } from '../../hooks/use-event'
 import { useId } from '../../hooks/use-id'
 import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
 import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
+import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
 import { CloseProvider } from '../../internal/close-provider'
 import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
 import type { Props } from '../../types'
@@ -419,7 +420,7 @@ let DEFAULT_PANEL_TAG = 'div' as const
 type PanelRenderPropArg = {
   open: boolean
   close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
-}
+} & TransitionData
 type DisclosurePanelPropsWeControl = never
 
 let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -428,7 +429,7 @@ export type DisclosurePanelProps<TTag extends ElementType = typeof DEFAULT_PANEL
   TTag,
   PanelRenderPropArg,
   DisclosurePanelPropsWeControl,
-  PropsForFeatures<typeof PanelRenderFeatures>
+  { transition?: boolean } & PropsForFeatures<typeof PanelRenderFeatures>
 >
 
 function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
@@ -436,7 +437,11 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
   ref: Ref<HTMLElement>
 ) {
   let internalId = useId()
-  let { id = `headlessui-disclosure-panel-${internalId}`, ...theirProps } = props
+  let {
+    id = `headlessui-disclosure-panel-${internalId}`,
+    transition = false,
+    ...theirProps
+  } = props
   let [state, dispatch] = useDisclosureContext('Disclosure.Panel')
   let { close } = useDisclosureAPIContext('Disclosure.Panel')
   let mergeRefs = useMergeRefsFn()
@@ -453,20 +458,21 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
   }, [id, dispatch])
 
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return state.disclosureState === DisclosureStates.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    state.panelRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : state.disclosureState === DisclosureStates.Open
+  )
 
   let slot = useMemo(() => {
     return {
       open: state.disclosureState === DisclosureStates.Open,
       close,
+      ...transitionData,
     } satisfies PanelRenderPropArg
-  }, [state, close])
+  }, [state.disclosureState, close, transitionData])
 
   let ourProps = {
     ref: panelRef,
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 5781ebf..e0c45da 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -42,6 +42,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
 import { useTextValue } from '../../hooks/use-text-value'
 import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
+import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
 import { useDisabled } from '../../internal/disabled'
 import {
   FloatingProvider,
@@ -863,7 +864,7 @@ let SelectedOptionContext = createContext(false)
 let DEFAULT_OPTIONS_TAG = 'div' as const
 type OptionsRenderPropArg = {
   open: boolean
-}
+} & TransitionData
 type OptionsPropsWeControl =
   | 'aria-activedescendant'
   | 'aria-labelledby'
@@ -882,6 +883,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
     anchor?: AnchorPropsWithSelection
     portal?: boolean
     modal?: boolean
+    transition?: boolean
   } & PropsForFeatures<typeof OptionsRenderFeatures>
 >
 
@@ -895,6 +897,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
     anchor: rawAnchor,
     portal = false,
     modal = true,
+    transition = false,
     ...theirProps
   } = props
   let anchor = useResolvedAnchor(rawAnchor)
@@ -910,13 +913,13 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   let ownerDocument = useOwnerDocument(data.optionsRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return data.listboxState === ListboxStates.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    data.optionsRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : data.listboxState === ListboxStates.Open
+  )
 
   // Ensure we close the listbox as soon as the button becomes hidden
   useOnDisappear(visible, data.buttonRef, actions.closeListbox)
@@ -1073,10 +1076,12 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   })
 
   let labelledby = useComputed(() => data.buttonRef.current?.id, [data.buttonRef.current])
-  let slot = useMemo(
-    () => ({ open: data.listboxState === ListboxStates.Open }) satisfies OptionsRenderPropArg,
-    [data]
-  )
+  let slot = useMemo(() => {
+    return {
+      open: data.listboxState === ListboxStates.Open,
+      ...transitionData,
+    } satisfies OptionsRenderPropArg
+  }, [data.listboxState, transitionData])
 
   let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
     id,
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index eb34c94..3619aac 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -37,6 +37,7 @@ import { useScrollLock } from '../../hooks/use-scroll-lock'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
 import { useTextValue } from '../../hooks/use-text-value'
 import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
+import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
 import { useTreeWalker } from '../../hooks/use-tree-walker'
 import {
   FloatingProvider,
@@ -564,7 +565,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
 let DEFAULT_ITEMS_TAG = 'div' as const
 type ItemsRenderPropArg = {
   open: boolean
-}
+} & TransitionData
 type ItemsPropsWeControl = 'aria-activedescendant' | 'aria-labelledby' | 'role' | 'tabIndex'
 
 let ItemsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -577,6 +578,7 @@ export type MenuItemsProps<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>
     anchor?: AnchorProps
     portal?: boolean
     modal?: boolean
+    transition?: boolean
 
     // ItemsRenderFeatures
     static?: boolean
@@ -594,6 +596,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
     anchor: rawAnchor,
     portal = false,
     modal = true,
+    transition = false,
     ...theirProps
   } = props
   let anchor = useResolvedAnchor(rawAnchor)
@@ -608,16 +611,14 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
     portal = true
   }
 
-  let searchDisposables = useDisposables()
-
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return state.menuState === MenuStates.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    state.itemsRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : state.menuState === MenuStates.Open
+  )
 
   // Ensure we close the menu as soon as the button becomes hidden
   useOnDisappear(visible, state.buttonRef, () => {
@@ -671,6 +672,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
     },
   })
 
+  let searchDisposables = useDisposables()
   let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
     searchDisposables.dispose()
 
@@ -755,10 +757,12 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
     }
   })
 
-  let slot = useMemo(
-    () => ({ open: state.menuState === MenuStates.Open }) satisfies ItemsRenderPropArg,
-    [state]
-  )
+  let slot = useMemo(() => {
+    return {
+      open: state.menuState === MenuStates.Open,
+      ...transitionData,
+    } satisfies ItemsRenderPropArg
+  }, [state, transitionData])
 
   let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
     'aria-activedescendant':
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index 0574097..cc8cf85 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -36,6 +36,7 @@ import { useMainTreeNode, useRootContainers } from '../../hooks/use-root-contain
 import { useScrollLock } from '../../hooks/use-scroll-lock'
 import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
 import { Direction as TabDirection, useTabDirection } from '../../hooks/use-tab-direction'
+import { useTransitionData, type TransitionData } from '../../hooks/use-transition-data'
 import { CloseProvider } from '../../internal/close-provider'
 import {
   FloatingProvider,
@@ -726,7 +727,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
 let DEFAULT_OVERLAY_TAG = 'div' as const
 type OverlayRenderPropArg = {
   open: boolean
-}
+} & TransitionData
 type OverlayPropsWeControl = 'aria-hidden'
 
 let OverlayRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
@@ -735,7 +736,7 @@ export type PopoverOverlayProps<TTag extends ElementType = typeof DEFAULT_OVERLA
   TTag,
   OverlayRenderPropArg,
   OverlayPropsWeControl,
-  PropsForFeatures<typeof OverlayRenderFeatures>
+  { transition?: boolean } & PropsForFeatures<typeof OverlayRenderFeatures>
 >
 
 function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
@@ -743,28 +744,31 @@ function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
   ref: Ref<HTMLElement>
 ) {
   let internalId = useId()
-  let { id = `headlessui-popover-overlay-${internalId}`, ...theirProps } = props
+  let { id = `headlessui-popover-overlay-${internalId}`, transition = false, ...theirProps } = props
   let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay')
-  let overlayRef = useSyncRefs(ref)
+  let internalOverlayRef = useRef<HTMLElement | null>(null)
+  let overlayRef = useSyncRefs(ref, internalOverlayRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return popoverState === PopoverStates.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    internalOverlayRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : popoverState === PopoverStates.Open
+  )
 
   let handleClick = useEvent((event: ReactMouseEvent) => {
     if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
     dispatch({ type: ActionTypes.ClosePopover })
   })
 
-  let slot = useMemo(
-    () => ({ open: popoverState === PopoverStates.Open }) satisfies OverlayRenderPropArg,
-    [popoverState]
-  )
+  let slot = useMemo(() => {
+    return {
+      open: popoverState === PopoverStates.Open,
+      ...transitionData,
+    } satisfies OverlayRenderPropArg
+  }, [popoverState, transitionData])
 
   let ourProps = {
     ref: overlayRef,
@@ -790,7 +794,7 @@ let DEFAULT_PANEL_TAG = 'div' as const
 type PanelRenderPropArg = {
   open: boolean
   close: (focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => void
-}
+} & TransitionData
 
 let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
 
@@ -805,6 +809,7 @@ export type PopoverPanelProps<TTag extends ElementType = typeof DEFAULT_PANEL_TA
     anchor?: AnchorProps
     portal?: boolean
     modal?: boolean
+    transition?: boolean
 
     // ItemsRenderFeatures
     static?: boolean
@@ -823,6 +828,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
     anchor: rawAnchor,
     portal = false,
     modal = false,
+    transition = false,
     ...theirProps
   } = props
 
@@ -856,13 +862,13 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
   }, [id, dispatch])
 
   let usesOpenClosedState = useOpenClosed()
-  let visible = (() => {
-    if (usesOpenClosedState !== null) {
-      return (usesOpenClosedState & State.Open) === State.Open
-    }
-
-    return state.popoverState === PopoverStates.Open
-  })()
+  let [visible, transitionData] = useTransitionData(
+    transition,
+    internalPanelRef,
+    usesOpenClosedState !== null
+      ? (usesOpenClosedState & State.Open) === State.Open
+      : state.popoverState === PopoverStates.Open
+  )
 
   // Ensure we close the popover as soon as the button becomes hidden
   useOnDisappear(visible, state.button, () => {
@@ -914,10 +920,13 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
     focusIn(internalPanelRef.current, Focus.First)
   }, [state.__demoMode, focus, internalPanelRef, state.popoverState])
 
-  let slot = useMemo(
-    () => ({ open: state.popoverState === PopoverStates.Open, close }) satisfies PanelRenderPropArg,
-    [state, close]
-  )
+  let slot = useMemo(() => {
+    return {
+      open: state.popoverState === PopoverStates.Open,
+      close,
+      ...transitionData,
+    } satisfies PanelRenderPropArg
+  }, [state.popoverState, close, transitionData])
 
   let ourProps: Record<string, any> = mergeProps(anchor ? getFloatingPanelProps() : {}, {
     ref: panelRef,
diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx
index d2ddadf..2ab9916 100644
--- a/packages/@headlessui-react/src/components/transition/transition.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.tsx
@@ -21,6 +21,7 @@ import { useOnDisappear } from '../../hooks/use-on-disappear'
 import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
 import { useSyncRefs } from '../../hooks/use-sync-refs'
 import { useTransition } from '../../hooks/use-transition'
+import { useTransitionData } from '../../hooks/use-transition-data'
 import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
 import type { Props, ReactTag } from '../../types'
 import { classNames } from '../../utils/class-names'
@@ -501,6 +502,8 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     if (theirProps.className === '') delete theirProps.className
   }
 
+  let [, slot] = useTransitionData(ready, container, show)
+
   return (
     <NestingContext.Provider value={nesting}>
       <OpenClosedProvider
@@ -514,6 +517,7 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
         {render({
           ourProps,
           theirProps,
+          slot,
           defaultTag: DEFAULT_TRANSITION_CHILD_TAG,
           features: TransitionChildRenderFeatures,
           visible: state === TreeStates.Visible,
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts
index fc2e31e..a3ea34e 100644
--- a/packages/@headlessui-react/src/components/transition/utils/transition.ts
+++ b/packages/@headlessui-react/src/components/transition/utils/transition.ts
@@ -11,7 +11,7 @@ function removeClasses(node: HTMLElement, ...classes: string[]) {
   node && classes.length > 0 && node.classList.remove(...classes)
 }
 
-function waitForTransition(node: HTMLElement, _done: () => void) {
+export function waitForTransition(node: HTMLElement, _done: () => void) {
   let done = once(_done)
   let d = disposables()
 
@@ -184,7 +184,7 @@ export function transition(
   return d.dispose
 }
 
-function prepareTransition(
+export function prepareTransition(
   node: HTMLElement,
   { inFlight, prepare }: { inFlight?: MutableRefObject<boolean>; prepare: () => void }
 ) {
diff --git a/packages/@headlessui-react/src/hooks/use-flags.ts b/packages/@headlessui-react/src/hooks/use-flags.ts
index 4f9cb84..f7d1590 100644
--- a/packages/@headlessui-react/src/hooks/use-flags.ts
+++ b/packages/@headlessui-react/src/hooks/use-flags.ts
@@ -1,32 +1,14 @@
 import { useCallback, useState } from 'react'
-import { useIsMounted } from './use-is-mounted'
 
 export function useFlags(initialFlags = 0) {
   let [flags, setFlags] = useState(initialFlags)
-  let mounted = useIsMounted()
 
-  let addFlag = useCallback(
-    (flag: number) => {
-      if (!mounted.current) return
-      setFlags((flags) => flags | flag)
-    },
-    [flags, mounted]
-  )
-  let hasFlag = useCallback((flag: number) => Boolean(flags & flag), [flags])
-  let removeFlag = useCallback(
-    (flag: number) => {
-      if (!mounted.current) return
-      setFlags((flags) => flags & ~flag)
-    },
-    [setFlags, mounted]
-  )
-  let toggleFlag = useCallback(
-    (flag: number) => {
-      if (!mounted.current) return
-      setFlags((flags) => flags ^ flag)
-    },
-    [setFlags]
-  )
+  let setFlag = useCallback((flag: number) => setFlags(flag), [flags])
 
-  return { flags, addFlag, hasFlag, removeFlag, toggleFlag }
+  let addFlag = useCallback((flag: number) => setFlags((flags) => flags | flag), [flags])
+  let hasFlag = useCallback((flag: number) => (flags & flag) === flag, [flags])
+  let removeFlag = useCallback((flag: number) => setFlags((flags) => flags & ~flag), [setFlags])
+  let toggleFlag = useCallback((flag: number) => setFlags((flags) => flags ^ flag), [setFlags])
+
+  return { flags, setFlag, addFlag, hasFlag, removeFlag, toggleFlag }
 }
diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts
new file mode 100644
index 0000000..64645d4
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-transition-data.ts
@@ -0,0 +1,213 @@
+import { useRef, useState, type MutableRefObject } from 'react'
+import { prepareTransition, waitForTransition } from '../components/transition/utils/transition'
+import { disposables } from '../utils/disposables'
+import { useDisposables } from './use-disposables'
+import { useFlags } from './use-flags'
+import { useIsoMorphicEffect } from './use-iso-morphic-effect'
+
+/**
+ * ```
+ * ┌─────┐              │       ┌────────────┐
+ * │From │              │       │From        │
+ * └─────┘              │       └────────────┘
+ * ┌─────┐┌─────┐┌─────┐│┌─────┐┌─────┐┌─────┐
+ * │Frame││Frame││Frame│││Frame││Frame││Frame│
+ * └─────┘└─────┘└─────┘│└─────┘└─────┘└─────┘
+ * ┌───────────────────┐│┌───────────────────┐
+ * │Enter              │││Exit               │
+ * └───────────────────┘│└───────────────────┘
+ * ┌───────────────────┐│┌───────────────────┐
+ * │Transition         │││Transition         │
+ * ├───────────────────┘│└───────────────────┘
+ * │
+ * └─ Applied when `Enter` or `Exit` is applied.
+ * ```
+ */
+enum TransitionState {
+  None = 0,
+
+  From = 1 << 0,
+
+  Enter = 1 << 1,
+  Exit = 1 << 2,
+}
+
+export type TransitionData = {
+  from?: boolean
+  enter?: boolean
+  exit?: boolean
+  transition?: boolean
+}
+
+export function useTransitionData(
+  enabled: boolean,
+  elementRef: MutableRefObject<HTMLElement | null>,
+  show: boolean
+): [visible: boolean, data: TransitionData] {
+  let [visible, setVisible] = useState(show)
+
+  let { hasFlag, addFlag, removeFlag } = useFlags(
+    visible ? TransitionState.From : TransitionState.None
+  )
+  let inFlight = useRef(false)
+  let cancelledRef = useRef(false)
+
+  let d = useDisposables()
+
+  useIsoMorphicEffect(
+    function retry() {
+      if (!enabled) return
+
+      if (show) {
+        setVisible(true)
+      }
+
+      let node = elementRef.current
+      if (!node) {
+        // Retry if the DOM node isn't available yet
+        if (show) {
+          addFlag(TransitionState.Enter | TransitionState.From)
+          return d.nextFrame(() => retry())
+        }
+        return
+      }
+
+      return transition(node, {
+        inFlight,
+        prepare() {
+          if (cancelledRef.current) {
+            // Cancelled a cancellation, we're back to the original state.
+            cancelledRef.current = false
+          } else {
+            // If we were already in-flight, then we want to cancel the current
+            // transition.
+            cancelledRef.current = inFlight.current
+          }
+
+          inFlight.current = true
+
+          if (cancelledRef.current) return
+
+          if (show) {
+            addFlag(TransitionState.Enter | TransitionState.From)
+            removeFlag(TransitionState.Exit)
+          } else {
+            addFlag(TransitionState.Exit)
+            removeFlag(TransitionState.Enter)
+          }
+        },
+        run() {
+          if (cancelledRef.current) {
+            // If we cancelled a transition, then the `show` state is going to
+            // be inverted already, but that doesn't mean we have to go to that
+            // new state.
+            //
+            // What we actually want is to revert to the "idle" state (the
+            // stable state where an `Enter` transitions to, and an `Exit`
+            // transitions from.)
+            //
+            // Because of this, it might look like we are swapping the flags in
+            // the following branches, but that's not the case.
+            if (show) {
+              removeFlag(TransitionState.Enter | TransitionState.From)
+              addFlag(TransitionState.Exit)
+            } else {
+              removeFlag(TransitionState.Exit)
+              addFlag(TransitionState.Enter | TransitionState.From)
+            }
+          } else {
+            if (show) {
+              removeFlag(TransitionState.From)
+            } else {
+              addFlag(TransitionState.From)
+            }
+          }
+        },
+        done() {
+          if (cancelledRef.current) {
+            if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
+              return
+            }
+          }
+
+          inFlight.current = false
+
+          removeFlag(TransitionState.Enter | TransitionState.Exit | TransitionState.From)
+
+          if (!show) {
+            setVisible(false)
+          }
+        },
+      })
+    },
+    [enabled, show, elementRef, d]
+  )
+
+  if (!enabled) {
+    return [
+      show,
+      {
+        from: undefined,
+        enter: undefined,
+        exit: undefined,
+        transition: undefined,
+      },
+    ] as const
+  }
+
+  return [
+    visible,
+    {
+      from: hasFlag(TransitionState.From),
+      enter: hasFlag(TransitionState.Enter),
+      exit: hasFlag(TransitionState.Exit),
+      transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Exit),
+    },
+  ] as const
+}
+
+function transition(
+  node: HTMLElement,
+  {
+    prepare,
+    run,
+    done,
+    inFlight,
+  }: {
+    prepare: () => void
+    run: () => void
+    done: () => void
+    inFlight: MutableRefObject<boolean>
+  }
+) {
+  let d = disposables()
+
+  // Prepare the transitions by ensuring that all the "before" classes are
+  // applied and flushed to the DOM.
+  prepareTransition(node, {
+    prepare,
+    inFlight,
+  })
+
+  // This is a workaround for a bug in all major browsers.
+  //
+  // 1. When an element is just mounted
+  // 2. And you apply a transition to it (e.g.: via a class)
+  // 3. And you're using `getComputedStyle` and read any returned value
+  // 4. Then the `transition` immediately jumps to the end state
+  //
+  // This means that no transition happens at all. To fix this, we delay the
+  // actual transition by one frame.
+  d.nextFrame(() => {
+    // Wait for the transition, once the transition is complete we can cleanup.
+    // This is registered first to prevent race conditions, otherwise it could
+    // happen that the transition is already done before we start waiting for
+    // the actual event.
+    d.add(waitForTransition(node, done))
+
+    // Initiate the transition by applying the new classes.
+    run()
+  })
+
+  return d.dispose
+}


Test patch:
diff --git a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
index c00a201..f133165 100644
--- a/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
+++ b/packages/@headlessui-react/src/components/transition/__snapshots__/transition.test.tsx.snap
@@ -4,11 +4,29 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
 <div
   class="My Page"
 >
-  <article>
-    <aside>
+  <article
+    data-enter=""
+    data-from=""
+    data-headlessui-state="from enter transition"
+    data-transition=""
+    style=""
+  >
+    <aside
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Sidebar
     </aside>
-    <section>
+    <section
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Content
     </section>
   </article>
@@ -19,11 +37,29 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
 <div
   class="My Page"
 >
-  <div>
-    <aside>
+  <div
+    data-enter=""
+    data-from=""
+    data-headlessui-state="from enter transition"
+    data-transition=""
+    style=""
+  >
+    <aside
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Sidebar
     </aside>
-    <section>
+    <section
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Content
     </section>
   </div>
@@ -34,11 +70,29 @@ exports[`Setup API nested should be possible to nest transition components 1`] =
 <div
   class="My Page"
 >
-  <div>
-    <div>
+  <div
+    data-enter=""
+    data-from=""
+    data-headlessui-state="from enter transition"
+    data-transition=""
+    style=""
+  >
+    <div
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Sidebar
     </div>
-    <div>
+    <div
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+      style=""
+    >
       Content
     </div>
   </div>
@@ -49,11 +103,26 @@ exports[`Setup API nested should be possible to use render props on the Transiti
 <div
   class="My Page"
 >
-  <article>
-    <aside>
+  <article
+    data-enter=""
+    data-from=""
+    data-headlessui-state="from enter transition"
+    data-transition=""
+  >
+    <aside
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+    >
       Sidebar
     </aside>
-    <section>
+    <section
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+    >
       Content
     </section>
   </article>
@@ -64,11 +133,27 @@ exports[`Setup API nested should be possible to use render props on the Transiti
 <div
   class="My Page"
 >
-  <div>
-    <aside>
+  <div
+    data-enter=""
+    data-from=""
+    data-headlessui-state="from enter transition"
+    data-transition=""
+    style=""
+  >
+    <aside
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+    >
       Sidebar
     </aside>
-    <section>
+    <section
+      data-enter=""
+      data-from=""
+      data-headlessui-state="from enter transition"
+      data-transition=""
+    >
       Content
     </section>
   </div>
@@ -82,13 +167,24 @@ exports[`Setup API nested should yell at us when we forgot to forward a ref on t
 exports[`Setup API nested should yell at us when we forgot to forward the ref on one of the Transition.Child components 1`] = `"Did you forget to passthrough the \`ref\` to the actual DOM node?"`;
 
 exports[`Setup API shallow should be possible to change the underlying DOM tag 1`] = `
-<span>
+<span
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
+  style=""
+>
   Children
 </span>
 `;
 
 exports[`Setup API shallow should be possible to use a render prop 1`] = `
-<span>
+<span
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
+>
   Children
 </span>
 `;
@@ -96,7 +192,12 @@ exports[`Setup API shallow should be possible to use a render prop 1`] = `
 exports[`Setup API shallow should passthrough all the props (that we do not use internally) 1`] = `
 <div
   class="text-blue-400"
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
   id="root"
+  style=""
 >
   Children
 </div>
@@ -105,14 +206,25 @@ exports[`Setup API shallow should passthrough all the props (that we do not use
 exports[`Setup API shallow should passthrough all the props (that we do not use internally) even when using an \`as\` prop 1`] = `
 <a
   class="text-blue-400"
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
   href="/"
+  style=""
 >
   Children
 </a>
 `;
 
 exports[`Setup API shallow should render another component if the \`as\` prop is used and its children by default 1`] = `
-<a>
+<a
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
+  style=""
+>
   Children
 </a>
 `;
@@ -122,7 +234,13 @@ exports[`Setup API shallow should render nothing when the show prop is false 1`]
 exports[`Setup API shallow should yell at us when we forget to forward the ref when using a render prop 1`] = `"Did you forget to passthrough the \`ref\` to the actual DOM node?"`;
 
 exports[`Setup API transition classes should be possible to passthrough the transition classes 1`] = `
-<div>
+<div
+  data-enter=""
+  data-from=""
+  data-headlessui-state="from enter transition"
+  data-transition=""
+  style=""
+>
   Children
 </div>
 `;
@@ -130,6 +248,8 @@ exports[`Setup API transition classes should be possible to passthrough the tran
 exports[`Setup API transition classes should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true 1`] = `
 <div
   class="enter enter-from"
+  data-from=""
+  data-headlessui-state="from"
   style=""
 >
   Children
diff --git a/packages/@headlessui-react/src/components/transition/transition.test.tsx b/packages/@headlessui-react/src/components/transition/transition.test.tsx
index f070dbf..58ebb6f 100644
--- a/packages/@headlessui-react/src/components/transition/transition.test.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.test.tsx
@@ -358,6 +358,11 @@ describe('Setup API', () => {
           <div
             class="foo1
         foo2"
+            data-enter=""
+            data-from=""
+            data-headlessui-state="from enter transition"
+            data-transition=""
+            style=""
           >
             Children
           </div>
@@ -370,18 +375,22 @@ describe('Setup API', () => {
       // The `foo1\nfoo2` should be gone
       // I think this is a quirk of JSDOM
       expect(container.firstChild).toMatchInlineSnapshot(`
-        <div>
-          <button>
-            toggle
-          </button>
-          <div
-            class="foo1
-        foo2 foo1 foo2 leave"
-            style=""
-          >
-            Children
-          </div>
-        </div>
+         <div>
+           <button>
+             toggle
+           </button>
+           <div
+             class="foo1
+         foo2 foo1 foo2 leave"
+             data-enter=""
+             data-from=""
+             data-headlessui-state="from enter transition"
+             data-transition=""
+             style=""
+           >
+             Children
+           </div>
+         </div>
       `)
     })