Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
56210d2
Initial plan
Copilot Mar 18, 2026
030c974
Initial plan
Copilot Mar 18, 2026
bebed8d
Update to use @starting-style for entry animation
Copilot Mar 18, 2026
0723392
Update modal to use native dialog element with showModal()
Copilot Mar 18, 2026
812bdae
Address native dialog review feedback
Copilot Oct 23, 2026
643511f
Synchronize native dialog with modal state
Copilot Oct 23, 2026
61c0aa8
fix: allow nullable dialog refs for React 19
marlonmarcello Oct 23, 2026
d9c72e8
fix: backdrop color
marlonmarcello Oct 23, 2026
b2838e4
Simplify modal with native dialog backdrop
Copilot Oct 23, 2026
a9bdfa3
Use dynamic viewport units for dialog sizing
Copilot Oct 23, 2026
85d8b0f
Handle backdrop closing inside Modal
Copilot Oct 23, 2026
3a3b022
Merge remote-tracking branch 'origin/copilot/update-dialog-element-sh…
Copilot Oct 23, 2026
4dba8c2
Merge remote-tracking branch 'origin/main' into copilot/update-entry-…
Copilot Oct 26, 2026
7f18a11
Keep native dialog mounted for CSS exits
Copilot Oct 26, 2026
828b390
Merge branch 'main' into copilot/update-entry-animation-starting-style
marlonmarcello Oct 26, 2026
84c66a3
fix: update CustomModalTransition for opacity transition
marlonmarcello Oct 26, 2026
014aa13
docs: add minor changeset for CSS transitions
Copilot Oct 26, 2026
ff57956
Merge remote-tracking branch 'origin/main' into copilot/update-entry-…
Copilot Oct 26, 2026
0eaedd6
Merge remote-tracking branch 'origin/main' into copilot/update-entry-…
Copilot Oct 27, 2026
acc94e5
fix: avoid state update in demo effect
Copilot Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-chairs-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@wethegit/react-modal": minor
---

