Repository: tailwindlabs/headlessui
Pull request #3303 (2024-06-19 10:54:49)

Implement `<Transition />` and `<TransitionChild />` on top of data attributes
This PR is mainly a refactor to implement the `<Transition />` and `<TransitionChild />` components on top of the data attribute implementation. The big difference is that there is no direct DOM mutation anymore, but just applying data attributes to the underlying element.

This also means that the `<Transition />` and `useTransitionData()` don't have 2 separate paths for controlling the transition, but there is only a single source of truth.

This PR also renames the `useTransitionData()` to `useTransition()` and removed the original `useTransition()`.

Because of the refactor and not relying on direct DOM mutations we can use React to declaratively provide the correct state instead of doing it imperatively by mutating directly. Which in turn fixes an issue: #3277

From an API perspective nothing changes, it's purely an internal refactor.

Fixes: #3277



vercel[bot]: [vc]: #WBLxaxK/QRJ3mpWh9mZ76/CGR7o6z1vEioO+0EuKtpE=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXJlYWN0Iiwicm9vdERpcmVjdG9yeSI6InBsYXlncm91bmRzL3JlYWN0IiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXJlYWN0L0RxNnVCTTU0YWFlbkp6cTc5MjdHQlBjSnFYYzgiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS1yZWFjdC1naXQtcmVmYWN0b3Itc2ltcGxpZnktdHJhbnNpdGlvbi10YWlsd2luZGxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktcmVhY3QtZ2l0LXJlZmFjdG9yLXNpbXBsaWZ5LXRyYW5zaXRpb24tdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAifX0seyJuYW1lIjoiaGVhZGxlc3N1aS12dWUiLCJyb290RGlyZWN0b3J5IjoicGxheWdyb3VuZHMvdnVlIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RhaWx3aW5kbGFicy9oZWFkbGVzc3VpLXZ1ZS9CcVJMdk5IaGN6VWZMMnNaWEZxWFNmNjk5Wk5yIiwicHJldmlld1VybCI6ImhlYWRsZXNzdWktdnVlLWdpdC1yZWZhY3Rvci1zaW1wbGlmeS10cmFuc2l0aW9uLXRhaWx3aW5kbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkciLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiaGVhZGxlc3N1aS12dWUtZ2l0LXJlZmFjdG9yLXNpbXBsaWZ5LXRyYW5zaXRpb24tdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAifX1dfQ==
**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** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-react/Dq6uBM54aaenJzq7927GBPcJqXc8)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-react-git-refactor-simplify-transition-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-react-git-refactor-simplify-transition-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 19, 2024 10:54am |
| **headlessui-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/BqRLvNHhczUfL2sZXFqXSf699ZNr)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-refactor-simplify-transition-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-refactor-simplify-transition-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 19, 2024 10:54am |




Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 6961276..f8c3cc4 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
 - Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
 - Cancel outside click behavior on touch devices when scrolling ([#3266](https://github.com/tailwindlabs/headlessui/pull/3266))
+- Correctly apply conditional classses when using `<Transition />` and `<TransitionChild />` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303))
 
 ### Changed
 
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 89008c7..6927a4e 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -41,7 +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 { useTransition, type TransitionData } from '../../hooks/use-transition'
 import { useTreeWalker } from '../../hooks/use-tree-walker'
 import { useWatch } from '../../hooks/use-watch'
 import { useDisabled } from '../../internal/disabled'
