Reoverlay
The missing solution for managing modals in React.
Animation types
There are quite a few preset animations. You can create your own custom animation too!
Usage, Props, etc.
You can find more information on{' '} github .
{message}
` if your npm account requires 2FA.
6. Deploy the demo with `pnpm --filter reoverlay-demo deploy`.
## License
[MIT](LICENSE)
================================================
FILE: demo/index.html
================================================
Reoverlay demo
================================================
FILE: demo/package.json
================================================
{
"name": "reoverlay-demo",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"deploy": "vite build && gh-pages -d dist",
"dev": "vite --host 0.0.0.0",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"reoverlay": "workspace:*",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.3",
"vite": "^8.0.10"
}
}
================================================
FILE: demo/src/App.tsx
================================================
import { ModalContainer, ModalWrapper, Reoverlay, type ModalAnimation } from 'reoverlay'
import 'reoverlay/ModalWrapper.css'
import logo from './assets/logo.svg'
import myPhoto from './assets/me.jpeg'
import Icon from './Icon'
const animationTypes: ModalAnimation[] = [
'fade',
'zoom',
'flip',
'door',
'rotate',
'slideUp',
'slideDown',
'slideLeft',
'slideRight',
]
const installationCode = `
pnpm add reoverlay
// or if you prefer npm
npm install reoverlay
`
type DemoModalProps = {
animation: ModalAnimation
}
const Code = ({ code }: { code: string }) => (
{code.trim()}
)
const Modal3 = ({ animation }: DemoModalProps) => {
return (
#3 Modal. Ok that's enough 😆 (Though you can keep stacking up as you wish.)
)
}
const Modal2 = ({ animation }: DemoModalProps) => {
return (
#2 Modal. It's getting dark here 🌗. Wanna see the third one?
)
}
const Modal1 = ({ animation }: DemoModalProps) => {
return (
#1 Modal. So sweet! ❤️. Wanna see more?
)
}
const App = () => {
const showModal = (animation: ModalAnimation) => {
Reoverlay.showModal(Modal1, { animation })
}
return (
Reoverlay
The missing solution for managing modals in React.
Animation types
There are quite a few preset animations. You can create your own custom animation too!
{animationTypes.map((type) => (
))}
Usage, Props, etc.
You can find more information on{' '}
github
.
)
}
export default App
================================================
FILE: demo/src/Icon.tsx
================================================
type IconProps = {
className?: string
name: 'donation' | 'github' | 'twitter'
}
const Icon = ({ className, name }: IconProps) => {
switch (name) {
case 'github':
return (
)
case 'donation':
return (
)
case 'twitter':
return (
)
}
}
export default Icon
================================================
FILE: demo/src/main.tsx
================================================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root') as HTMLElement).render(
)
================================================
FILE: demo/src/styles.css
================================================
@font-face {
font-family: 'ProductSans';
font-weight: 400;
src:
local('Product Sans'),
local('ProductSans-Regular'),
url('./fonts/ProductSansRegular.woff2') format('woff2');
font-style: normal;
}
@font-face {
font-family: 'ProductSans';
font-weight: 700;
src:
local('Product Sans Bold'),
local('ProductSans-Bold'),
url('./fonts/ProductSansBold.woff2') format('woff2');
font-style: normal;
}
:root {
--color-blue: #2578ff;
--color-blue-dark-primary: #263b5d;
--color-blue-dark-secondary: #435b81;
--color-gray: #b1b8c5;
--color-gray-light: rgba(177, 184, 197, 0.15);
--size-huge-title: 2em;
--size-title: 1.5625em;
}
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote::before,
blockquote::after,
q::before,
q::after {
content: '';
}
table {
border-collapse: collapse;
border-spacing: 0;
}
* {
box-sizing: border-box;
}
body {
font-family: 'ProductSans', sans-serif;
font-weight: normal;
}
a {
-webkit-tap-highlight-color: transparent;
color: inherit;
text-decoration: none;
}
button {
-webkit-tap-highlight-color: transparent;
border: none;
outline: none;
cursor: pointer;
font-family: inherit;
}
main {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 20vh 0;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 40em;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.header .header__logo {
width: 221px;
height: 144px;
}
.header .header__title {
color: var(--color-blue-dark-primary);
font-size: var(--size-huge-title);
font-weight: bold;
padding-top: 0.5em;
}
.header .header__description {
color: var(--color-gray);
padding-top: 1.5em;
text-align: center;
line-height: 1.5em;
}
.header .header__buttonContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 2.625em 0 1.5em;
}
.header .header__githubButton {
background-color: var(--color-blue);
height: 3.75em;
padding: 0 3em;
border-radius: 1em;
color: white;
transition: box-shadow 0.35s;
user-select: none;
margin: 0 1em;
display: flex;
align-items: center;
}
.header .header__githubButtonText {
padding-right: 0.5em;
white-space: nowrap;
}
.header .header__githubButton:hover {
box-shadow: 0 8px 15px rgba(37, 120, 255, 0.2);
}
.header .header__donationButton {
border: 1px solid #d7dee9;
height: 3.75em;
padding: 0 2.5em;
border-radius: 1em;
margin: 0 1em;
display: flex;
align-items: center;
color: var(--color-blue-dark-primary);
user-select: none;
transition: box-shadow 0.35s;
}
.header .header__donationButtonText {
padding-right: 0.5em;
white-space: nowrap;
}
.Code {
width: 100%;
}
.Code pre {
overflow: auto;
border-radius: 1em;
background: #f6f8fb;
color: var(--color-blue-dark-primary);
line-height: 1.5;
padding: 1.5em;
}
.section {
margin-top: 3.75em;
display: flex;
flex-direction: column;
width: 100%;
}
.section .section__title {
color: var(--color-blue-dark-primary);
font-size: var(--size-title);
font-weight: bold;
margin-bottom: 0.2em;
text-align: left;
}
.section .section__description {
color: var(--color-gray);
line-height: 1.5em;
}
.section .section__description .section__link {
color: var(--color-blue-dark-secondary);
}
.section .section__animationTypesContainer {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding-top: 1.5em;
}
.section .section__animationTypeContainer {
width: 28%;
margin-bottom: 2em;
background: transparent;
padding: 0;
}
.section .section__animationType {
background-color: var(--color-gray-light);
border-radius: 1em;
text-align: center;
padding: 1em 0;
color: var(--color-blue-dark-secondary);
user-select: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
display: block;
}
.modalContent {
padding: 2em 5em;
display: flex;
flex-direction: column;
align-items: center;
}
.modalContent .modalContent__title {
font-weight: bold;
font-size: 1.5em;
}
.modalContent .modalContent__buttonContainer {
display: flex;
align-items: center;
justify-content: center;
margin-top: 2em;
}
.modalContent .modalContent__buttonContainer button {
border: 1px solid #d7dee9;
padding: 1em 2.5em;
border-radius: 1em;
margin: 0 1em;
display: flex;
align-items: center;
color: var(--color-blue-dark-primary);
user-select: none;
background-color: white;
transition: 0.3s background-color;
}
.modalContent .modalContent__buttonContainer button:hover {
background-color: var(--color-gray-light);
}
.footer {
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
margin-top: 3.75em;
}
.footer .footer__profilePhoto {
width: 6.25em;
height: 6.25em;
border-radius: 100%;
margin-bottom: 1.25em;
}
.footer .footer__intentionText {
color: var(--color-gray);
}
.footer .footer__twitterIcon {
width: 1em;
}
.footer .footer__twitterContainer {
display: flex;
align-items: center;
margin-top: 1em;
}
.footer .footer__twitterHandle {
padding-left: 0.2em;
color: var(--color-blue-dark-primary);
}
@media (max-width: 768px) {
.container {
padding: 0 1.25em;
}
.modalContent {
padding: 2em 3em;
}
.modalContent .modalContent__buttonContainer {
flex-direction: column;
}
.modalContent .modalContent__buttonContainer button:not(:last-child) {
margin-bottom: 1em;
}
}
@media (max-width: 500px) {
.header .header__buttonContainer {
flex-direction: column;
}
.header .header__githubButton {
margin: 0 0 1em;
}
}
================================================
FILE: demo/tsconfig.json
================================================
{
"extends": "../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": true,
"paths": {
"reoverlay": ["../src/index.ts"],
"reoverlay/ModalWrapper.css": ["../src/ModalWrapper.css"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
================================================
FILE: demo/vite.config.ts
================================================
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export default defineConfig({
base: '/reoverlay/',
plugins: [react()],
resolve: {
alias: [
{
find: /^reoverlay\/ModalWrapper.css$/,
replacement: path.resolve(root, 'src/ModalWrapper.css'),
},
{
find: 'reoverlay',
replacement: path.resolve(root, 'src/index.ts'),
},
],
},
})
================================================
FILE: eslint.config.mjs
================================================
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{
ignores: ['coverage', 'dist', 'lib', 'demo/dist'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
},
}
)
================================================
FILE: package.json
================================================
{
"name": "reoverlay",
"version": "1.1.0",
"description": "A tiny, typed modal manager for React.",
"license": "MIT",
"author": "Hirad Arshadi ",
"repository": {
"type": "git",
"url": "https://github.com/hiradary/reoverlay"
},
"bugs": {
"url": "https://github.com/hiradary/reoverlay/issues"
},
"homepage": "https://hiradary.github.io/reoverlay",
"keywords": [
"modal",
"overlay",
"react",
"react-modal",
"react-overlay",
"reoverlay"
],
"packageManager": "pnpm@10.30.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ModalWrapper.css": "./dist/ModalWrapper.css",
"./lib/ModalWrapper.css": "./lib/ModalWrapper.css",
"./package.json": "./package.json"
},
"files": [
"dist",
"lib",
"README.md",
"LICENSE"
],
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "pnpm build:package",
"build:all": "pnpm build:package && pnpm build:demo",
"build:demo": "pnpm --filter reoverlay-demo build",
"build:package": "tsup",
"clean": "rm -rf dist lib coverage demo/dist",
"dev": "pnpm --filter reoverlay-demo dev",
"format": "prettier --write .",
"lint": "eslint .",
"prepublishOnly": "pnpm lint && pnpm typecheck && pnpm test && pnpm build:package",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit && pnpm --filter reoverlay-demo typecheck"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"gh-pages": "^6.3.0",
"jsdom": "^29.0.2",
"prettier": "^3.8.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
}
================================================
FILE: pnpm-workspace.yaml
================================================
packages:
- demo
================================================
FILE: src/ModalContainer.tsx
================================================
import { Fragment, cloneElement, isValidElement, useEffect, useState } from 'react'
import { EVENT } from './constants'
import type { ActiveModal } from './types'
import { eventManager } from './utils'
const ModalContainer = () => {
const [modals, setModals] = useState([])
useEffect(() => {
const unsubscribe = eventManager.on(EVENT.CHANGE_MODAL, setModals)
return unsubscribe
}, [])
return (
{modals.map(({ modalKey, component, props }) => {
if (isValidElement(component)) {
return {cloneElement(component, props)}
}
const Component = component
return (
)
})}
)
}
export default ModalContainer
================================================
FILE: src/ModalWrapper.css
================================================
.reOverlay .reOverlay__modalWrapper {
width: 100%;
height: 100vh;
height: 100dvh;
position: fixed;
top: 0;
left: 0;
z-index: 999;
background-color: rgba(0, 0, 0, 0.6);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-animation: ro-fade 0.3s forwards;
animation: ro-fade 0.3s forwards;
}
.reOverlay .reOverlay__modalWrapper,
.reOverlay .reOverlay__modalWrapper * {
box-sizing: border-box;
}
.reOverlay .reOverlay__modalWrapper .reOverlay__modalContainer {
background-color: white;
}
.reOverlay .reOverlay__modalWrapper.-ro-zoom .reOverlay__modalContainer {
-webkit-animation: ro-zoom 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-zoom 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
.reOverlay .reOverlay__modalWrapper.-ro-slideDown .reOverlay__modalContainer {
-webkit-animation: ro-slideDown 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-slideDown 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
.reOverlay .reOverlay__modalWrapper.-ro-slideUp .reOverlay__modalContainer {
-webkit-animation: ro-slideUp 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-slideUp 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
.reOverlay .reOverlay__modalWrapper.-ro-slideLeft .reOverlay__modalContainer {
-webkit-animation: ro-slideLeft 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-slideLeft 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
.reOverlay .reOverlay__modalWrapper.-ro-slideRight .reOverlay__modalContainer {
-webkit-animation: ro-slideRight 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-slideRight 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
.reOverlay .reOverlay__modalWrapper.-ro-flip .reOverlay__modalContainer {
-webkit-animation: ro-flip 0.3s forwards ease-in;
animation: ro-flip 0.3s forwards ease-in;
-webkit-backface-visibility: visible !important;
backface-visibility: visible !important;
}
.reOverlay .reOverlay__modalWrapper.-ro-rotate .reOverlay__modalContainer {
-webkit-animation: ro-rotate 0.3s forwards ease-out;
animation: ro-rotate 0.3s forwards ease-out;
-webkit-transform-origin: center;
-ms-transform-origin: center;
transform-origin: center;
}
.reOverlay .reOverlay__modalWrapper.-ro-door .reOverlay__modalContainer {
-webkit-animation: ro-door 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
animation: ro-door 0.3s cubic-bezier(0.4, 0, 0, 1.5) forwards;
}
/* Keyframes */
@-webkit-keyframes ro-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ro-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@-webkit-keyframes ro-zoom {
from {
-webkit-transform: scale3d(0.3, 0.3, 0.3);
transform: scale3d(0.3, 0.3, 0.3);
}
to {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@keyframes ro-zoom {
from {
-webkit-transform: scale3d(0.3, 0.3, 0.3);
transform: scale3d(0.3, 0.3, 0.3);
}
to {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@-webkit-keyframes ro-slideDown {
from {
-webkit-transform: translate3d(0, -2rem, 0);
transform: translate3d(0, -2rem, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes ro-slideDown {
from {
-webkit-transform: translate3d(0, -2rem, 0);
transform: translate3d(0, -2rem, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@-webkit-keyframes ro-slideUp {
from {
-webkit-transform: translate3d(0, 2rem, 0);
transform: translate3d(0, 2rem, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes ro-slideUp {
from {
-webkit-transform: translate3d(0, 2rem, 0);
transform: translate3d(0, 2rem, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@-webkit-keyframes ro-slideLeft {
from {
-webkit-transform: translate3d(-2rem, 0, 0);
transform: translate3d(-2rem, 0, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes ro-slideLeft {
from {
-webkit-transform: translate3d(-2rem, 0, 0);
transform: translate3d(-2rem, 0, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@-webkit-keyframes ro-slideRight {
from {
-webkit-transform: translate3d(2rem, 0, 0);
transform: translate3d(2rem, 0, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes ro-slideRight {
from {
-webkit-transform: translate3d(2rem, 0, 0);
transform: translate3d(2rem, 0, 0);
}
to {
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@-webkit-keyframes ro-flip {
from {
-webkit-transform: perspective(18rem) rotate3d(1, 0, 0, 50deg);
transform: perspective(18rem) rotate3d(1, 0, 0, 50deg);
}
70% {
-webkit-transform: perspective(18rem) rotate3d(1, 0, 0, -15deg);
transform: perspective(18rem) rotate3d(1, 0, 0, -15deg);
}
to {
-webkit-transform: perspective(18rem);
transform: perspective(18rem);
}
}
@keyframes ro-flip {
from {
-webkit-transform: perspective(18rem) rotate3d(1, 0, 0, 50deg);
transform: perspective(18rem) rotate3d(1, 0, 0, 50deg);
}
70% {
-webkit-transform: perspective(18rem) rotate3d(1, 0, 0, -15deg);
transform: perspective(18rem) rotate3d(1, 0, 0, -15deg);
}
to {
-webkit-transform: perspective(18rem);
transform: perspective(18rem);
}
}
@-webkit-keyframes ro-rotate {
from {
-webkit-transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3);
transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3);
}
to {
-webkit-transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1);
transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1);
}
}
@keyframes ro-rotate {
from {
-webkit-transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3);
transform: rotate3d(0, 0, 1, -180deg) scale3d(0.3, 0.3, 0.3);
}
to {
-webkit-transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1);
transform: rotate3d(0, 0, 1, 0deg) scale3d(1, 1, 1);
}
}
@-webkit-keyframes ro-door {
from {
-webkit-transform: scale3d(0, 1, 1);
transform: scale3d(0, 1, 1);
}
to {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@keyframes ro-door {
from {
-webkit-transform: scale3d(0, 1, 1);
transform: scale3d(0, 1, 1);
}
to {
-webkit-transform: scale3d(1, 1, 1);
transform: scale3d(1, 1, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.reOverlay .reOverlay__modalWrapper,
.reOverlay .reOverlay__modalWrapper .reOverlay__modalContainer {
-webkit-animation-duration: 1ms;
animation-duration: 1ms;
}
}
================================================
FILE: src/ModalWrapper.tsx
================================================
import { useEffect, useRef } from 'react'
import type React from 'react'
import Reoverlay from './Reoverlay'
import type { ModalWrapperProps } from './types'
const ModalWrapper = ({
'aria-describedby': ariaDescribedBy,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
animation = 'fade',
children = null,
closeOnEscape = true,
contentContainerClassName = '',
onClose = () => {
Reoverlay.hideModal()
},
role = 'dialog',
wrapperClassName = '',
}: ModalWrapperProps) => {
const wrapperElement = useRef(null)
useEffect(() => {
if (!closeOnEscape) return undefined
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose(event)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [closeOnEscape, onClose])
const handleClickOutside = (event: React.MouseEvent) => {
if (event.target === wrapperElement.current) {
onClose(event)
}
}
return (
{children}
)
}
export default ModalWrapper
================================================
FILE: src/Reoverlay.ts
================================================
import { EVENT, VALIDATE } from './constants'
import type { ActiveModal, ModalConfigItem, ModalProps, ModalRenderable } from './types'
import { eventManager, getLastElement, validate } from './utils'
type ModalSnapshot = Omit, 'modalKey'>
let modalId = 0
const createModalKey = () => {
modalId += 1
return `reoverlay-${modalId}`
}
const Reoverlay = {
modals: new Map(),
snapshots: new Map>(),
config(configData: ModalConfigItem[] = []) {
validate(VALIDATE.CONFIG, configData)
configData.forEach((item) => {
this.modals.set(item.name, item.component)
})
},
showModal(
modal: ModalRenderable
| string,
props = {} as P
) {
const modalType = validate(VALIDATE.SHOW_MODAL, modal)
if (modalType === 'string') {
const modalKey = modal as string
const modalElement = this.modals.get(modalKey)
if (!modalElement) {
throw new Error(
`Reoverlay: Modal not found. Make sure "${modalKey}" has been passed to Reoverlay.config().`
)
}
this.applyModal({
component: modalElement,
modalKey,
props,
type: EVENT.SHOW_MODAL,
})
return
}
this.applyModal({
component: modal as ModalRenderable
,
modalKey: createModalKey(),
props,
type: EVENT.SHOW_MODAL,
})
},
getSnapshotsArray() {
return Array.from(this.snapshots.entries()).map(([modalKey, value]) => ({
modalKey,
...value,
}))
},
hideModal(modal: string | null = null) {
if (modal) {
validate(VALIDATE.HIDE_MODAL, modal)
const modalKey = modal
const snapshot = this.snapshots.get(modalKey)
if (!snapshot) {
throw new Error("Reoverlay: Snapshot not found. You're trying to hide a missing modal.")
}
this.applyModal({
...snapshot,
modalKey,
type: EVENT.HIDE_MODAL,
})
return
}
const lastSnapshot = getLastElement(this.getSnapshotsArray()) ?? null
if (lastSnapshot) {
this.applyModal({ ...lastSnapshot, type: EVENT.HIDE_MODAL })
return
}
console.error("Reoverlay: There's no active modal to be hidden.")
},
hideAll() {
this.applyModal({ type: EVENT.HIDE_ALL })
},
applyModal({
component,
modalKey,
props,
type,
}: Partial> & { type: (typeof EVENT)[keyof typeof EVENT] }) {
switch (type) {
case EVENT.SHOW_MODAL:
if (component && modalKey) {
this.snapshots.set(modalKey, { component, props: props ?? {} })
}
break
case EVENT.HIDE_ALL:
this.snapshots.clear()
break
default:
if (modalKey) {
this.snapshots.delete(modalKey)
}
break
}
eventManager.emit(EVENT.CHANGE_MODAL, this.getSnapshotsArray())
},
}
export default Reoverlay
================================================
FILE: src/constants/index.ts
================================================
export const VALIDATE = {
CONFIG: 'config',
HIDE_MODAL: 'hide_modal',
SHOW_MODAL: 'show_modal',
} as const
export const EVENT = {
CHANGE_MODAL: 'change_modal',
HIDE_ALL: 'hide_all',
HIDE_MODAL: 'hide_modal',
SHOW_MODAL: 'show_modal',
} as const
================================================
FILE: src/index.ts
================================================
export { default as ModalContainer } from './ModalContainer'
export { default as ModalWrapper } from './ModalWrapper'
export { default as Reoverlay } from './Reoverlay'
export type {
ActiveModal,
ModalAnimation,
ModalCloseEvent,
ModalComponent,
ModalConfigItem,
ModalElement,
ModalProps,
ModalRenderable,
ModalWrapperProps,
} from './types'
================================================
FILE: src/types.ts
================================================
import type React from 'react'
export type ModalProps = Record
export type ModalComponent = React.ElementType
export type ModalElement
= React.ReactElement
export type ModalRenderable
= ModalComponent
| ModalElement
export type ModalConfigItem
= {
name: string
component: ModalRenderable
}
export type ModalAnimation =
| 'fade'
| 'zoom'
| 'flip'
| 'door'
| 'rotate'
| 'slideUp'
| 'slideDown'
| 'slideLeft'
| 'slideRight'
export type ModalCloseEvent =
| React.MouseEvent
| KeyboardEvent
| React.KeyboardEvent
export type ModalWrapperProps = {
'aria-describedby'?: string
'aria-label'?: string
'aria-labelledby'?: string
animation?: ModalAnimation
children?: React.ReactNode
closeOnEscape?: boolean
contentContainerClassName?: string
onClose?: (event: ModalCloseEvent) => void
role?: 'dialog' | 'alertdialog'
wrapperClassName?: string
}
export type ActiveModal = {
component: ModalRenderable
modalKey: string
props: P
}
================================================
FILE: src/utils/eventManager.ts
================================================
type Listener = (payload: TPayload) => void
const eventManager = (() => {
const subscribers = new Map>>()
const on = (eventName: string, callback: Listener) => {
if (!subscribers.has(eventName)) {
subscribers.set(eventName, new Set())
}
const listeners = subscribers.get(eventName)
listeners?.add(callback as Listener)
return () => off(eventName, callback)
}
const off = (eventName?: string, callback?: Listener) => {
if (!eventName) {
subscribers.clear()
return
}
if (!callback) {
subscribers.delete(eventName)
return
}
const listeners = subscribers.get(eventName)
listeners?.delete(callback as Listener)
if (listeners?.size === 0) {
subscribers.delete(eventName)
}
}
const emit = (eventName: string, payload: TPayload) => {
subscribers.get(eventName)?.forEach((callback) => {
callback(payload)
})
}
const listenerCount = (eventName: string) => subscribers.get(eventName)?.size ?? 0
return {
emit,
listenerCount,
off,
on,
}
})()
export default eventManager
================================================
FILE: src/utils/index.ts
================================================
export { default as eventManager } from './eventManager'
export * from './utils'
export * from './validator'
================================================
FILE: src/utils/utils.ts
================================================
export const getLastElement = (array: TValue[]) => array[array.length - 1]
export const isArrayUnique = (array: TValue[]) => new Set(array).size === array.length
export const isString = (value: unknown): value is string =>
typeof value === 'string' || value instanceof String
export const isModalLikeObject = (value: unknown) =>
typeof value === 'object' && value !== null && '$$typeof' in value
================================================
FILE: src/utils/validator.ts
================================================
import { isValidElement } from 'react'
import { VALIDATE } from '../constants'
import type { ModalConfigItem, ModalRenderable } from '../types'
import { isArrayUnique, isModalLikeObject, isString } from './utils'
type ValidationType = (typeof VALIDATE)[keyof typeof VALIDATE]
export type ShowModalValidationResult = 'component' | 'string'
const isRenderableModal = (value: unknown): value is ModalRenderable =>
typeof value === 'function' || isValidElement(value) || isModalLikeObject(value)
export const validate = (
type: ValidationType,
value: unknown
): boolean | ShowModalValidationResult => {
switch (type) {
case VALIDATE.CONFIG: {
if (!Array.isArray(value)) {
throw new Error(
'Reoverlay: Config data must be an array. Pass an array to Reoverlay.config().'
)
}
const configData = value as ModalConfigItem[]
configData.forEach((item) => {
if (!item.name || !item.component) {
throw new Error(
"Reoverlay: Each config item must contain a 'name' and 'component' property."
)
}
})
const names = configData.map((item) => item.name)
if (!isArrayUnique(names)) {
throw new Error('Reoverlay: Modal config names must be unique.')
}
return true
}
case VALIDATE.SHOW_MODAL: {
const throwError = () => {
throw new Error(
"Reoverlay: Method 'showModal' requires a React component, React element, or configured modal name."
)
}
if (!value) throwError()
if (isString(value)) return 'string'
if (isRenderableModal(value)) return 'component'
throwError()
return false
}
case VALIDATE.HIDE_MODAL: {
if (isString(value)) return true
throw new Error(
`Reoverlay: Method 'hideModal' accepts an optional string modal name, got ${typeof value}.`
)
}
default:
return false
}
}
================================================
FILE: tests/ModalContainer.test.tsx
================================================
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { EVENT } from '../src/constants'
import { ModalContainer, Reoverlay } from '../src'
import { eventManager } from '../src/utils'
const TestModal = ({ label }: { label: string }) => {label}
afterEach(() => {
act(() => {
Reoverlay.hideAll()
})
cleanup()
})
describe('ModalContainer', () => {
it('subscribes once and cleans up the exact change listener', () => {
const { unmount } = render( )
expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(1)
act(() => {
Reoverlay.showModal(TestModal, { label: 'Stable listener' })
})
expect(screen.getByText('Stable listener')).toBeInTheDocument()
expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(1)
unmount()
expect(eventManager.listenerCount(EVENT.CHANGE_MODAL)).toBe(0)
})
})
================================================
FILE: tests/ModalWrapper.test.tsx
================================================
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ModalWrapper } from '../src'
describe('ModalWrapper', () => {
it('adds dialog semantics and custom classes', () => {
render(
Content
)
const dialog = screen.getByRole('dialog', { name: 'Confirm action' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveClass('-ro-slideUp')
expect(dialog).toHaveClass('custom-wrapper')
expect(dialog.firstElementChild).toHaveClass('custom-content')
})
it('calls onClose when the wrapper backdrop is clicked', () => {
const onClose = vi.fn()
render(
)
fireEvent.click(screen.getByRole('dialog', { name: 'Close from backdrop' }))
fireEvent.click(screen.getByRole('button', { name: 'Inside' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('calls onClose when Escape is pressed', () => {
const onClose = vi.fn()
render(
Content
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('can disable Escape close behavior', () => {
const onClose = vi.fn()
render(
Content
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).not.toHaveBeenCalled()
})
})
================================================
FILE: tests/Reoverlay.test.tsx
================================================
import { cleanup, render, screen, act } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ModalContainer, Reoverlay } from '../src'
const TestModal = ({ label }: { label: string }) => {label}
afterEach(() => {
act(() => {
Reoverlay.hideAll()
})
cleanup()
vi.restoreAllMocks()
})
describe('Reoverlay', () => {
it('renders a direct modal component with props', () => {
render( )
act(() => {
Reoverlay.showModal(TestModal, { label: 'Direct modal' })
})
expect(screen.getByText('Direct modal')).toBeInTheDocument()
})
it('renders a configured modal by name', () => {
render( )
Reoverlay.config([{ component: TestModal, name: 'NamedModal' }])
act(() => {
Reoverlay.showModal('NamedModal', { label: 'Named modal' })
})
expect(screen.getByText('Named modal')).toBeInTheDocument()
})
it('supports React elements passed directly', () => {
render( )
act(() => {
Reoverlay.showModal( )
})
expect(screen.getByText('Element modal')).toBeInTheDocument()
})
it('hides the last modal by default', () => {
render( )
act(() => {
Reoverlay.showModal(TestModal, { label: 'First modal' })
Reoverlay.showModal(TestModal, { label: 'Second modal' })
Reoverlay.hideModal()
})
expect(screen.getByText('First modal')).toBeInTheDocument()
expect(screen.queryByText('Second modal')).not.toBeInTheDocument()
})
it('hides a named modal while leaving other modals open', () => {
render( )
Reoverlay.config([
{ component: TestModal, name: 'FirstNamedModal' },
{ component: TestModal, name: 'SecondNamedModal' },
])
act(() => {
Reoverlay.showModal('FirstNamedModal', { label: 'First named modal' })
Reoverlay.showModal('SecondNamedModal', { label: 'Second named modal' })
Reoverlay.hideModal('SecondNamedModal')
})
expect(screen.getByText('First named modal')).toBeInTheDocument()
expect(screen.queryByText('Second named modal')).not.toBeInTheDocument()
})
it('hides all active modals', () => {
render( )
act(() => {
Reoverlay.showModal(TestModal, { label: 'First modal' })
Reoverlay.showModal(TestModal, { label: 'Second modal' })
Reoverlay.hideAll()
})
expect(screen.queryByText('First modal')).not.toBeInTheDocument()
expect(screen.queryByText('Second modal')).not.toBeInTheDocument()
})
it('validates duplicate configured names', () => {
expect(() => {
Reoverlay.config([
{ component: TestModal, name: 'DuplicateModal' },
{ component: TestModal, name: 'DuplicateModal' },
])
}).toThrow(/unique/i)
})
it('throws for missing configured modals', () => {
expect(() => {
Reoverlay.showModal('MissingModal')
}).toThrow(/not found/i)
})
})
================================================
FILE: tests/package-smoke.test.ts
================================================
import { createRequire } from 'node:module'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { beforeAll, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const root = path.resolve(import.meta.dirname, '..')
describe('package output', () => {
beforeAll(() => {
execFileSync('pnpm', ['build:package'], {
cwd: root,
stdio: 'inherit',
})
}, 60_000)
it('emits ESM, CommonJS, declarations, and CSS compatibility files', async () => {
expect(existsSync(path.join(root, 'dist/index.js'))).toBe(true)
expect(existsSync(path.join(root, 'dist/index.cjs'))).toBe(true)
expect(existsSync(path.join(root, 'dist/index.d.ts'))).toBe(true)
expect(existsSync(path.join(root, 'dist/ModalWrapper.css'))).toBe(true)
expect(existsSync(path.join(root, 'lib/ModalWrapper.css'))).toBe(true)
const esm = await import(pathToFileURL(path.join(root, 'dist/index.js')).href)
const cjs = require('../dist/index.cjs')
expect(esm.Reoverlay.showModal).toEqual(expect.any(Function))
expect(cjs.ModalContainer).toEqual(expect.any(Function))
})
})
================================================
FILE: tsconfig.build.json
================================================
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"noEmit": false,
"outDir": "dist",
"types": []
},
"include": ["src"],
"exclude": ["tests", "**/*.test.ts", "**/*.test.tsx"]
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"ignoreDeprecations": "6.0",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"types": ["vitest/globals"]
},
"include": [
"src",
"tests",
"tsup.config.ts",
"vitest.config.ts",
"vitest.setup.ts",
"eslint.config.mjs"
]
}
================================================
FILE: tsup.config.ts
================================================
import { copyFile, mkdir } from 'node:fs/promises'
import { defineConfig } from 'tsup'
const copyStyles = async () => {
await mkdir('dist', { recursive: true })
await mkdir('lib', { recursive: true })
await copyFile('src/ModalWrapper.css', 'dist/ModalWrapper.css')
await copyFile('src/ModalWrapper.css', 'lib/ModalWrapper.css')
}
export default defineConfig({
clean: true,
dts: true,
entry: ['src/index.ts'],
external: ['react', 'react-dom'],
format: ['esm', 'cjs'],
minify: true,
onSuccess: copyStyles,
outDir: 'dist',
outExtension({ format }) {
return {
js: format === 'cjs' ? '.cjs' : '.js',
}
},
sourcemap: true,
splitting: false,
target: 'es2020',
treeshake: true,
})
================================================
FILE: vitest.config.ts
================================================
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
})
================================================
FILE: vitest.setup.ts
================================================
import '@testing-library/jest-dom/vitest'