Repository: tailwindlabs/headlessui
Pull request #3307 (2024-06-20 15:28:11)

Add `transition` prop to `<Dialog />` component
This PR adds a `transition` prop to the `<Dialog />` component. Internally it uses the `<Transition />` and `<TransitionChild />` components to coordinate the transitions.

The `<Dialog />` itself will be wrapped in a `<Transition />`, the `<DialogPanel />` will be wrapped in a `<TransitionChild />`, and to make the backdrop transition happen we re-introduced the `<DialogBackdrop />` component which is a `div` wrapped in a `<TransitionChild />`.

Recently we introduced `data-*` attribute based transitions (#3273) to simplify transitions. This also introduced the `transition` prop on components such as:

- `ComboboxOptions`
- `DisclosurePanel`
- `ListboxOptions`
- `MenuItems`
- `PopoverPanel`

This PR now also adds it to the `<Dialog />` component. This is what a migration could look like:

```tsx
<Transition show={open}>
  <Dialog onClose={setOpen}>
    <TransitionChild
      enter="ease-in-out duration-300"
      enterFrom="opacity-0"
      enterTo="opacity-100"
      leave="ease-in-out duration-300"
      leaveFrom="opacity-100"
      leaveTo="opacity-0"
    >
      <div />
    </TransitionChild>

     <TransitionChild
       enter="ease-in-out duration-300"
       enterFrom="opacity-0 scale-95"
       enterTo="opacity-100 scale-100"
       leave="ease-in-out duration-300"
       leaveFrom="opacity-100 scale-100"
       leaveTo="opacity-0 scale-95"
     >
       <DialogPanel>
         {/* … */}
       </DialogPanel>
     </TransitionChild>
  </Dialog>
</Transition>
```

↓↓↓↓↓ Converted to use `data-*` attributes

```tsx
<Transition show={open}>
  <Dialog onClose={setOpen}>
    <TransitionChild>
      <div className="ease-in-out duration-300 data-[closed]:opacity-0 data-[closed]:scale-95" />
    </TransitionChild>

     <TransitionChild>
       <DialogPanel className="ease-in-out duration-300 data-[closed]:opacity-0 data-[closed]:scale-95 bg-white">
         {/* … */}
       </DialogPanel>
     </TransitionChild>
  </Dialog>
</Transition>
```

↓↓↓↓↓ Using the new `transition` prop

```tsx
<Dialog transition open={open} onClose={setOpen}>
  <DialogBackdrop className="ease-in-out duration-300 data-[closed]:opacity-0 data-[closed]:scale-95" />

  <DialogPanel className="ease-in-out duration-300 data-[closed]:opacity-0 data-[closed]:scale-95 bg-white">
    {/* … */}
  </DialogPanel>
</Dialog>
```


vercel[bot]: [vc]: #hHFvqsMVriUNRdunPYrgeR2luz/lmQ5mFC1j0cNlUyM=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJoZWFkbGVzc3VpLXZ1ZSIsInJvb3REaXJlY3RvcnkiOiJwbGF5Z3JvdW5kcy92dWUiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGFpbHdpbmRsYWJzL2hlYWRsZXNzdWktdnVlLzJLcVFtVTdnclpiYlpWamhCVll1OVN2WU12NVYiLCJwcmV2aWV3VXJsIjoiaGVhZGxlc3N1aS12dWUtZ2l0LWZlYXQtZGlhbG9nLXRyYW5zaXRpb24tcHJvcC10YWlsd2luZGxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImhlYWRsZXNzdWktdnVlLWdpdC1mZWF0LWRpYWxvZy10cmFuc2l0aW9uLXByb3AtdGFpbHdpbmRsYWJzLnZlcmNlbC5hcHAifX1dfQ==
**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-vue** | 🔄 Building ([Inspect](https://vercel.com/tailwindlabs/headlessui-vue/2KqQmU7grZbbZVjhBVYu9SvYMv5V)) | [Visit Preview](https://vercel.live/open-feedback/headlessui-vue-git-feat-dialog-transition-prop-tailwindlabs.vercel.app?via=pr-comment-visit-preview-link&passThrough=1) | 💬 [**Add feedback**](https://vercel.live/open-feedback/headlessui-vue-git-feat-dialog-transition-prop-tailwindlabs.vercel.app?via=pr-comment-feedback-link) | Jun 20, 2024 3:28pm |



Patch:
diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 4b7fe6d..f98ab27 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 - 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), [#3285](https://github.com/tailwindlabs/headlessui/pull/3285))
+- Add a `transition` prop to `<Dialog />` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
+- Re-introduce `<DialogBackdrop />` component ([#3307](https://github.com/tailwindlabs/headlessui/pull/3307))
 
 ### Fixed
 
@@ -21,7 +23,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))
+- Correctly apply conditional classes when using `<Transition />` and `<TransitionChild />` components ([#3303](https://github.com/tailwindlabs/headlessui/pull/3303))
 - Improve UX by freezing `ComboboxOptions` while closing ([#3304](https://github.com/tailwindlabs/headlessui/pull/3304))
 
 ### Changed
diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx
index 7c2f4dd..21b4d09 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx
@@ -2,6 +2,7 @@
 
 // WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/
 import React, {
+  Fragment,
   createContext,
   createRef,
   useContext,
@@ -49,6 +50,9 @@ import {
 } from '../description/description'
 import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
 import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'
+import { Transition, TransitionChild } from '../transition/transition'
+
+let WithTransitionWrapper = createContext(false)
 
 enum DialogStates {
   Open,
@@ -126,6 +130,7 @@ export type DialogProps<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG> =
     role?: 'dialog' | 'alertdialog'
     autoFocus?: boolean
     __demoMode?: boolean
+    transition?: boolean
   }
 >
 
@@ -141,6 +146,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
     initialFocus,
     role = 'dialog',
     autoFocus = true,
+    transition = false,
     __demoMode = false,
     ...theirProps
   } = props
@@ -337,6 +343,17 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
     }
   }
 
+  if (transition) {
+    let { transition: _transition, open, ...rest } = props
+    return (
+      <WithTransitionWrapper.Provider value={true}>
+        <Transition show={open}>
+          <Dialog ref={ref} {...rest} />
+        </Transition>
+      </WithTransitionWrapper.Provider>
+    )
+  }
+
   return (
     <>
       <ForcePortalRoot force={true}>
@@ -416,13 +433,62 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
     onClick: handleClick,
   }
 
-  return render({
-    ourProps,
-    theirProps,
-    slot,
-    defaultTag: DEFAULT_PANEL_TAG,
-    name: 'Dialog.Panel',
-  })
+  let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
+
+  return (
+    <WithTransitionWrapper.Provider value={false}>
+      <Wrapper>
+        {render({
+          ourProps,
+          theirProps,
+          slot,
+          defaultTag: DEFAULT_PANEL_TAG,
+          name: 'Dialog.Panel',
+        })}
+      </Wrapper>
+    </WithTransitionWrapper.Provider>
+  )
+}
+
+// ---
+
+let DEFAULT_BACKDROP_TAG = 'div' as const
+type BackdropRenderPropArg = {
+  open: boolean
+}
+
+export type DialogBackdropProps<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG> = Props<
+  TTag,
+  BackdropRenderPropArg
+>
+
+function BackdropFn<TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
+  props: DialogBackdropProps<TTag>,
+  ref: Ref<HTMLElement>
+) {
+  let theirProps = props
+  let [{ dialogState }] = useDialogContext('Dialog.Backdrop')
+
+  let slot = useMemo(
+    () => ({ open: dialogState === DialogStates.Open }) satisfies BackdropRenderPropArg,
+    [dialogState]
+  )
+
+  let ourProps = { ref }
+
+  let Wrapper = useContext(WithTransitionWrapper) ? TransitionChild : Fragment
+
+  return (
+    <Wrapper>
+      {render({
+        ourProps,
+        theirProps,
+        slot,
+        defaultTag: DEFAULT_BACKDROP_TAG,
+        name: 'Dialog.Backdrop',
+      })}
+    </Wrapper>
+  )
 }
 
 // ---
@@ -482,6 +548,12 @@ export interface _internal_ComponentDialogPanel extends HasDisplayName {
   ): JSX.Element
 }
 
+export interface _internal_ComponentDialogBackdrop extends HasDisplayName {
+  <TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG>(
+    props: DialogBackdropProps<TTag> & RefProp<typeof BackdropFn>
+  ): JSX.Element
+}
+
 export interface _internal_ComponentDialogTitle extends HasDisplayName {
   <TTag extends ElementType = typeof DEFAULT_TITLE_TAG>(
     props: DialogTitleProps<TTag> & RefProp<typeof TitleFn>
@@ -492,6 +564,7 @@ export interface _internal_ComponentDialogDescription extends _internal_Componen
 
 let DialogRoot = forwardRefWithAs(DialogFn) as _internal_ComponentDialog
 export let DialogPanel = forwardRefWithAs(PanelFn) as _internal_ComponentDialogPanel
+export let DialogBackdrop = forwardRefWithAs(BackdropFn) as _internal_ComponentDialogBackdrop
 export let DialogTitle = forwardRefWithAs(TitleFn) as _internal_ComponentDialogTitle
 /** @deprecated use `<Description>` instead of `<DialogDescription>` */
 export let DialogDescription = Description as _internal_ComponentDialogDescription
diff --git a/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx b/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx
new file mode 100644
index 0000000..a5de69e
--- /dev/null
+++ b/playgrounds/react/pages/dialog/dialog-built-in-transition.tsx
@@ -0,0 +1,47 @@
+import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
+import { useState } from 'react'
+import { Button } from '../../components/button'
+import { classNames } from '../../utils/class-names'
+
+export default function Home() {
+  let [isOpen, setIsOpen] = useState(false)
+  let [transition, setTransition] = useState(true)
+
+  return (
+    <>
+      <div className="flex gap-4 p-12">
+        <Button onClick={() => setIsOpen((v) => !v)}>Toggle!</Button>
+        <Button onClick={() => setTransition((v) => !v)}>
+          <span>Toggle transition</span>
+          <span
+            className={classNames(
+              'ml-2 inline-flex size-4 rounded-md',
+              transition ? 'bg-green-500' : 'bg-red-500'
+            )}
+          ></span>
+        </Button>
+      </div>
+
+      <Dialog
+        open={isOpen}
+        transition={transition}
+        onClose={() => setIsOpen(false)}
+        className="relative z-50"
+      >
+        <DialogBackdrop className="fixed inset-0 bg-black/30 duration-500 ease-out data-[closed]:opacity-0" />
+        <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
+          <DialogPanel className="w-full max-w-lg space-y-4 bg-white p-12 duration-500 ease-out data-[closed]:scale-95 data-[closed]:opacity-0">
+            <h1 className="text-2xl font-bold">Dialog</h1>
+            <p>
+              Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed pulvinar, nunc nec
+              vehicula fermentum, nunc sapien tristique ipsum, nec facilisis dolor sapien non dui.
+              Nullam vel sapien ultrices, lacinia felis sit amet, fermentum odio. Nullam vel sapien
+              ultrices, lacinia felis sit amet, fermentum odio.
+            </p>
+            <Button onClick={() => setIsOpen(false)}>Close</Button>
+          </DialogPanel>
+        </div>
+      </Dialog>
+    </>
+  )
+}


Test patch:
diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts
index ea86956..bcce250 100644
--- a/packages/@headlessui-react/src/index.test.ts
+++ b/packages/@headlessui-react/src/index.test.ts
@@ -24,6 +24,7 @@ it('should expose the correct components', () => {
     'Description',
 
     'Dialog',
+    'DialogBackdrop',
     'DialogDescription',
     'DialogPanel',
     'DialogTitle',