@@ -1610,7 +1610,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   let ownerDocument = useOwnerDocument(data.optionsRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     data.optionsRef,
     usesOpenClosedState !== null
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
index fcf624f..fa47e65 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -24,7 +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 { useTransition, type TransitionData } from '../../hooks/use-transition'
 import { CloseProvider } from '../../internal/close-provider'
 import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
 import type { Props } from '../../types'
@@ -458,7 +458,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
   }, [id, dispatch])
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     state.panelRef,
     usesOpenClosedState !== null
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 8a66aec..3cf1069 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -42,7 +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 { useTransition, type TransitionData } from '../../hooks/use-transition'
 import { useDisabled } from '../../internal/disabled'
 import {
   FloatingProvider,
@@ -919,7 +919,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
   let ownerDocument = useOwnerDocument(data.optionsRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     data.optionsRef,
     usesOpenClosedState !== null
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index 2b40a8c..7fb21a2 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -37,7 +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 { useTransition, type TransitionData } from '../../hooks/use-transition'
 import { useTreeWalker } from '../../hooks/use-tree-walker'
 import {
   FloatingProvider,
@@ -612,7 +612,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
   }
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     state.itemsRef,
     usesOpenClosedState !== null
diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx
index cc8cf85..67826d7 100644
--- a/packages/@headlessui-react/src/components/popover/popover.tsx
+++ b/packages/@headlessui-react/src/components/popover/popover.tsx
@@ -36,7 +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 { useTransition, type TransitionData } from '../../hooks/use-transition'
 import { CloseProvider } from '../../internal/close-provider'
 import {
   FloatingProvider,
@@ -750,7 +750,7 @@ function OverlayFn<TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG>(
   let overlayRef = useSyncRefs(ref, internalOverlayRef)
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     internalOverlayRef,
     usesOpenClosedState !== null
@@ -862,7 +862,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
   }, [id, dispatch])
 
   let usesOpenClosedState = useOpenClosed()
-  let [visible, transitionData] = useTransitionData(
+  let [visible, transitionData] = useTransition(
     transition,
     internalPanelRef,
     usesOpenClosedState !== null
diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx
index 2ab9916..0a676d7 100644
--- a/packages/@headlessui-react/src/components/transition/transition.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.tsx
@@ -4,6 +4,7 @@ import React, {
   Fragment,
   createContext,
   useContext,
+  useEffect,
   useMemo,
   useRef,
   useState,
@@ -13,7 +14,6 @@ import React, {
 } from 'react'
 import { useDisposables } from '../../hooks/use-disposables'
 import { useEvent } from '../../hooks/use-event'
-import { useFlags } from '../../hooks/use-flags'
 import { useIsMounted } from '../../hooks/use-is-mounted'
 import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
 import { useLatestValue } from '../../hooks/use-latest-value'
@@ -21,7 +21,6 @@ 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'
@@ -29,6 +28,7 @@ import { match } from '../../utils/match'
 import {
   RenderFeatures,
   RenderStrategy,
+  compact,
   forwardRefWithAs,
   render,
   type HasDisplayName,
@@ -38,17 +38,7 @@ import {
 
 type ContainerElement = MutableRefObject<HTMLElement | null>
 
-type TransitionDirection = 'enter' | 'leave' | 'idle'
-
-/**
- * Split class lists by whitespace
- *
- * We can't check for just spaces as all whitespace characters are
- * invalid in a class name, so we have to split on ANY whitespace.
- */
-function splitClasses(classes: string = '') {
-  return classes.split(/\s+/).filter((className) => className.length > 1)
-}
+type TransitionDirection = 'enter' | 'leave'
 
 /**
  * Check if we should forward the ref to the child element or not. This is to
@@ -224,11 +214,7 @@ function useNesting(done?: () => void, parent?: NestingContextValues) {
 
   let chains = useRef<
     Record<TransitionDirection, [identifier: ContainerElement, promise: Promise<void>][]>
-  >({
-    enter: [],
-    leave: [],
-    idle: [],
-  })
+  >({ enter: [], leave: [] })
 
   let onStart = useEvent(
     (
@@ -328,13 +314,13 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     leaveTo,
 
     // @ts-expect-error
-    ...rest
+    ...theirProps
   } = props as typeof props
   let container = useRef<HTMLElement | null>(null)
   let requiresRef = shouldForwardRef(props)
 
   let transitionRef = useSyncRefs(...(requiresRef ? [container, ref] : ref === null ? [] : [ref]))
-  let strategy = rest.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
+  let strategy = theirProps.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
 
   let { show, appear, initial } = useTransitionContext()
 
@@ -362,24 +348,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     })
   }, [state, container, register, unregister, show, strategy])
 
-  let classes = useLatestValue({
-    base: splitClasses(rest.className),
-    enter: splitClasses(enter),
-    enterFrom: splitClasses(enterFrom),
-    enterTo: splitClasses(enterTo),
-    entered: splitClasses(entered),
-    leave: splitClasses(leave),
-    leaveFrom: splitClasses(leaveFrom),
-    leaveTo: splitClasses(leaveTo),
-  })
-
-  let events = useLatestValue({
-    beforeEnter,
-    afterEnter,
-    beforeLeave,
-    afterLeave,
-  })
-
   let ready = useServerHandoffComplete()
 
   useIsoMorphicEffect(() => {
@@ -394,43 +362,6 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
   let skip = initial && !appear
   let immediate = appear && show && initial
 
-  let transitionDirection = (() => {
-    if (immediate) return 'enter'
-    if (!ready) return 'idle'
-    if (skip) return 'idle'
-    return show ? 'enter' : 'leave'
-  })() as TransitionDirection
-
-  let transitionStateFlags = useFlags(0)
-
-  let beforeEvent = useEvent((direction: TransitionDirection) => {
-    return match(direction, {
-      enter: () => {
-        transitionStateFlags.addFlag(State.Opening)
-        events.current.beforeEnter?.()
-      },
-      leave: () => {
-        transitionStateFlags.addFlag(State.Closing)
-        events.current.beforeLeave?.()
-      },
-      idle: () => {},
-    })
-  })
-
-  let afterEvent = useEvent((direction: TransitionDirection) => {
-    return match(direction, {
-      enter: () => {
-        transitionStateFlags.removeFlag(State.Opening)
-        events.current.afterEnter?.()
-      },
-      leave: () => {
-        transitionStateFlags.removeFlag(State.Closing)
-        events.current.afterLeave?.()
-      },
-      idle: () => {},
-    })
-  })
-
   let isTransitioning = useRef(false)
 
   let nesting = useNesting(() => {
@@ -443,77 +374,97 @@ function TransitionChildFn<TTag extends ElementType = typeof DEFAULT_TRANSITION_
     unregister(container)
   }, parentNesting)
 
-  useTransition({
-    container,
-    classes,
-    direction: transitionDirection,
-    onStart: useLatestValue((direction) => {
-      isTransitioning.current = true
-      nesting.onStart(container, direction, beforeEvent)
-    }),
-    onStop: useLatestValue((direction) => {
-      isTransitioning.current = false
-      nesting.onStop(container, direction, afterEvent)
-
-      if (direction === 'leave' && !hasChildren(nesting)) {
-        // When we don't have children anymore we can safely unregister from the
-        // parent and hide ourselves.
-        setState(TreeStates.Hidden)
-        unregister(container)
-      }
-    }),
+  let start = useEvent((show: boolean) => {
+    isTransitioning.current = true
+    let direction: TransitionDirection = show ? 'enter' : 'leave'
+
+    nesting.onStart(container, direction, (direction) => {
+      if (direction === 'enter') beforeEnter?.()
+      else if (direction === 'leave') beforeLeave?.()
+    })
   })
 
-  let theirProps = rest
-  let ourProps = { ref: transitionRef }
+  let end = useEvent((show: boolean) => {
+    let direction: TransitionDirection = show ? 'enter' : 'leave'
 
-  // Already apply the `enter` and `enterFrom` on the server if required
-  if (immediate) {
-    theirProps = {
-      ...theirProps,
-      className: classNames(rest.className, ...classes.current.enter, ...classes.current.enterFrom),
-    }
-  }
+    isTransitioning.current = false
+    nesting.onStop(container, direction, (direction) => {
+      if (direction === 'enter') afterEnter?.()
+      else if (direction === 'leave') afterLeave?.()
+    })
 
-  // If we are re-rendering while we are transitioning, then we should ensure that the classes are
-  // not mutated by React itself because we are handling the transition ourself.
-  else if (isTransitioning.current) {
-    // When we re-render while we are in the middle of the transition, then we should take the
-    // incoming className and the current classes that are applied.
-    //
-    // This is a bit dirty, but we need to make sure React is not applying changes to the class
-    // attribute while we are transitioning.
-    theirProps.className = classNames(rest.className, container.current?.className)
-    if (theirProps.className === '') delete theirProps.className
-  }
+    if (direction === 'leave' && !hasChildren(nesting)) {
+      // When we don't have children anymore we can safely unregister from the
+      // parent and hide ourselves.
+      setState(TreeStates.Hidden)
+      unregister(container)
+    }
+  })
 
-  // If we were never transitioning, or we're not transitioning anymore, then
-  // apply the `enterTo` and `leaveTo` classes as the final state.
-  else {
-    theirProps.className = classNames(
-      rest.className,
-      container.current?.className,
-      ...match(transitionDirection, {
-        enter: [...classes.current.enterTo, ...classes.current.entered],
-        leave: classes.current.leaveTo,
-        idle: [],
-      })
-    )
-    if (theirProps.className === '') delete theirProps.className
-  }
+  useEffect(() => {
+    if (requiresRef) return
+
+    // When we don't transition, then we can complete the transition
+    // immediately.
+    start(show)
+    end(show)
+  }, [show, requiresRef])
+
+  let enabled = (() => {
+    // If we don't require a ref, then we can't transition.
+    if (!requiresRef) return false
+
+    // If the server handoff isn't completed yet, we can't transition.
+    if (!ready) return false
+
+    // If we start in a `show` state but without the `appear` prop, then we skip
+    // the initial transition.
+    if (skip) return false
+
+    return true
+  })()
+
+  // Ignoring the `visible` state because this doesn't handle the hierarchy. If
+  // a leave transition on the `<Transition>` is done, but there is still a
+  // child `<TransitionChild>` busy, then `visible` would be `false`, while
+  // `state` would still be `TreeStates.Visible`.
+  let [, slot] = useTransition(enabled, container, show, { start, end })
+
+  let ourProps = compact({
+    ref: transitionRef,
+    className:
+      classNames(
+        // Incoming classes if any
+        theirProps.className,
+
+        // Apply these classes immediately
+        immediate && enter,
+        immediate && enterFrom,
+
+        // Map data attributes to `enter`, `enterFrom` and `enterTo` classes
+        slot.enter && enter,
+        slot.enter && slot.closed && enterFrom,
+        slot.enter && !slot.closed && enterTo,
+
+        // Map data attributes to `leave`, `leaveFrom` and `leaveTo` classes
+        slot.leave && leave,
+        slot.leave && !slot.closed && leaveFrom,
+        slot.leave && slot.closed && leaveTo,
+
+        // Map data attributes to `entered` class (backwards compatibility)
+        !slot.transition && show && entered
+      )?.trim() || undefined, // If `className` is an empty string, we can omit it
+  })
 
-  let [, slot] = useTransitionData(ready, container, show)
+  let openClosedState = 0
+  if (state === TreeStates.Visible) openClosedState |= State.Open
+  if (state === TreeStates.Hidden) openClosedState |= State.Closed
+  if (slot.enter) openClosedState |= State.Opening
+  if (slot.leave) openClosedState |= State.Closing
 
   return (
     <NestingContext.Provider value={nesting}>
-      <OpenClosedProvider
-        value={
-          match(state, {
-            [TreeStates.Visible]: State.Open,
-            [TreeStates.Hidden]: State.Closed,
-          }) | transitionStateFlags.flags
-        }
-      >
+      <OpenClosedProvider value={openClosedState}>
         {render({
           ourProps,
           theirProps,
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.ts b/packages/@headlessui-react/src/components/transition/utils/transition.ts
deleted file mode 100644
index a3ea34e..0000000
--- a/packages/@headlessui-react/src/components/transition/utils/transition.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import type { MutableRefObject } from 'react'
-import { disposables } from '../../../utils/disposables'
-import { match } from '../../../utils/match'
-import { once } from '../../../utils/once'
-
-function addClasses(node: HTMLElement, ...classes: string[]) {
-  node && classes.length > 0 && node.classList.add(...classes)
-}
-
-function removeClasses(node: HTMLElement, ...classes: string[]) {
-  node && classes.length > 0 && node.classList.remove(...classes)
-}
-
-export function waitForTransition(node: HTMLElement, _done: () => void) {
-  let done = once(_done)
-  let d = disposables()
-
-  if (!node) return d.dispose
-
-  // Safari returns a comma separated list of values, so let's sort them and take the highest value.
-  let { transitionDuration, transitionDelay } = getComputedStyle(node)
-
-  let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
-    let [resolvedValue = 0] = value
-      .split(',')
-      // Remove falsy we can't work with
-      .filter(Boolean)
-      // Values are returned as `0.3s` or `75ms`
-      .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
-      .sort((a, z) => z - a)
-
-    return resolvedValue
-  })
-
-  let totalDuration = durationMs + delayMs
-
-  if (totalDuration !== 0) {
-    if (process.env.NODE_ENV === 'test') {
-      let dispose = d.setTimeout(() => {
-        done()
-        dispose()
-      }, totalDuration)
-    } else {
-      let disposeGroup = d.group((d) => {
-        // Mark the transition as done when the timeout is reached. This is a fallback in case the
-        // transitionrun event is not fired.
-        let cancelTimeout = d.setTimeout(() => {
-          done()
-          d.dispose()
-        }, totalDuration)
-
-        // The moment the transitionrun event fires, we should cleanup the timeout fallback, because
-        // then we know that we can use the native transition events because something is
-        // transitioning.
-        d.addEventListener(node, 'transitionrun', (event) => {
-          if (event.target !== event.currentTarget) return
-          cancelTimeout()
-
-          d.addEventListener(node, 'transitioncancel', (event) => {
-            if (event.target !== event.currentTarget) return
-            done()
-            disposeGroup()
-          })
-        })
-      })
-
-      d.addEventListener(node, 'transitionend', (event) => {
-        if (event.target !== event.currentTarget) return
-        done()
-        d.dispose()
-      })
-    }
-  } else {
-    // No transition is happening, so we should cleanup already. Otherwise we have to wait until we
-    // get disposed.
-    done()
-  }
-
-  return d.dispose
-}
-
-export function transition(
-  node: HTMLElement,
-  {
-    direction,
-    done,
-    classes,
-    inFlight,
-  }: {
-    direction: 'enter' | 'leave'
-    done?: () => void
-    classes: {
-      base: string[]
-      enter: string[]
-      enterFrom: string[]
-      enterTo: string[]
-      leave: string[]
-      leaveFrom: string[]
-      leaveTo: string[]
-      entered: string[]
-    }
-    inFlight?: MutableRefObject<boolean>
-  }
-) {
-  let d = disposables()
-  let _done = done !== undefined ? once(done) : () => {}
-
-  // When using unmount={false}, when the element is "hidden", then we apply a `style.display =
-  // 'none'` and a `hidden` attribute. Let's remove that in case we want to make an enter
-  // transition. It can happen that React is removing this a bit too late causing the element to not
-  // transition at all.
-  if (direction === 'enter') {
-    node.removeAttribute('hidden')
-    node.style.display = ''
-  }
-
-  let base = match(direction, {
-    enter: () => classes.enter,
-    leave: () => classes.leave,
-  })
-  let to = match(direction, {
-    enter: () => classes.enterTo,
-    leave: () => classes.leaveTo,
-  })
-  let from = match(direction, {
-    enter: () => classes.enterFrom,
-    leave: () => classes.leaveFrom,
-  })
-
-  // Prepare the transitions by ensuring that all the "before" classes are
-  // applied and flushed to the DOM.
-  prepareTransition(node, {
-    prepare() {
-      removeClasses(
-        node,
-        ...classes.base,
-        ...classes.enter,
-        ...classes.enterTo,
-        ...classes.enterFrom,
-        ...classes.leave,
-        ...classes.leaveFrom,
-        ...classes.leaveTo,
-        ...classes.entered
-      )
-      addClasses(node, ...classes.base, ...base, ...from)
-    },
-    inFlight,
-  })
-
-  // Mark the transition as in-flight
-  if (inFlight) inFlight.current = true
-
-  // 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, () => {
-        removeClasses(node, ...classes.base, ...base)
-        addClasses(node, ...classes.base, ...classes.entered, ...to)
-
-        // Mark the transition as done.
-        if (inFlight) inFlight.current = false
-
-        return _done()
-      })
-    )
-
-    // Initiate the transition by applying the new classes.
-    removeClasses(node, ...classes.base, ...base, ...from)
-    addClasses(node, ...classes.base, ...base, ...to)
-  })
-
-  return d.dispose
-}
-
-export function prepareTransition(
-  node: HTMLElement,
-  { inFlight, prepare }: { inFlight?: MutableRefObject<boolean>; prepare: () => void }
-) {
-  // If we are already transitioning, then we don't need to force cancel the
-  // current transition (by triggering a reflow).
-  if (inFlight?.current) {
-    prepare()
-    return
-  }
-
-  let previous = node.style.transition
-
-  // Force cancel current transition
-  node.style.transition = 'none'
-
-  prepare()
-
-  // Trigger a reflow, flushing the CSS changes
-  node.offsetHeight
-
-  // Reset the transition to what it was before
-  node.style.transition = previous
-}
diff --git a/packages/@headlessui-react/src/hooks/use-transition-data.ts b/packages/@headlessui-react/src/hooks/use-transition-data.ts
deleted file mode 100644
index 8e93692..0000000
--- a/packages/@headlessui-react/src/hooks/use-transition-data.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-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'
-
-/**
- * ```
- * ┌──────┐                │        ┌──────────────┐
- * │Closed│                │        │Closed        │
- * └──────┘                │        └──────────────┘
- * ┌──────┐┌──────┐┌──────┐│┌──────┐┌──────┐┌──────┐
- * │Frame ││Frame ││Frame │││Frame ││Frame ││Frame │
- * └──────┘└──────┘└──────┘│└──────┘└──────┘└──────┘
- * ┌──────────────────────┐│┌──────────────────────┐
- * │Enter                 │││Leave                 │
- * └──────────────────────┘│└──────────────────────┘
- * ┌──────────────────────┐│┌──────────────────────┐
- * │Transition            │││Transition            │
- * ├──────────────────────┘│└──────────────────────┘
- * │
- * └─ Applied when `Enter` or `Leave` is applied.
- * ```
- */
-enum TransitionState {
-  None = 0,
-
-  Closed = 1 << 0,
-
-  Enter = 1 << 1,
-  Leave = 1 << 2,
-}
-
-export type TransitionData = {
-  closed?: boolean
-  enter?: boolean
-  leave?: 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.Closed : 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.Closed)
-          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.Closed)
-            removeFlag(TransitionState.Leave)
-          } else {
-            addFlag(TransitionState.Leave)
-            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 a `Leave`
-            // 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.Closed)
-              addFlag(TransitionState.Leave)
-            } else {
-              removeFlag(TransitionState.Leave)
-              addFlag(TransitionState.Enter | TransitionState.Closed)
-            }
-          } else {
-            if (show) {
-              removeFlag(TransitionState.Closed)
-            } else {
-              addFlag(TransitionState.Closed)
-            }
-          }
-        },
-        done() {
-          if (cancelledRef.current) {
-            if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
-              return
-            }
-          }
-
-          inFlight.current = false
-
-          removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
-
-          if (!show) {
-            setVisible(false)
-          }
-        },
-      })
-    },
-    [enabled, show, elementRef, d]
-  )
-
-  if (!enabled) {
-    return [
-      show,
-      {
-        closed: undefined,
-        enter: undefined,
-        leave: undefined,
-        transition: undefined,
-      },
-    ] as const
-  }
-
-  return [
-    visible,
-    {
-      closed: hasFlag(TransitionState.Closed),
-      enter: hasFlag(TransitionState.Enter),
-      leave: hasFlag(TransitionState.Leave),
-      transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
-    },
-  ] 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
-}
diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts
index e8114dd..7977184 100644
--- a/packages/@headlessui-react/src/hooks/use-transition.ts
+++ b/packages/@headlessui-react/src/hooks/use-transition.ts
@@ -1,66 +1,314 @@
-import { useRef, type MutableRefObject } from 'react'
-import { transition } from '../components/transition/utils/transition'
+import { useRef, useState, type MutableRefObject } from 'react'
+import { disposables } from '../utils/disposables'
+import { once } from '../utils/once'
 import { useDisposables } from './use-disposables'
