From 56210d2104c43872541b401be91dbab268f9912c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:24:34 +0000 Subject: [PATCH 01/15] Initial plan From 030c974c58b1692abb8c66c6f7434564221e7ce2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:24:52 +0000 Subject: [PATCH 02/15] Initial plan From bebed8d8c5a76179b3062f8a7345ef36af2cd57e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:30:28 +0000 Subject: [PATCH 03/15] Update to use @starting-style for entry animation Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- .stylelintrc | 1 + README.md | 37 ++++++++++++++++++++++--------------- package-lock.json | 4 ++-- src/main.module.css | 12 ++++++------ src/main.tsx | 7 ++++++- 5 files changed, 37 insertions(+), 24 deletions(-) diff --git a/.stylelintrc b/.stylelintrc index 34563f5..5bf836c 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -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": { diff --git a/README.md b/README.md index a4001c7..d92c3cf 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,10 @@ function MyModal() { Custom transition, focus management and hash-based state management. -Use your favorite animation library, [@wethegit/react-hooks](https://wethegit.github.io/react-hooks/use-animate-presence) provides a simple one for these cases. +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 — no extra JavaScript or animation libraries needed. ```jsx import { useRef } from 'react' -import { useAnimatePresence } from '@wethegit/react-hooks' import { Modal, ModalContent, @@ -113,6 +112,8 @@ import { useModal } from "@wethegit/react-modal" +import styles from "./my-styles.module.css" + function MyModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) @@ -124,11 +125,6 @@ function MyModal() { hash: "modal-with-hash", }) - const { render, animate } = useAnimatePresence({ - isVisible: isOpen, - duration: 800 - }) - return ( <>
- {render && modalRootRef.current && ( - + {isOpen && modalRootRef.current && ( + - + @@ -157,6 +148,22 @@ function MyModal() { } ``` +```css +/* my-styles.module.css */ +.modal { + transition: opacity 0.5s ease, transform 0.5s ease; + opacity: 1; + transform: scale(1); +} + +@starting-style { + .modal { + opacity: 0; + transform: scale(0.9); + } +} +``` + ## Props ### `` diff --git a/package-lock.json b/package-lock.json index baa2a9f..d011b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "@changesets/changelog-github": "~0.5.0", diff --git a/src/main.module.css b/src/main.module.css index 4651be0..30b73c2 100644 --- a/src/main.module.css +++ b/src/main.module.css @@ -22,14 +22,14 @@ } .CustomModalTransition { - transition: transform var(--transition-duration) - var(--ease, cubic-bezier(0.77, 0, 0.1, 1.39)); - transform: scale(var(--scale, 0)); + transition: transform 0.5s cubic-bezier(0.77, 0, 0.1, 1.39); + transform: scale(1); } -.CustomModalTransitionOpen { - --ease: cubic-bezier(0.77, 0, 0.1, 1.39); - --scale: 1; +@starting-style { + .CustomModalTransition { + transform: scale(0); + } } .CustomModalAbsolute { diff --git a/src/main.tsx b/src/main.tsx index c71a5b2..fbbfeea 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -24,7 +24,12 @@ function CustomModal() { {isOpen && modalRootRef.current && ( - + From 0723392df3c4b98e81587faefda660123a337e35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:35:13 +0000 Subject: [PATCH 04/15] Update modal to use native dialog element with showModal() Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- README.md | 4 +- package-lock.json | 4 +- .../components/modal-focus-bounds/index.tsx | 1 - .../modal-focus-bounds.module.scss | 5 -- .../modal-focus-bounds/modal-focus-bounds.tsx | 10 ---- .../modal-inner/modal-inner.module.scss | 8 ++- .../components/modal-inner/modal-inner.tsx | 51 +++++++------------ .../modal-visually-hidden/index.tsx | 1 - .../modal-visually-hidden.module.scss | 10 ---- .../modal-visually-hidden.tsx | 15 ------ src/lib/components/modal/modal.module.scss | 7 --- src/lib/components/modal/modal.tsx | 8 +-- 12 files changed, 30 insertions(+), 94 deletions(-) delete mode 100644 src/lib/components/modal-focus-bounds/index.tsx delete mode 100644 src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss delete mode 100644 src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx delete mode 100644 src/lib/components/modal-visually-hidden/index.tsx delete mode 100644 src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss delete mode 100644 src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx delete mode 100644 src/lib/components/modal/modal.module.scss diff --git a/README.md b/README.md index a4001c7..481e320 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,9 @@ You can override these as you see fit and as your setup allows. This component was built with focus accessibility best-practices at top-of-mind, and provides enough flexibility to allow you to create an accessible modal window. -### Focus loop +### Focus trap -There are hidden elements at the start and end of the modal component, which, on focus, shift the user's focus to either the end or start of the content, respectively. +The modal uses the native `` element with the `showModal()` method, which automatically traps focus within the dialog. This ensures keyboard navigation stays within the modal while it is open. ### Focus on close diff --git a/package-lock.json b/package-lock.json index baa2a9f..d011b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@wethegit/react-modal", - "version": "3.1.0", + "version": "3.2.0", "license": "MIT", "dependencies": { "@changesets/changelog-github": "~0.5.0", diff --git a/src/lib/components/modal-focus-bounds/index.tsx b/src/lib/components/modal-focus-bounds/index.tsx deleted file mode 100644 index e5f7b60..0000000 --- a/src/lib/components/modal-focus-bounds/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ModalFocusBounds } from "./modal-focus-bounds" diff --git a/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss b/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss deleted file mode 100644 index 8327c0a..0000000 --- a/src/lib/components/modal-focus-bounds/modal-focus-bounds.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.modalFocusBounds { - // This applies to the hidden element(s) that help manage user focus. - box-shadow: none; - outline: none; -} diff --git a/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx b/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx deleted file mode 100644 index 660dac0..0000000 --- a/src/lib/components/modal-focus-bounds/modal-focus-bounds.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { forwardRef } from "react" - -import styles from "./modal-focus-bounds.module.scss" - -export const ModalFocusBounds = forwardRef(function ModalFocusBounds( - _, - ref -) { - return
-}) diff --git a/src/lib/components/modal-inner/modal-inner.module.scss b/src/lib/components/modal-inner/modal-inner.module.scss index 2864462..94e207f 100644 --- a/src/lib/components/modal-inner/modal-inner.module.scss +++ b/src/lib/components/modal-inner/modal-inner.module.scss @@ -1,8 +1,12 @@ .modalInner { align-items: center; + background: transparent; + border: none; display: flex; - inset: 0; + height: 100%; justify-content: center; + max-height: 100%; + max-width: 100%; + padding: 0; width: 100%; - z-index: 1; } diff --git a/src/lib/components/modal-inner/modal-inner.tsx b/src/lib/components/modal-inner/modal-inner.tsx index ae3e826..75bc78a 100644 --- a/src/lib/components/modal-inner/modal-inner.tsx +++ b/src/lib/components/modal-inner/modal-inner.tsx @@ -2,12 +2,9 @@ import { useEffect, useRef } from "react" import { classnames } from "../../../utils/classnames" -import { ModalVisuallyHidden } from "../modal-visually-hidden" -import { ModalFocusBounds } from "../modal-focus-bounds" - import styles from "./modal-inner.module.scss" -export interface ModalInnerProps extends React.HTMLAttributes { +export interface ModalInnerProps extends React.HTMLAttributes { /** * The content of the modal. */ @@ -16,45 +13,33 @@ export interface ModalInnerProps extends React.HTMLAttributes { * The className of the modal. */ className?: string + /** + * Called when the cancel event fires (e.g. user presses Escape). + * The default browser close behaviour is always prevented so React state stays in control. + */ + onCancel?: React.ReactEventHandler } -export function ModalInner({ children, className, ...props }: ModalInnerProps) { - const modalRef = useRef(null) - const firstFocusableElement = useRef(null) - const lastFocusableElement = useRef(null) - - const focusStartingPosition = () => { - const element = firstFocusableElement.current - if (element) element.focus() - } - - const focusEndingPosition = () => { - const element = lastFocusableElement.current - if (element) element.focus() - } +export function ModalInner({ children, className, onCancel, ...props }: ModalInnerProps) { + const dialogRef = useRef(null) useEffect(() => { - focusStartingPosition() + dialogRef.current?.showModal() }, []) return ( -
{ + // Prevent the browser from closing the dialog so our React state + // remains in control. The useModal hook handles closing via Escape. + e.preventDefault() + onCancel?.(e) + }} > - - - - {children} - - - - -
+
) } diff --git a/src/lib/components/modal-visually-hidden/index.tsx b/src/lib/components/modal-visually-hidden/index.tsx deleted file mode 100644 index 2f2e5a0..0000000 --- a/src/lib/components/modal-visually-hidden/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ModalVisuallyHidden } from "./modal-visually-hidden" diff --git a/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss b/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss deleted file mode 100644 index 5205dac..0000000 --- a/src/lib/components/modal-visually-hidden/modal-visually-hidden.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.modalVisuallyHidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} diff --git a/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx b/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx deleted file mode 100644 index 0edce2b..0000000 --- a/src/lib/components/modal-visually-hidden/modal-visually-hidden.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { forwardRef } from "react" - -import styles from "./modal-visually-hidden.module.scss" - -export interface ModalVisuallyHiddenProps { - onFocus?: () => void -} - -export const ModalVisuallyHidden = forwardRef( - function ModalVisuallyHidden(props: ModalVisuallyHiddenProps, ref) { - return ( -
- ) - } -) diff --git a/src/lib/components/modal/modal.module.scss b/src/lib/components/modal/modal.module.scss deleted file mode 100644 index 5c39867..0000000 --- a/src/lib/components/modal/modal.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.ModalFixed { - position: fixed; -} - -.ModalAbsolute { - position: absolute; -} diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index 1d978c5..5169701 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -3,9 +3,7 @@ import ReactDOM from "react-dom" import { ModalInner } from "../modal-inner" import type { ModalInnerProps } from "../modal-inner" -import { classnames } from "../../../utils/classnames" -import styles from "./modal.module.scss" export interface ModalProps extends ModalInnerProps { /** * The modal will be appended to the passed element instead of being rendered in place @@ -14,10 +12,8 @@ export interface ModalProps extends ModalInnerProps { renderTo: HTMLElement } -export function Modal({ renderTo, className, ...props }: ModalProps) { - const classes = classnames([styles.ModalFixed, className]) - - const modalContent = +export function Modal({ renderTo, ...props }: ModalProps) { + const modalContent = if (renderTo) { return ReactDOM.createPortal(modalContent, renderTo) From 812bdae050e94615e19ee8096e5b2ae6d4a523b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:24:40 +0000 Subject: [PATCH 05/15] Address native dialog review feedback --- src/lib/components/modal-inner/modal-inner.module.scss | 5 +++++ src/lib/hooks/use-modal.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/modal-inner/modal-inner.module.scss b/src/lib/components/modal-inner/modal-inner.module.scss index 94e207f..b75e37a 100644 --- a/src/lib/components/modal-inner/modal-inner.module.scss +++ b/src/lib/components/modal-inner/modal-inner.module.scss @@ -2,6 +2,7 @@ align-items: center; background: transparent; border: none; + color: inherit; display: flex; height: 100%; justify-content: center; @@ -10,3 +11,7 @@ padding: 0; width: 100%; } + +.modalInner::backdrop { + background: transparent; +} diff --git a/src/lib/hooks/use-modal.ts b/src/lib/hooks/use-modal.ts index e25ef76..4b98246 100644 --- a/src/lib/hooks/use-modal.ts +++ b/src/lib/hooks/use-modal.ts @@ -28,7 +28,7 @@ export function useModal(props: UseModalOptions = {}) { // an extra dependecy and stay within the render loop current = cur - if (cur !== ModalStates.CLOSED) return ModalStates.CLOSED + if (cur !== ModalStates.CLOSED) return ModalStates.CLOSED return cur }) @@ -41,6 +41,8 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } + document.querySelector("dialog:modal")?.close() + if (triggerRef && triggerRef.current) triggerRef.current.focus() }, [triggerRef, hash]) From 643511f25e4015ac98e7c279f83cb9e4b0e8fd1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:39:30 +0000 Subject: [PATCH 06/15] Synchronize native dialog with modal state --- README.md | 16 +++++++++------- .../components/modal-inner/modal-inner.tsx | 19 +++++++++++++++---- src/lib/hooks/use-modal.ts | 13 ++++++++++--- src/main.tsx | 16 ++++++++++------ 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 481e320..70a69f3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ import "@wethegit/react-modal/style.css" ## Usage -The `useModal` hook provides mechanisms for hooking into the modal state. We'll need to pass said `state` to the `` component, as shown below. 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 `useModal` hook provides mechanisms for hooking into the modal state. Pass its `dialogRef` to `` 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. @@ -38,7 +38,7 @@ import { } from "@wethegit/react-modal" function MyModal() { - const { isOpen, toggle } = useModal() + const { isOpen, toggle, dialogRef } = useModal() return ( <> @@ -47,7 +47,7 @@ function MyModal() { {isOpen && ( - + @@ -73,7 +73,7 @@ import { } from "@wethegit/react-modal" function MyModal() { - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ hash: "my-modal-hash" }) @@ -84,7 +84,7 @@ function MyModal() { {isOpen && ( - + @@ -117,7 +117,7 @@ function MyModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ // `triggerRef` allows the focus to shift to whatever triggered the modal triggerRef: triggerButton, // `hash` will automatically open the modal when hash changes and will also update the hash when the modal opens/closes @@ -137,7 +137,8 @@ function MyModal() {
{render && modalRootRef.current && ( - ` diff --git a/src/lib/components/modal-inner/modal-inner.tsx b/src/lib/components/modal-inner/modal-inner.tsx index 75bc78a..3c399f5 100644 --- a/src/lib/components/modal-inner/modal-inner.tsx +++ b/src/lib/components/modal-inner/modal-inner.tsx @@ -13,6 +13,10 @@ export interface ModalInnerProps extends React.HTMLAttributes * The className of the modal. */ className?: string + /** + * A ref to the native dialog element. + */ + dialogRef?: React.RefObject /** * Called when the cancel event fires (e.g. user presses Escape). * The default browser close behaviour is always prevented so React state stays in control. @@ -20,12 +24,19 @@ export interface ModalInnerProps extends React.HTMLAttributes onCancel?: React.ReactEventHandler } -export function ModalInner({ children, className, onCancel, ...props }: ModalInnerProps) { - const dialogRef = useRef(null) +export function ModalInner({ + children, + className, + dialogRef: forwardedDialogRef, + onCancel, + ...props +}: ModalInnerProps) { + const internalDialogRef = useRef(null) + const dialogRef = forwardedDialogRef ?? internalDialogRef useEffect(() => { - dialogRef.current?.showModal() - }, []) + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() + }, [dialogRef]) return ( (ModalStates.CLOSED) + const dialogRef = useRef(null) const handleClose = useCallback(() => { let current: ModalStates | undefined @@ -41,7 +42,7 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } - document.querySelector("dialog:modal")?.close() + if (dialogRef.current?.open) dialogRef.current.close() if (triggerRef && triggerRef.current) triggerRef.current.focus() }, [triggerRef, hash]) @@ -58,7 +59,12 @@ export function useModal(props: UseModalOptions = {}) { return cur }) - if (current === ModalStates.OPEN) return + if (current === ModalStates.OPEN) { + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() + return + } + + if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() if (hash && window && window.location.hash !== `#${hash}`) { window.location.hash = `#${hash}` @@ -105,6 +111,7 @@ export function useModal(props: UseModalOptions = {}) { return { isOpen: state === ModalStates.OPEN, + dialogRef, toggle, } } diff --git a/src/main.tsx b/src/main.tsx index c71a5b2..17dd241 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,7 +9,7 @@ import styles from "./main.module.css" function CustomModal() { const triggerButton = useRef(null) const modalRootRef = useRef(null) - const { isOpen, toggle } = useModal({ + const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) @@ -22,7 +22,7 @@ function CustomModal() {
{isOpen && modalRootRef.current && ( - + {isOpen && bodyRef.current && ( - + {isOpen && ( - - + event.target === event.currentTarget && toggle()} + >

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -68,7 +69,6 @@ It's as easy as passing a `hash` prop to the `useModal` hook. The hook will then import { Modal, ModalContent, - ModalBackdrop, useModal } from "@wethegit/react-modal" @@ -84,8 +84,10 @@ function MyModal() { {isOpen && ( - - + event.target === event.currentTarget && toggle()} + >

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -109,7 +111,6 @@ import { useAnimatePresence } from '@wethegit/react-hooks' import { Modal, ModalContent, - ModalBackdrop, useModal } from "@wethegit/react-modal" @@ -140,11 +141,11 @@ function MyModal() { event.target === event.currentTarget && toggle()} style={{ transition: `opacity 800ms ease-in-out`, opacity: animate ? 1 : 0 }}> -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -57,12 +60,12 @@ function CustomModalOnBody() { {isOpen && bodyRef.current && ( event.target === event.currentTarget && toggle()} > - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -89,10 +92,14 @@ function ModalWithHash() {
{isOpen && modalRootRef.current && ( - - + event.target === event.currentTarget && toggle()} + > -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

From a9bdfa31142e5c87ac3761831be2334b21e6bb0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:16:18 +0000 Subject: [PATCH 10/15] Use dynamic viewport units for dialog sizing Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- src/lib/components/modal/modal.module.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/lib/components/modal/modal.module.scss b/src/lib/components/modal/modal.module.scss index 3dcf666..0b18f6f 100644 --- a/src/lib/components/modal/modal.module.scss +++ b/src/lib/components/modal/modal.module.scss @@ -13,6 +13,15 @@ width: 100vw; } +@supports (height: 100dvh) and (width: 100dvw) { + .modal { + height: 100dvh; + max-height: 100dvh; + max-width: 100dvw; + width: 100dvw; + } +} + .modal:not([open]) { display: none; } From 85d8b0faa629ce878806257f20be487c3ae6293b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:20:20 +0000 Subject: [PATCH 11/15] Handle backdrop closing inside Modal Co-authored-by: marlonmarcello <1956448+marlonmarcello@users.noreply.github.com> --- README.md | 13 +++------- src/lib/components/modal/modal.tsx | 9 +++++++ src/lib/hooks/use-modal.ts | 39 +++++++++++++++++++++++------- src/main.tsx | 3 --- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7e1440d..76af0c8 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,7 @@ function MyModal() { {isOpen && ( - event.target === event.currentTarget && toggle()} - > +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -84,10 +81,7 @@ function MyModal() { {isOpen && ( - event.target === event.currentTarget && toggle()} - > +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

@@ -141,7 +135,6 @@ function MyModal() { event.target === event.currentTarget && toggle()} style={{ transition: `opacity 800ms ease-in-out`, opacity: animate ? 1 : 0 @@ -170,7 +163,7 @@ function MyModal() { | dialogRef | RefObject | null | The ref returned by `useModal`, used to associate the hook with this specific native dialog. | | children | ReactNode | null | | -The native dialog backdrop is styled with the `::backdrop` pseudo-element. Clicking outside the dialog content can be handled by checking whether the click event target is the dialog itself, as shown in the examples. +The native dialog backdrop is styled with the `::backdrop` pseudo-element. Clicking the backdrop closes the modal automatically. ### `` diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index 217e405..90cfcf9 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -23,6 +23,7 @@ export function Modal({ dialogRef: forwardedDialogRef, className, onCancel, + onClick, children, ...props }: ModalProps) { @@ -33,11 +34,19 @@ export function Modal({ if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() }, [dialogRef]) + const handleClick = (event: React.MouseEvent) => { + onClick?.(event) + if (event.target === event.currentTarget && !event.defaultPrevented) { + dialogRef.current?.close() + } + } + const modal = ( } {...props} + onClick={handleClick} onCancel={(event) => { event.preventDefault() onCancel?.(event) diff --git a/src/lib/hooks/use-modal.ts b/src/lib/hooks/use-modal.ts index 1741f4a..d30a9b8 100644 --- a/src/lib/hooks/use-modal.ts +++ b/src/lib/hooks/use-modal.ts @@ -19,11 +19,12 @@ export interface UseModalOptions { export function useModal(props: UseModalOptions = {}) { const { triggerRef, hash } = props const [state, setState] = useState(ModalStates.CLOSED) + const stateRef = useRef(state) const dialogRef = useRef(null) - const handleClose = useCallback(() => { - if (state === ModalStates.CLOSED) return - + const closeState = useCallback(() => { + if (stateRef.current === ModalStates.CLOSED) return + stateRef.current = ModalStates.CLOSED setState(ModalStates.CLOSED) if (hash && window && window.location.hash === `#${hash}`) { @@ -31,21 +32,29 @@ export function useModal(props: UseModalOptions = {}) { window.history.replaceState({}, "", window.location.pathname) } - if (dialogRef.current?.open) dialogRef.current.close() - if (triggerRef && triggerRef.current) triggerRef.current.focus() - }, [triggerRef, hash, state]) + }, [triggerRef, hash]) + + const handleClose = useCallback(() => { + if (stateRef.current === ModalStates.CLOSED) return + + if (dialogRef.current?.open) dialogRef.current.close() + closeState() + }, [closeState]) const handleOpen = useCallback(() => { - const wasOpen = state === ModalStates.OPEN - if (!wasOpen) setState(ModalStates.OPEN) + const wasOpen = stateRef.current === ModalStates.OPEN + if (!wasOpen) { + stateRef.current = ModalStates.OPEN + setState(ModalStates.OPEN) + } if (dialogRef.current && !dialogRef.current.open) dialogRef.current.showModal() if (!wasOpen && hash && window.location.hash !== `#${hash}`) { window.location.hash = `#${hash}` } - }, [hash, state]) + }, [hash]) const toggle = useCallback(() => { // check all states in case user tried to close the modal in a transition @@ -72,6 +81,18 @@ export function useModal(props: UseModalOptions = {}) { } }, [handleClose, handleOpen, hash]) + useEffect(() => { + const dialog = dialogRef.current + if (!dialog) return + + const handleDialogClose = () => { + if (!dialog.open) closeState() + } + + dialog.addEventListener("close", handleDialogClose) + return () => dialog.removeEventListener("close", handleDialogClose) + }, [closeState]) + // Hook up the escape key useEffect(() => { const onKeyUp = (e: KeyboardEvent) => { diff --git a/src/main.tsx b/src/main.tsx index 23e9a03..ed64156 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -25,7 +25,6 @@ function CustomModal() { renderTo={modalRootRef.current} dialogRef={dialogRef} className={styles.CustomModalOverlay} - onClick={(event) => event.target === event.currentTarget && toggle()} > - {isOpen && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } @@ -80,14 +78,12 @@ function MyModal() { Open the modal window! - {isOpen && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } @@ -97,7 +93,7 @@ function MyModal() { Focus management, rendering to a custom element, and hash-based state management. -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 — no extra JavaScript or animation libraries needed. Exit animations using `useAnimatePresence` are 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 `` 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' @@ -127,20 +123,19 @@ function MyModal() {
- {isOpen && modalRootRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } @@ -152,13 +147,34 @@ function MyModal() { 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); @@ -175,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 `` 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. diff --git a/src/lib/components/modal/modal.tsx b/src/lib/components/modal/modal.tsx index 3067843..fcf2fcc 100644 --- a/src/lib/components/modal/modal.tsx +++ b/src/lib/components/modal/modal.tsx @@ -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. */ @@ -23,6 +27,7 @@ export interface ModalProps export function Modal({ renderTo, + isOpen, dialogRef: forwardedDialogRef, className, onCancel, @@ -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) => { const path = event.nativeEvent.composedPath() diff --git a/src/main.module.css b/src/main.module.css index 02efa4b..baded39 100644 --- a/src/main.module.css +++ b/src/main.module.css @@ -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 { @@ -17,6 +32,10 @@ } @starting-style { + .CustomModalOverlay[open] { + opacity: 0; + } + .CustomModalTransition { transform: scale(0); } diff --git a/src/main.tsx b/src/main.tsx index 97e0161..9445f84 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react" +import React, { useRef, useEffect, useState } from "react" import { createRoot } from "react-dom/client" import { Modal, ModalContent, useModal } from "./lib" @@ -7,7 +7,7 @@ import styles from "./main.module.css" function CustomModal() { const triggerButton = useRef(null) - const modalRootRef = useRef(null) + const [modalRoot, setModalRoot] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) @@ -18,35 +18,34 @@ function CustomModal() { -
+
- {isOpen && modalRootRef.current && ( - + - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } function CustomModalOnBody() { const triggerButton = useRef(null) - const bodyRef = useRef(null) + const [body, setBody] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) useEffect(() => { - bodyRef.current = document.body + setBody(document.body) }, []) return ( @@ -56,25 +55,24 @@ function CustomModalOnBody() { Open the modal (Append to body) - {isOpen && bodyRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } function ModalWithHash() { const triggerButton = useRef(null) - const modalRootRef = useRef(null) + const [modalRoot, setModalRoot] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, hash: "modal-with-hash", @@ -85,20 +83,19 @@ function ModalWithHash() { -
+
- {isOpen && modalRootRef.current && ( - - - -

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

-
-
- )} + + + +

Voluptate Lorem ut minim excepteur sit fugiat anim magna aliquip.

+
+
) } From 84c66a31c6900a8a72d6720b1721b00f777f003d Mon Sep 17 00:00:00 2001 From: "Marlon U. Marcello" Date: Mon, 26 Oct 2026 14:00:00 -0700 Subject: [PATCH 13/15] fix: update CustomModalTransition for opacity transition Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Marlon U. Marcello --- src/main.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.module.css b/src/main.module.css index baded39..f279a7e 100644 --- a/src/main.module.css +++ b/src/main.module.css @@ -27,7 +27,7 @@ } .CustomModalTransition { - transition: transform 0.5s cubic-bezier(0.77, 0, 0.1, 1.39); + transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.77, 0, 0.1, 1.39); transform: scale(1); } From 014aa138c71285822092a05564d7b06ad24f2994 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:04:29 +0000 Subject: [PATCH 14/15] docs: add minor changeset for CSS transitions --- .changeset/proud-chairs-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/proud-chairs-behave.md diff --git a/.changeset/proud-chairs-behave.md b/.changeset/proud-chairs-behave.md new file mode 100644 index 0000000..f2aebf0 --- /dev/null +++ b/.changeset/proud-chairs-behave.md @@ -0,0 +1,5 @@ +--- +"@wethegit/react-modal": minor +--- + +Add native CSS entry and exit transitions From acc94e5240424e0420c20cf9d98e6b4fd473c1d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:08:08 +0000 Subject: [PATCH 15/15] fix: avoid state update in demo effect --- src/main.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 9445f84..84e7d2f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState } from "react" +import React, { useRef, useState } from "react" import { createRoot } from "react-dom/client" import { Modal, ModalContent, useModal } from "./lib" @@ -39,15 +39,10 @@ function CustomModal() { function CustomModalOnBody() { const triggerButton = useRef(null) - const [body, setBody] = useState(null) const { isOpen, toggle, dialogRef } = useModal({ triggerRef: triggerButton, }) - useEffect(() => { - setBody(document.body) - }, []) - return ( <> {/* `triggerRef` allows the focus to shift to whatever triggered the modal, on close. */} @@ -56,7 +51,7 @@ function CustomModalOnBody() {