@react-above/modal
A flexible headless modal library for your React app.
- All commonly used features (
closeOnClickOutside
,closeOnEsc
, Scroll and Focus locks as plugins) - Completely customizable and extendable (actually, it doesn't have an UI out of the box)
- Lightweight
- Plugin system (you can even do animations inside)
- A11y attributes and convenient API for specifying them
- 2 methods of rendering:
children
and render-prop - A lot of lifecycle methods available
- Warning: NO full support for multiple/nested modals
Roadmap
- [x] Core modal features
- [x] Theme support
- [x] Plugin system
- [x] Close on ESC and click outside
- [ ] Move plugins to separate repos
- [ ] Theme-level lifecycle callbacks
- [ ] Animation plugin
- [ ] Plugin and theme repo templates
- [ ] Better default theme
- [ ] Test cases for everything
- [ ] A small documentation and API reference in READMEs
- [ ] Release v1.0.0
- [ ] Logo and banner for
react-above
- [ ] A good comprehensive documentation on a separate website
- [ ] Theme catalog with demos
- [ ] Plugin catalog with demos
- [ ] More themes
Installation
$ yarn add @react-above/modal
Usage
1. Create Modal component
/* Somewhere in your UI layer.. */
import { createModal, ScrollLockPlugin, FocusLockPlugin } from '@react-above/modal'
// you can use any theme instead of the default one
import { ThemeDefault } from '@react-above/modal-theme-default'
export const Modal = createModal({
theme: ThemeDefault(),
plugins: [ScrollLockPlugin(), FocusLockPlugin()],
})
2. Use anywhere
Children syntax
The most common usage method. The majority of modal libraries look like so.
import { Modal } from '@app/ui'
<Modal isOpen={isOpen} close={close}>
<Modal.Surface>
<Modal.Header title="My modal" close={close} />
<Modal.Body>My modal description</Modal.Body>
</Modal.Surface>
</Modal>
Render syntax
Also, react-above
's Modal provides the render
prop API for more flexibility.
It can be a simple inline-function, or a real React-component - you can freely use hooks inside.
import { Modal } from '@app/ui'
<Modal
isOpen={isOpen}
close={close}
render={({ close }) => (
<Modal.Surface>
<Modal.Header title="My modal" close={close} />
<Modal.Body>My modal description</Modal.Body>
</Modal.Surface>
)}
/>
API Reference
Shared types
type Elements = {
html: HTMLElement
body: HTMLElement
screen: HTMLElement
overlay: HTMLElement
modal: HTMLElement
}
type LifecycleCallbacks = {
/*
* The Modal will wait for Promise to be resolved
* You can use this behavior to implement animation delays
*/
onAfterMount?: (elements: Elements) => Promise<void> | void
onBeforeUnmount?: (elements: Elements) => Promise<void> | void
/*
* The DOM-postfixed callbacks are called inside useLayoutEffect
* If you want to work with HTML nodes - this is the perfect place
*/
onAfterMountDOM?: (elements: Elements) => void
onBeforeUnmountDOM?: (elements: Elements) => void
}
type ModalFC = FC<ModalProps>
createModal
type Parameters = {
/*
* Theme is used to set up modal's Frame (screen + overlay + modal)
* Also, Theme can extend Modal component with its specific sub-components,
* so you can use them like Modal.Surface, Modal.Header, Modal.Body and etc.
*/
theme: ThemeOutput
/*
* Plugins are used to add specific functionality to Modal
* They have an access to Modal's elements and lifecycle callbacks
*
* Some lifecycle callbacks may be asynchronous,
* so you can implement animation delay in there
*/
plugins?: PluginOutput[]
/*
* The function returning Modal's render target node
* It's done as a function for the SSR-compatibility reasons
*/
root?: () => HTMLElement
}
type ReturnType = ModalFC & {
// ... custom Theme components
}
Modal
type ModalProps = {
// no need to explain
isOpen: boolean
// "close" callback is used by inner functionality (like "closeOnClickOutside" and "closeOnEsc")
close: () => void
// the most common usage method - just pass content as a children
children?: ReactNode
/*
* Render is useful for avoiding the execution of unwanted logic
* It guarantees that your Renderer component will be called ONLY when modal is open
* So, you can get rid of useless conditional rendering and hook calls
*
* Also, ModalRenderer may be the common React component,
* so you can safely use hooks inside
*/
render?: ModalRenderer
closeOnClickOutside?: boolean
closeOnEsc?: boolean
// title and description for a11y attributes
aria?: Aria
// custom root
root?: () => HTMLElement
// see in "Shared types" section
...LifecycleCallbacks
}
createPlugin
/*
* Accepts Options type as a generic parameter
* It defaults to "void"
*
* In "build" callback, "void" transforms to "undefined",
* to emulate "optional" parameter for better DX
*/
export const MyPlugin = createPlugin<MyPluginOptions | void>({
build: (options: MyPluginOptions | undefined) => ({
// LifecycleCallbacks (see in "Shared types" section)
}),
})
A few words about multiple/nested modals
The main reasons why it's not implemented:
- In most cases, the nested modals is an anti-pattern
- It can cause inconvenient public API - most likely you would have to render something like
ModalRoot
- The
Overlay
shouldn't overlap, so it requires an additional work to be done. We would need a new type of lifecycle callbacks, and the more complicatedTheme
API - It's hard to implement and the resulting code won't look good
The current behavior:
- You can open multiple Modals at once
- The
Overlay
components will overlap - the background will become darker (in case of black transparentOverlay
) - On click outside: the upper one Modal will be closed
- On ESC press: ALL Modals will be closed