-import { useIsMounted } from './use-is-mounted'
+import { useFlags } from './use-flags'
 import { useIsoMorphicEffect } from './use-iso-morphic-effect'
 
-interface TransitionArgs {
-  container: MutableRefObject<HTMLElement | null>
-  classes: MutableRefObject<{
-    base: string[]
+/**
+ * ```
+ * ┌──────┐                │        ┌──────────────┐
+ * │Closed│                │        │Closed        │
+ * └──────┘                │        └──────────────┘
+ * ┌──────┐┌──────┐┌──────┐│┌──────┐┌──────┐┌──────┐
+ * │Frame ││Frame ││Frame │││Frame ││Frame ││Frame │
+ * └──────┘└──────┘└──────┘│└──────┘└──────┘└──────┘
+ * ┌──────────────────────┐│┌──────────────────────┐
+ * │Enter                 │││Leave                 │
+ * └──────────────────────┘│└──────────────────────┘
+ * ┌──────────────────────┐│┌──────────────────────┐
+ * │Transition            │││Transition            │
+ * ├──────────────────────┘│└──────────────────────┘
+ * │
+ * └─ Applied when `Enter` or `Leave` is applied.
+ * ```
+ */
+enum TransitionState {
+  None = 0,
 
-    enter: string[]
-    enterFrom: string[]
-    enterTo: string[]
+  Closed = 1 << 0,
 
-    leave: string[]
-    leaveFrom: string[]
-    leaveTo: string[]
+  Enter = 1 << 1,
+  Leave = 1 << 2,
+}
 
-    entered: string[]
-  }>
-  direction: 'enter' | 'leave' | 'idle'
-  onStart: MutableRefObject<(direction: TransitionArgs['direction']) => void>
-  onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void>
+export type TransitionData = {
+  closed?: boolean
+  enter?: boolean
+  leave?: boolean
+  transition?: boolean
 }
 
-export function useTransition({ container, direction, classes, onStart, onStop }: TransitionArgs) {
-  let mounted = useIsMounted()
-  let d = useDisposables()
+export function useTransition(
+  enabled: boolean,
+  elementRef: MutableRefObject<HTMLElement | null>,
+  show: boolean,
+  events?: {
+    start?(show: boolean): void
+    end?(show: boolean): void
+  }
+): [visible: boolean, data: TransitionData] {
+  let [visible, setVisible] = useState(show)
 
-  // Track whether the transition is in flight or not. This will help us for
-  // cancelling mid-transition because in that case we don't have to force
-  // clearing existing transitions. See: `prepareTransition` in the `transition`
-  // file.
+  let { hasFlag, addFlag, removeFlag } = useFlags(
+    enabled && visible ? TransitionState.Enter | TransitionState.Closed : TransitionState.None
+  )
   let inFlight = useRef(false)
+  let cancelledRef = useRef(false)
 
-  useIsoMorphicEffect(() => {
-    if (direction === 'idle') return // We don't need to transition
-    if (!mounted.current) return
+  let d = useDisposables()
 
-    onStart.current(direction)
+  useIsoMorphicEffect(
+    function retry() {
+      if (!enabled) return
 
-    let node = container.current
-    if (!node) {
-      // No node, so let's skip the transition and call the `onStop` callback
-      // immediately because there is no transition to wait for anyway.
-      onStop.current(direction)
-    }
+      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.Closed)
+          return d.nextFrame(() => retry())
+        }
+        return
+      }
+
+      events?.start?.(show)
+
+      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.Closed)
+            removeFlag(TransitionState.Leave)
+          } else {
+            addFlag(TransitionState.Leave)
+            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 a `Leave`
+            // 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.Closed)
+              addFlag(TransitionState.Leave)
+            } else {
+              removeFlag(TransitionState.Leave)
+              addFlag(TransitionState.Enter | TransitionState.Closed)
+            }
+          } else {
+            if (show) {
+              removeFlag(TransitionState.Closed)
+            } else {
+              addFlag(TransitionState.Closed)
+            }
+          }
+        },
+        done() {
+          if (cancelledRef.current) {
+            if (typeof node.getAnimations === 'function' && node.getAnimations().length > 0) {
+              return
+            }
+          }
+
+          inFlight.current = false
+
+          removeFlag(TransitionState.Enter | TransitionState.Leave | TransitionState.Closed)
+
+          if (!show) {
+            setVisible(false)
+          }
+
+          events?.end?.(show)
+        },
+      })
+    },
+    [enabled, show, elementRef, d]
+  )
+
+  if (!enabled) {
+    return [
+      show,
+      {
+        closed: undefined,
+        enter: undefined,
+        leave: undefined,
+        transition: undefined,
+      },
+    ] as const
+  }
+
+  return [
+    visible,
+    {
+      closed: hasFlag(TransitionState.Closed),
+      enter: hasFlag(TransitionState.Enter),
+      leave: hasFlag(TransitionState.Leave),
+      transition: hasFlag(TransitionState.Enter) || hasFlag(TransitionState.Leave),
+    },
+  ] 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))
 
