A lightweight React modal primitive built on the native HTML <dialog> element. No dependencies beyond React. Two files — drop them in and go.
- The browser handles the top-layer — no z-index wars
::backdropis a real CSS pseudo-element — no extra DOM node for the overlay- Escape key is handled natively via the
cancelevent - Focus trap is built into
showModal()— no extra library needed - Fully accessible out of the box (
role="dialog",aria-modal)
This component is designed to be copied directly into your project. Copy both files:
dialog.tsx
dialog.css
Then import from wherever you placed them:
import { Dialog } from './components/dialog/dialog'import { useState } from 'react'
import { Dialog } from './components/dialog/dialog'
function App() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<Dialog open={open} onClose={() => setOpen(false)}>
<Dialog.Header>
<h2>Title</h2>
</Dialog.Header>
<Dialog.Body>
<p>Dialog content goes here.</p>
</Dialog.Body>
<Dialog.Footer>
<button onClick={() => setOpen(false)}>Close</button>
</Dialog.Footer>
</Dialog>
</>
)
}The compound component API gives you three optional layout slots. Use any combination — or none at all.
| Slot | Element | Default padding |
|---|---|---|
<Dialog.Header> |
div.modalith-dialog-header |
16px 20px, bottom border |
<Dialog.Body> |
div.modalith-dialog-body |
20px |
<Dialog.Footer> |
div.modalith-dialog-footer |
12px 20px, top border |
{/* Header + body only — no footer */}
<Dialog open={open} onClose={close}>
<Dialog.Header><h2>Alert</h2></Dialog.Header>
<Dialog.Body><p>Something happened.</p></Dialog.Body>
</Dialog>
{/* No slots — fully custom layout */}
<Dialog open={open} onClose={close}>
<div style={{ padding: 24 }}>
<p>I control my own layout.</p>
</div>
</Dialog>Each slot also accepts a className prop for targeted overrides:
<Dialog.Footer className="my-footer">...</Dialog.Footer>| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
— | Controls whether the dialog is open |
onClose |
() => void |
— | Called when the dialog requests to close |
children |
ReactNode |
— | Dialog content |
closeOnBackdrop |
boolean |
true |
Controls both backdrop clicks and the Escape key |
appearFrom |
'top' | 'right' | 'bottom' | 'left' |
'top' |
Entry animation direction |
width |
number |
520 |
Max width in pixels — pass a plain number, not a string (width={640}, not width="640px") |
className |
string |
— | Extra class on the <dialog> element |
style |
CSSProperties |
— | Inline styles forwarded to <dialog> |
container |
HTMLElement | null |
document.body |
DOM node the portal mounts into |
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
Slot content |
className |
string |
Extra class on the wrapper div |
<Dialog
open={open}
onClose={() => setOpen(false)}
closeOnBackdrop={false}
>
<Dialog.Body>
<p>You must click a button to dismiss this.</p>
<button onClick={() => setOpen(false)}>Got it</button>
</Dialog.Body>
</Dialog><Dialog open={open} onClose={close} appearFrom="right" width={400}>
<Dialog.Header><h2>Settings</h2></Dialog.Header>
<Dialog.Body>...</Dialog.Body>
</Dialog>Useful when the dialog needs to live inside a specific scroll container or a shadow root.
<Dialog
open={open}
onClose={close}
container={document.getElementById('modal-root')}
>
...
</Dialog>The component uses plain CSS with a modalith- prefix — no CSS Modules, no runtime injection. Styles only load when you import the component.
Override these on .modalith-dialog to theme globally or per-instance:
.modalith-dialog {
--dialog-width: 520px; /* also controlled by the width prop */
--dialog-radius: 8px;
--dialog-shadow: /* ... */;
}.modalith-dialog-header { /* header area */ }
.modalith-dialog-body { /* body area */ }
.modalith-dialog-footer { /* footer area */ }Styled via the native ::backdrop pseudo-element — no extra DOM node needed:
.modalith-dialog::backdrop {
background-color: oklch(0.4 0.023 173 / 0.25);
backdrop-filter: blur(1px);
}Requires native <dialog> support. Covered by all modern browsers (Chrome 37+, Firefox 98+, Safari 15.4+). No polyfill is included.
Contributions are welcome! This component is intentionally minimal — but there's always room to improve it.
Ideas for what to work on:
- Accessibility — improved ARIA attributes, better screen reader announcements, focus restoration on close
- CSS — additional animation directions, smoother transitions, better mobile defaults
- Animation —
prefers-reduced-motionsupport - Features — scroll lock, stacking multiple dialogs, drawer/sheet variant
- Fork the repo and work on the
devbranch - Keep changes focused — one concern per PR
- Test in a real browser (Chrome, Firefox, Safari)
- Open a pull request with a short description of what you changed and why
All skill levels welcome. If you're unsure whether something fits, open an issue first.