Add native CSS entry and exit transitions
1 change: 1 addition & 0 deletions .stylelintrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
],
"plugins": ["stylelint-selector-bem-pattern"],
"rules": {
"at-rule-no-unknown": [true, { "ignoreAtRules": ["starting-style"] }],
"no-descending-specificity": null,
"selector-class-pattern": null,
"plugin/selector-bem-pattern": {
Expand Down
102 changes: 71 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import "@wethegit/react-modal/style.css"

The `useModal` hook provides mechanisms for hooking into the modal state. Pass its `dialogRef` to `<Modal>` so the hook can control that specific dialog's native modality. This approach allows us to abstract away state management, which becomes especially useful when [using a URL hash to manage the modal's state](#using-a-url-hash-to-manage-the-modal-state).

The snippet below demonstrates the **minimum** required setup. No focus management, no animations.
The snippet below demonstrates the **minimum** required setup. Keep `<Modal>` mounted and pass `isOpen` so the native dialog can close without being unmounted.

```jsx
import {
Expand All @@ -45,14 +45,12 @@ function MyModal() {
Open the modal window!
</button>

{isOpen && (
<Modal dialogRef={dialogRef}>
<ModalContent>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<Modal dialogRef={dialogRef} isOpen={isOpen}>
<ModalContent>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}
Expand Down Expand Up @@ -80,14 +78,12 @@ function MyModal() {
Open the modal window!
</button>

{isOpen && (
<Modal dialogRef={dialogRef}>
<ModalContent>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<Modal dialogRef={dialogRef} isOpen={isOpen}>
<ModalContent>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}
Expand All @@ -97,7 +93,7 @@ function MyModal() {

Focus management, rendering to a custom element, and hash-based state management.

**Important:** Animation with `useAnimatePresence` is not included in this refactor. Support is deferred to [#155](https://github.com/wethegit/react-modal/issues/155). Render the modal conditionally with `isOpen`, without delaying unmount for an exit animation.
The [`@starting-style`](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style) CSS at-rule lets you define entry animations with pure CSS. Keep `<Modal>` mounted and pass `isOpen` to let the native dialog close normally; in supporting browsers, CSS can animate its exit using discrete transitions for `display` and `overlay`.

```jsx
import { useRef } from 'react'
Expand All @@ -107,6 +103,8 @@ import {
useModal
} from "@wethegit/react-modal"

import styles from "./my-styles.module.css"

function MyModal() {
const triggerButton = useRef(null)
const modalRootRef = useRef(null)
Expand All @@ -125,24 +123,65 @@ function MyModal() {
</button>
<div ref={modalRootRef}></div>

{isOpen && modalRootRef.current && (
<Modal
dialogRef={dialogRef}
renderTo={modalRootRef.current}
>
<ModalContent>
<button onClick={toggle}>
Close
</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<Modal
dialogRef={dialogRef}
isOpen={isOpen}
renderTo={modalRootRef.current ?? undefined}
className={styles.modalOverlay}
>
<ModalContent className={styles.modal}>
<button onClick={toggle}>
Close
</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}
```

```css
/* my-styles.module.css */
.modalOverlay::backdrop {
background: rgb(0 0 0 / 80%);
}

.modalOverlay {
opacity: 0;
transition:
opacity 0.5s ease,
display 0.5s allow-discrete,
overlay 0.5s allow-discrete;
}

.modalOverlay[open] {
opacity: 1;
}

.modal {
transition: opacity 0.5s ease, transform 0.5s ease;
opacity: 1;
transform: scale(1);
}

.modalOverlay:not([open]) .modal {
opacity: 0;
transform: scale(0.9);
}

@starting-style {
.modalOverlay[open] {
opacity: 0;
}

.modal {
opacity: 0;
transform: scale(0.9);
}
}
```

## Props

### `<Modal>`
Expand All @@ -152,6 +191,7 @@ function MyModal() {
| renderTo | HTMLElement | null | If a valid HTMLElement is provided, the modal will be appended to that element. Otherwise will be rendered in place. |
| className | String | null | |
| dialogRef | RefObject | null | The ref returned by `useModal`, used to associate the hook with this specific native dialog. |
| isOpen | Boolean | — | Controls the native dialog's open state. Keep `<Modal>` mounted to allow CSS exit transitions. |
| children | ReactNode | null | |

The native dialog backdrop is styled with the `::backdrop` pseudo-element. Clicking the backdrop closes the modal automatically.
Expand Down
18 changes: 15 additions & 3 deletions src/lib/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export interface ModalProps
* The modal will be appended to the passed element instead of being rendered in place
* @defaultValue defaults inPlace
**/
renderTo: HTMLElement
renderTo?: HTMLElement
/**
* Controls whether the native dialog is open. When omitted, the dialog opens on mount.
*/
isOpen?: boolean
/**
* A ref to the native dialog element.
*/
Expand All @@ -23,6 +27,7 @@ export interface ModalProps

export function Modal({
renderTo,
isOpen,
dialogRef: forwardedDialogRef,
className,
onCancel,
Expand All @@ -41,8 +46,15 @@ export function Modal({
const dialogRef = forwardedDialogRef ?? internalDialogRef

useEffect(() => {
if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal()
}, [dialogRef, renderTo])
const dialog = dialogRef.current
if (!dialog) return

if (isOpen === false) {
if (dialog.open) dialog.close()
} else if (!dialog.open) {
dialog.showModal()
}
}, [dialogRef, isOpen, renderTo])

const isOutsideContent = (event: React.PointerEvent<HTMLDialogElement>) => {
const path = event.nativeEvent.composedPath()
Expand Down
30 changes: 30 additions & 0 deletions src/main.module.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
.CustomModalOverlay::backdrop {
background-color: rgb(125 125 125 / 80%);
transition: background-color 0.5s ease;
}

.CustomModalOverlay {
opacity: 0;
transition: opacity 0.5s ease, display 0.5s allow-discrete, overlay 0.5s allow-discrete;
}

.CustomModalOverlay[open] {
opacity: 1;
}

.CustomModalOverlay:not([open]) .CustomModalTransition {
opacity: 0;
transform: scale(0);
}

.CustomModalContent {
Expand All @@ -10,3 +25,18 @@
padding: 2rem;
font-family: sans-serif;
}

.CustomModalTransition {
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.77, 0, 0.1, 1.39);
transform: scale(1);
}

@starting-style {
.CustomModalOverlay[open] {
opacity: 0;
}

.CustomModalTransition {
transform: scale(0);
}
}
90 changes: 39 additions & 51 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from "react"
import React, { useRef, useState } from "react"
import { createRoot } from "react-dom/client"

import { Modal, ModalContent, useModal } from "./lib"
Expand All @@ -7,7 +7,7 @@ import styles from "./main.module.css"

function CustomModal() {
const triggerButton = useRef(null)
const modalRootRef = useRef(null)
const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null)
const { isOpen, toggle, dialogRef } = useModal({
triggerRef: triggerButton,
})
Expand All @@ -18,65 +18,56 @@ function CustomModal() {
<button ref={triggerButton} onClick={toggle}>
Open the modal (HTMLElement)
</button>
<div ref={modalRootRef}></div>
<div ref={setModalRoot}></div>

{isOpen && modalRootRef.current && (
<Modal
renderTo={modalRootRef.current}
dialogRef={dialogRef}
className={styles.CustomModalOverlay}
<Modal
renderTo={modalRoot ?? undefined}
dialogRef={dialogRef}
isOpen={isOpen}
className={styles.CustomModalOverlay}
>
<ModalContent
className={`${styles.CustomModalContent} ${styles.CustomModalTransition}`}
>
<ModalContent className={styles.CustomModalContent}>
<button onClick={toggle}>
Close
</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}

function CustomModalOnBody() {
const triggerButton = useRef(null)
const bodyRef = useRef<HTMLElement | null>(null)
const { isOpen, toggle, dialogRef } = useModal({
triggerRef: triggerButton,
})

useEffect(() => {
bodyRef.current = document.body
}, [])

return (
<>
{/* `triggerRef` allows the focus to shift to whatever triggered the modal, on close. */}
<button ref={triggerButton} onClick={toggle}>
Open the modal (Append to body)
</button>

{isOpen && bodyRef.current && (
<Modal
renderTo={bodyRef.current}
dialogRef={dialogRef}
className={styles.CustomModalOverlay}
>
<ModalContent className={styles.CustomModalContent}>
<button onClick={toggle}>
Close
</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<Modal
renderTo={document.body}
dialogRef={dialogRef}
isOpen={isOpen}
className={styles.CustomModalOverlay}
>
<ModalContent className={styles.CustomModalContent}>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}

function ModalWithHash() {
const triggerButton = useRef(null)
const modalRootRef = useRef(null)
const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null)
const { isOpen, toggle, dialogRef } = useModal({
triggerRef: triggerButton,
hash: "modal-with-hash",
Expand All @@ -87,22 +78,19 @@ function ModalWithHash() {
<button ref={triggerButton} onClick={toggle}>
Open the modal using a hash
</button>
<div ref={modalRootRef}></div>
<div ref={setModalRoot}></div>

{isOpen && modalRootRef.current && (
<Modal
renderTo={modalRootRef.current}
dialogRef={dialogRef}
className={styles.CustomModalOverlay}
>
<ModalContent className={styles.CustomModalContent}>
<button onClick={toggle}>
Close
</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
)}
<Modal
renderTo={modalRoot ?? undefined}
dialogRef={dialogRef}
isOpen={isOpen}
className={styles.CustomModalOverlay}
>
<ModalContent className={styles.CustomModalContent}>
<button onClick={toggle}>Close</button>
<p>Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.</p>
</ModalContent>
</Modal>
</>
)
}
Expand Down
Loading