-    // We do have a node, let's transition it!
-    else {
-      d.add(
-        transition(node, {
-          direction,
-          classes: classes.current,
-          inFlight,
-          done() {
-            onStop.current(direction)
-          },
+    // Initiate the transition by applying the new classes.
+    run()
+  })
+
+  return d.dispose
+}
+
+function waitForTransition(node: HTMLElement, _done: () => void) {
+  let done = once(_done)
+  let d = disposables()
+
+  if (!node) return d.dispose
+
+  // Safari returns a comma separated list of values, so let's sort them and take the highest value.
+  let { transitionDuration, transitionDelay } = getComputedStyle(node)
+
+  let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => {
+    let [resolvedValue = 0] = value
+      .split(',')
+      // Remove falsy we can't work with
+      .filter(Boolean)
+      // Values are returned as `0.3s` or `75ms`
+      .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
+      .sort((a, z) => z - a)
+
+    return resolvedValue
+  })
+
+  let totalDuration = durationMs + delayMs
+
+  if (totalDuration !== 0) {
+    if (process.env.NODE_ENV === 'test') {
+      let dispose = d.setTimeout(() => {
+        done()
+        dispose()
+      }, totalDuration)
+    } else {
+      let disposeGroup = d.group((d) => {
+        // Mark the transition as done when the timeout is reached. This is a fallback in case the
+        // transitionrun event is not fired.
+        let cancelTimeout = d.setTimeout(() => {
+          done()
+          d.dispose()
+        }, totalDuration)
+
+        // The moment the transitionrun event fires, we should cleanup the timeout fallback, because
+        // then we know that we can use the native transition events because something is
+        // transitioning.
+        d.addEventListener(node, 'transitionrun', (event) => {
+          if (event.target !== event.currentTarget) return
+          cancelTimeout()
+
+          d.addEventListener(node, 'transitioncancel', (event) => {
+            if (event.target !== event.currentTarget) return
+            done()
+            disposeGroup()
+          })
         })
-      )
+      })
+
+      d.addEventListener(node, 'transitionend', (event) => {
+        if (event.target !== event.currentTarget) return
+        done()
+        d.dispose()
+      })
     }
+  } else {
+    // No transition is happening, so we should cleanup already. Otherwise we have to wait until we
+    // get disposed.
+    done()
+  }
+
+  return d.dispose
+}
+
+function prepareTransition(
+  node: HTMLElement,
+  { inFlight, prepare }: { inFlight?: MutableRefObject<boolean>; prepare: () => void }
+) {
+  // If we are already transitioning, then we don't need to force cancel the
+  // current transition (by triggering a reflow).
+  if (inFlight?.current) {
+    prepare()
+    return
+  }
+
+  let previous = node.style.transition
+
+  // Force cancel current transition
+  node.style.transition = 'none'
+
+  prepare()
+
+  // Trigger a reflow, flushing the CSS changes
+  node.offsetHeight
 
-    return d.dispose
-  }, [direction])
+  // Reset the transition to what it was before
+  node.style.transition = previous
 }
