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 `
)
}
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 (