diff --git a/playgrounds/react/pages/transitions/both-apis.tsx b/playgrounds/react/pages/transitions/both-apis.tsx
new file mode 100644
index 0000000..b876f9a
--- /dev/null
+++ b/playgrounds/react/pages/transitions/both-apis.tsx
@@ -0,0 +1,55 @@
+import { Transition } from '@headlessui/react'
+import clsx from 'clsx'
+import { useState } from 'react'
+
+export default function Example() {
+  const [open, setOpen] = useState(false)
+  return (
+    <div className="grid min-h-full place-content-center">
+      <div className="flex flex-col">
+        <button onClick={() => setOpen((open) => !open)}>Toggle transition</button>
+        <div className="flex h-20 w-80">
+          <Before open={open} />
+          <After open={open} />
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function Before({ open }: { open: boolean }) {
+  return (
+    <Transition
+      show={open}
+      enter="transition ease-in-out duration-300"
+      enterFrom="opacity-0 -translate-x-full"
+      enterTo="opacity-100 translate-x-0"
+      leave="transition ease-in-out duration-300"
+      leaveFrom="opacity-100 translate-x-0"
+      leaveTo="opacity-0 translate-x-full"
+    >
+      <div className="h-20 w-48 border bg-blue-500 p-2 text-white">Using specific props</div>
+    </Transition>
+  )
+}
+
+function After({ open }: { open: boolean }) {
+  return (
+    <Transition show={open}>
+      <div
+        className={clsx([
+          // Defaults
+          'h-20 w-48 border bg-blue-500 p-2 text-white transition ease-in-out',
+          // Closed
+          'data-[closed]:opacity-0',
+          // Entering
+          'data-[enter]:data-[closed]:-translate-x-full data-[enter]:duration-300',
+          // Leaving
+          'data-[leave]:data-[closed]:translate-x-full data-[leave]:duration-300',
+        ])}
+      >
+        Using data attributes
+      </div>
+    </Transition>
+  )
+}


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 df81926..1bc2914 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,29 +4,11 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
 <div
   class="My Page"
 >
-  <article
-    data-closed=""
-    data-enter=""
-    data-headlessui-state="closed enter transition"
-    data-transition=""
-    style=""
-  >
-    <aside
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+  <article>
+    <aside>
       Sidebar
     </aside>
-    <section
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+    <section>
       Content
     </section>
   </article>
@@ -37,29 +19,11 @@ exports[`Setup API nested should be possible to change the underlying DOM tag of
 <div
   class="My Page"
 >
-  <div
-    data-closed=""
-    data-enter=""
-    data-headlessui-state="closed enter transition"
-    data-transition=""
-    style=""
-  >
-    <aside
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+  <div>
+    <aside>
       Sidebar
     </aside>
-    <section
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+    <section>
       Content
     </section>
   </div>
@@ -70,29 +34,11 @@ exports[`Setup API nested should be possible to nest transition components 1`] =
 <div
   class="My Page"
 >
-  <div
-    data-closed=""
-    data-enter=""
-    data-headlessui-state="closed enter transition"
-    data-transition=""
-    style=""
-  >
-    <div
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+  <div>
+    <div>
       Sidebar
     </div>
-    <div
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-      style=""
-    >
+    <div>
       Content
     </div>
   </div>
@@ -103,26 +49,11 @@ exports[`Setup API nested should be possible to use render props on the Transiti
 <div
   class="My Page"
 >
-  <article
-    data-closed=""
-    data-enter=""
-    data-headlessui-state="closed enter transition"
-    data-transition=""
-  >
-    <aside
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-    >
+  <article>
+    <aside>
       Sidebar
     </aside>
-    <section
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-    >
+    <section>
       Content
     </section>
   </article>
@@ -133,27 +64,11 @@ exports[`Setup API nested should be possible to use render props on the Transiti
 <div
   class="My Page"
 >
-  <div
-    data-closed=""
-    data-enter=""
-    data-headlessui-state="closed enter transition"
-    data-transition=""
-    style=""
-  >
-    <aside
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-    >
+  <div>
+    <aside>
       Sidebar
     </aside>
-    <section
-      data-closed=""
-      data-enter=""
-      data-headlessui-state="closed enter transition"
-      data-transition=""
-    >
+    <section>
       Content
     </section>
   </div>
@@ -167,24 +82,13 @@ 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
-  data-closed=""
-  data-enter=""
-  data-headlessui-state="closed enter transition"
-  data-transition=""
-  style=""
->
+<span>
   Children
 </span>
 `;
 
 exports[`Setup API shallow should be possible to use a render prop 1`] = `
-<span
-  data-closed=""
-  data-enter=""
-  data-headlessui-state="closed enter transition"
-  data-transition=""
->
+<span>
   Children
 </span>
 `;
@@ -192,12 +96,7 @@ 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-closed=""
-  data-enter=""
-  data-headlessui-state="closed enter transition"
-  data-transition=""
   id="root"
-  style=""
 >
   Children
 </div>
@@ -206,25 +105,14 @@ 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-closed=""
-  data-enter=""
-  data-headlessui-state="closed 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
-  data-closed=""
-  data-enter=""
-  data-headlessui-state="closed enter transition"
-  data-transition=""
-  style=""
->
+<a>
   Children
 </a>
 `;
@@ -234,13 +122,7 @@ 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
-  data-closed=""
-  data-enter=""
-  data-headlessui-state="closed enter transition"
-  data-transition=""
-  style=""
->
+<div>
   Children
 </div>
 `;
@@ -249,7 +131,9 @@ exports[`Setup API transition classes should be possible to passthrough the tran
 <div
   class="enter enter-from"
   data-closed=""
-  data-headlessui-state="closed"
+  data-enter=""
+  data-headlessui-state="closed enter transition"
+  data-transition=""
   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 4a2b2d0..045f474 100644
--- a/packages/@headlessui-react/src/components/transition/transition.test.tsx
+++ b/packages/@headlessui-react/src/components/transition/transition.test.tsx
@@ -358,11 +358,6 @@ describe('Setup API', () => {
           <div
             class="foo1
         foo2"
-            data-closed=""
-            data-enter=""
-            data-headlessui-state="closed enter transition"
-            data-transition=""
-            style=""
           >
             Children
           </div>
@@ -371,9 +366,6 @@ describe('Setup API', () => {
 
       await click(getByText('toggle'))
 
-      // TODO: This is not quite right
-      // The `foo1\nfoo2` should be gone
-      // I think this is a quirk of JSDOM
       expect(container.firstChild).toMatchInlineSnapshot(`
          <div>
            <button>
@@ -381,10 +373,9 @@ describe('Setup API', () => {
            </button>
            <div
              class="foo1
-         foo2 foo1 foo2 leave"
-             data-closed=""
-             data-enter=""
-             data-headlessui-state="closed enter transition"
+         foo2 leave"
+             data-headlessui-state="leave transition"
+             data-leave=""
              data-transition=""
              style=""
            >
diff --git a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts b/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
deleted file mode 100644
index fdbed2d..0000000
--- a/packages/@headlessui-react/src/components/transition/utils/transition.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { reportChanges } from '../../../test-utils/report-dom-node-changes'
-import { disposables } from '../../../utils/disposables'
-import { transition } from './transition'
-
-beforeEach(() => {
-  document.body.innerHTML = ''
-})
-
-it('should be possible to transition', async () => {
-  let d = disposables()
-
-  let snapshots: { content: string; recordedAt: bigint }[] = []
-  let element = document.createElement('div')
-  document.body.appendChild(element)
-
-  d.add(
-    reportChanges(
-      () => document.body.innerHTML,
-      (content) => {
-        snapshots.push({
-          content,
-          recordedAt: process.hrtime.bigint(),
-        })
-      }
-    )
-  )
-
-  await new Promise<void>((resolve) => {
-    transition(element, {
-      direction: 'enter', // Show
-      classes: {
-        base: [],
-        enter: ['enter'],
-        enterFrom: ['enterFrom'],
-        enterTo: ['enterTo'],
-        leave: [],
-        leaveFrom: [],
-        leaveTo: [],
-        entered: ['entered'],
-      },
-      done: resolve,
-    })
-  })
-
-  await new Promise((resolve) => d.nextFrame(resolve))
-
-  // Initial render:
-  expect(snapshots[0].content).toEqual('<div></div>')
-
-  // Start of transition
-  expect(snapshots[1].content).toEqual('<div style="" class="enter enterFrom"></div>')
-
-  // NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
-  // necessary to put the classes on the element and immediately remove them.
-
-  // Cleanup phase
-  expect(snapshots[2].content).toEqual('<div style="" class="entered enterTo enter"></div>')
-
-  d.dispose()
-})
-
-it('should wait the correct amount of time to finish a transition', async () => {
-  let d = disposables()
-
-  let snapshots: { content: string; recordedAt: bigint }[] = []
-  let element = document.createElement('div')
-  document.body.appendChild(element)
-
-  let duration = 20
-
-  element.style.transitionDuration = `${duration}ms`
-
-  d.add(
-    reportChanges(
-      () => document.body.innerHTML,
-      (content) => {
-        snapshots.push({
-          content,
-          recordedAt: process.hrtime.bigint(),
-        })
-      }
-    )
-  )
-
-  await new Promise<void>((resolve) => {
-    transition(element, {
-      direction: 'enter', // Show
-      classes: {
-        base: [],
-        enter: ['enter'],
-        enterFrom: ['enterFrom'],
-        enterTo: ['enterTo'],
-        leave: [],
-        leaveFrom: [],
-        leaveTo: [],
-        entered: ['entered'],
-      },
-      done: resolve,
-    })
-  })
-
-  await new Promise((resolve) => d.nextFrame(resolve))
-
-  // Initial render:
-  expect(snapshots[0].content).toEqual(`<div style="transition-duration: ${duration}ms;"></div>`)
-
-  // Start of transition
-  expect(snapshots[1].content).toEqual(
-    `<div style="transition-duration: ${duration}ms;" class="enter enterFrom"></div>`
-  )
-
-  expect(snapshots[2].content).toEqual(
-    `<div style="transition-duration: ${duration}ms;" class="enter enterTo"></div>`
-  )
-
-  let estimatedDuration = Number(
-    (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
-      BigInt(1e6)
-  )
-
-  expect(estimatedDuration).toBeWithinRenderFrame(duration)
-
-  // Cleanup phase
-  expect(snapshots[3].content).toEqual(
-    `<div style="transition-duration: ${duration}ms;" class="enterTo entered"></div>`
-  )
-})
-
-it('should keep the delay time into account', async () => {
-  let d = disposables()
-
-  let snapshots: { content: string; recordedAt: bigint }[] = []
-  let element = document.createElement('div')
-  document.body.appendChild(element)
-
-  let duration = 20
-  let delayDuration = 100
-
-  element.style.transitionDuration = `${duration}ms`
-  element.style.transitionDelay = `${delayDuration}ms`
-
-  d.add(
-    reportChanges(
-      () => document.body.innerHTML,
-      (content) => {
-        snapshots.push({
-          content,
-          recordedAt: process.hrtime.bigint(),
-        })
-      }
-    )
-  )
-
-  await new Promise<void>((resolve) => {
-    transition(element, {
-      direction: 'enter', // Show
-      classes: {
-        base: [],
-        enter: ['enter'],
-        enterFrom: ['enterFrom'],
-        enterTo: ['enterTo'],
-        leave: [],
-        leaveFrom: [],
-        leaveTo: [],
-        entered: ['entered'],
-      },
-      done: resolve,
-    })
-  })
-
-  await new Promise((resolve) => d.nextFrame(resolve))
-
-  let estimatedDuration = Number(
-    (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
-      BigInt(1e6)
-  )
-
-  expect(estimatedDuration).toBeWithinRenderFrame(duration + delayDuration)
-})