react-modal-port 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +18 -4
  3. package/readme.md +221 -118
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ Documentation only; the library code is unchanged.
6
+
7
+ - README rewritten: it leads with the headless approach (you own markup, styles, accessibility and animation; the library handles launching, stacking, resolving and per-modal state), adds a "How it works" overview, a single quick start, an `await confirm()` recipe, a `launchModal` and types reference, and a Next.js section. Every snippet type-checks.
8
+ - The `<dialog>` and animation recipes now mention `overflow: clip` and `focus({ preventScroll: true })`, which keep slide-in animations from being cancelled.
9
+ - Better npm description and keywords.
10
+
11
+ ## 1.0.1
12
+
13
+ Documentation only; the library code is unchanged.
14
+
15
+ - README links to the new CodePen 2.0 demos: [usage examples](https://codepen.io/oliverwehn/pen/018e18fb-0f15-724c-8fdf-4f47406f37d9) and [animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22).
16
+ - The demos in `demo/` are ported to the CodePen 2.0 editor (`package.json` with esm.sh packages, `.jsx` via the Babel block). The animation examples gained a stacked-modal deck and fixed drawer and bottom-sheet enter animations.
17
+
3
18
  ## 1.0.0
4
19
 
5
20
  A full rework for correctness, type safety and packaging. See [MIGRATION.md](./MIGRATION.md) for upgrade steps.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-modal-port",
3
- "version": "1.0.0",
4
- "description": "Launch type-safe modals from any React component and render them in one place.",
3
+ "version": "1.0.2",
4
+ "description": "Headless modal management for React 19: launch type-safe modals from anywhere, stack them, resolve them async and keep per-modal state. You own the UI.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
7
7
  "module": "./dist/index.js",
@@ -46,11 +46,25 @@
46
46
  },
47
47
  "keywords": [
48
48
  "react",
49
- "react-component",
49
+ "react-19",
50
50
  "modal",
51
51
  "dialog",
52
+ "modal-manager",
53
+ "modal-stack",
54
+ "headless",
55
+ "unstyled",
56
+ "hooks",
57
+ "react-hooks",
52
58
  "typescript",
53
- "stack"
59
+ "type-safe",
60
+ "confirm",
61
+ "promise",
62
+ "async",
63
+ "state-management",
64
+ "dialog-element",
65
+ "a11y",
66
+ "server-components",
67
+ "nextjs"
54
68
  ],
55
69
  "author": "Oliver Greuter-Wehn",
56
70
  "license": "MIT",
package/readme.md CHANGED
@@ -1,63 +1,98 @@
1
1
  # react-modal-port
2
2
 
3
- Launch modals from any React component, render them in one place, and have TypeScript check every launch.
3
+ [![npm version](https://img.shields.io/npm/v/react-modal-port.svg)](https://www.npmjs.com/package/react-modal-port)
4
+ [![CI](https://github.com/oliverwehn/react-modal-port/actions/workflows/ci.yml/badge.svg)](https://github.com/oliverwehn/react-modal-port/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/npm/l/react-modal-port.svg)](./LICENSE)
4
6
 
5
- - **Bring your own UI.** Modals and backdrops are your components; the library ships no styles or markup.
6
- - **Launch from anywhere.** `useModal()` returns a stable `launchModal` function that can be called from components, effects or other modals.
7
- - **One outlet.** `<ModalPort>` renders the top modal of a stack, wherever you put it (or into a portal).
8
- - **Type-safe.** Resolvers and props are checked against the modal component's props.
9
- - **Async-aware stacking.** A modal closes when one of its resolvers settles, even if other modals were stacked on top in the meantime.
7
+ **Headless modal management for React.** You own the modal: markup, styles, accessibility and animation. react-modal-port owns the logic: launching modals from anywhere, stacking them, resolving them (async included) and keeping per-modal state, with every launch type-checked against your component's props.
10
8
 
11
- **Requires React 19.** Upgrading from 0.x? See [MIGRATION.md](./MIGRATION.md).
12
-
13
- **Demos:** [Basics](https://codepen.io/oliverwehn/pen/YzMyoBr) (confirm, stacking, async resolvers, programmatic close) · [Animated modals](https://github.com/oliverwehn/react-modal-port/tree/main/demo/codepen-animated) (dialog, drawer, bottom sheet)
9
+ **Demos on CodePen:** [Usage examples](https://codepen.io/oliverwehn/pen/018e18fb-0f15-724c-8fdf-4f47406f37d9) · [Animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22) (sources in [`demo/`](./demo))
14
10
 
15
11
  ---
16
12
 
13
+ - [Headless by design](#headless-by-design)
14
+ - [How it works](#how-it-works)
17
15
  - [Installation](#installation)
18
- - [Setup](#setup)
19
- - [Launching modals](#launching-modals)
20
- - [Asynchronous resolution](#asynchronous-resolution)
21
- - [Dismissing modals](#dismissing-modals)
22
- - [Stacking and modal state](#stacking-and-modal-state)
23
- - [An accessible backdrop with `<dialog>`](#an-accessible-backdrop-with-dialog)
16
+ - [Quick start](#quick-start)
17
+ - [Type-safe launches](#type-safe-launches)
18
+ - [Recipe: `await confirm()`](#recipe-await-confirm)
19
+ - [Async resolvers](#async-resolvers)
20
+ - [Dismissible modals](#dismissible-modals)
21
+ - [Stacked modals and modal state](#stacked-modals-and-modal-state)
22
+ - [Accessible backdrop with native `<dialog>`](#accessible-backdrop-with-native-dialog)
24
23
  - [Animating modals in and out](#animating-modals-in-and-out)
24
+ - [Next.js and Server Components](#nextjs-and-server-components)
25
25
  - [API reference](#api-reference)
26
26
 
27
+ ## Headless by design
28
+
29
+ Most modal libraries give you a modal *component* and ask you to bend your design system around it. react-modal-port is the opposite: it ships **no markup and no styles**. Your modals are ordinary React components, and the library manages *which* modal is shown, *when* it closes and *what state it keeps*.
30
+
31
+ | react-modal-port handles | You decide |
32
+ | --- | --- |
33
+ | Launching a modal from any component, hook or other modal | What modals and backdrops look like |
34
+ | A stack of modals, rendered at one place in your app | Markup, styling and your design system's dialog component |
35
+ | Resolving: a modal stays open until its resolver has run (or its promise has settled), then exactly that modal closes | Focus handling, scroll locking and other accessibility details |
36
+ | Per-modal state that survives while other modals cover it | Enter and exit animations |
37
+ | Type-checking every launch against the modal's props | Where in the DOM modals render (in place or via a portal) |
38
+
39
+ Because the backdrop is your component too, react-modal-port works with a native `<dialog>`, with your component library's dialog, or with a plain `<div>`.
40
+
41
+ ## How it works
42
+
43
+ 1. **`<ModalProvider>`** holds the modal stack. Wrap your app in it.
44
+ 2. **`<ModalPort>`** is the outlet: it renders the top modal of the stack inside your **backdrop** component.
45
+ 3. **`launchModal(Component, resolvers, props)`**, from the `useModal()` hook, pushes a modal onto the stack.
46
+ 4. **Resolvers** are the modal's function props that *end* it. When the modal calls one, your function runs, and then that modal is removed. Any other function you pass in `props` is just a callback and leaves the modal open.
47
+ 5. **Modal state** (`useModalState()`) belongs to one modal. It is kept while other modals are stacked on top and can be handed on to them.
48
+
27
49
  ## Installation
28
50
 
29
51
  ```bash
30
52
  npm install react-modal-port
31
53
  ```
32
54
 
33
- ## Setup
55
+ Requires React 19. Upgrading from 0.x? See [MIGRATION.md](./MIGRATION.md).
56
+
57
+ ## Quick start
58
+
59
+ A modal is a plain component. Its resolvers, here `onConfirm` and `onCancel`, are normal props:
60
+
61
+ ```tsx
62
+ // confirm-modal.tsx
63
+ export type ConfirmModalProps = {
64
+ question: string;
65
+ onConfirm: () => void;
66
+ onCancel: () => void;
67
+ };
68
+
69
+ export function ConfirmModal({ question, onConfirm, onCancel }: ConfirmModalProps) {
70
+ return (
71
+ <div className="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
72
+ <h2 id="confirm-title">{question}</h2>
73
+ <button type="button" onClick={onCancel}>Cancel</button>
74
+ <button type="button" onClick={onConfirm}>Confirm</button>
75
+ </div>
76
+ );
77
+ }
78
+ ```
34
79
 
35
- Wrap your app in `ModalProvider` and place one `ModalPort` where modals should render. `backdrop` is the component the current modal is rendered into.
80
+ Add the provider and the port once, with a backdrop of your own:
36
81
 
37
82
  ```tsx
83
+ // app.tsx
38
84
  import type { ReactNode } from 'react';
39
85
  import { ModalPort, ModalProvider, type ModalPortRenderProps } from 'react-modal-port';
40
86
 
41
87
  function Backdrop({ children, onBackdropClick }: ModalPortRenderProps) {
42
88
  return (
43
- <div
44
- onClick={onBackdropClick}
45
- style={{
46
- position: 'fixed',
47
- inset: 0,
48
- zIndex: 1000,
49
- display: 'flex',
50
- alignItems: 'center',
51
- justifyContent: 'center',
52
- background: 'rgba(0, 0, 0, 0.65)',
53
- }}
54
- >
89
+ <div className="backdrop" onClick={onBackdropClick}>
55
90
  {children}
56
91
  </div>
57
92
  );
58
93
  }
59
94
 
60
- export function RootLayout({ children }: { children: ReactNode }) {
95
+ export function App({ children }: { children: ReactNode }) {
61
96
  return (
62
97
  <ModalProvider>
63
98
  {children}
@@ -67,129 +102,151 @@ export function RootLayout({ children }: { children: ReactNode }) {
67
102
  }
68
103
  ```
69
104
 
70
- The package is marked `"use client"`, so it can be imported from React Server Component layouts (for example in Next.js).
71
-
72
- ## Launching modals
73
-
74
- A modal is a plain component. The functions it calls to finish are its **resolvers**.
105
+ Launch the modal from anywhere inside the provider:
75
106
 
76
107
  ```tsx
77
- type DecisionModalProps = {
78
- question: string;
79
- decideYay: () => void;
80
- decideNay: () => void;
81
- };
82
-
83
- export function DecisionModal({ question, decideYay, decideNay }: DecisionModalProps) {
84
- return (
85
- <div role="dialog" aria-modal="true" aria-labelledby="decision-title">
86
- <h2 id="decision-title">{question}</h2>
87
- <button type="button" onClick={decideYay}>Yay</button>
88
- <button type="button" onClick={decideNay}>Nay</button>
89
- </div>
90
- );
91
- }
92
- ```
93
-
94
- Launch it with `launchModal(Component, resolvers, props?, options?)`:
95
-
96
- ```tsx
97
- import { useState } from 'react';
108
+ // delete-button.tsx
98
109
  import { useModal } from 'react-modal-port';
99
- import { DecisionModal } from './decision-modal';
110
+ import { ConfirmModal } from './confirm-modal';
100
111
 
101
- export function Page() {
112
+ export function DeleteButton({ onDelete }: { onDelete: () => void }) {
102
113
  const launchModal = useModal();
103
- const [decision, setDecision] = useState<boolean | null>(null);
104
-
105
- const ask = () => {
106
- launchModal(
107
- DecisionModal,
108
- { decideYay: () => setDecision(true), decideNay: () => setDecision(false) },
109
- { question: 'Ship it?' },
110
- );
111
- };
112
114
 
113
115
  return (
114
- <>
115
- <p>{decision === null ? 'Make your decision!' : `Your decision: ${decision ? 'Yay' : 'Nay'}`}</p>
116
- <button type="button" onClick={ask}>Decide now</button>
117
- </>
116
+ <button
117
+ type="button"
118
+ onClick={() =>
119
+ launchModal(
120
+ ConfirmModal,
121
+ { onConfirm: onDelete, onCancel: () => {} }, // resolvers: each one closes the modal
122
+ { question: 'Delete this file?' }, // the remaining props
123
+ )
124
+ }
125
+ >
126
+ Delete
127
+ </button>
118
128
  );
119
129
  }
120
130
  ```
121
131
 
132
+ That's the whole setup. Styling `.backdrop` and `.modal` is up to you; for a ready-made accessible backdrop, see [Accessible backdrop with native `<dialog>`](#accessible-backdrop-with-native-dialog).
133
+
134
+ ## Type-safe launches
135
+
136
+ `launchModal(Component, resolvers, props?, options?)` reads the modal's props from `Component` and checks the other arguments against them:
137
+
122
138
  | Argument | Description |
123
139
  | --- | --- |
124
- | `Component` | The modal component. Its props define what the other arguments must contain. |
125
- | `resolvers` | Function props of the modal that close it. Each one is called with the modal's arguments, and the modal is removed once it returns (or its promise resolves). Calling a second resolver after the first is ignored. |
126
- | `props` | The modal's remaining props. Required when the modal still has required props, otherwise optional. |
127
- | `options` | `{ onDismiss }`: see [Dismissing modals](#dismissing-modals). |
140
+ | `Component` | The modal component. |
141
+ | `resolvers` | The modal's function props that close it. The modal is removed after the resolver returns, or after its promise settles. Only the first resolver call counts; later calls are ignored. |
142
+ | `props` | The modal's remaining props. Required when required props remain, otherwise optional. |
143
+ | `options` | `{ onDismiss }`, see [Dismissible modals](#dismissible-modals). |
128
144
 
129
- TypeScript catches the common mistakes:
145
+ TypeScript catches the usual mistakes:
130
146
 
131
147
  ```tsx
132
- launchModal(DecisionModal, { decideYey: () => {} }, { question: '?' }); // ✗ unknown resolver
133
- launchModal(DecisionModal, { decideYay: () => {}, decideNay: () => {} }); // ✗ `question` is missing
148
+ const noop = () => {};
149
+ launchModal(ConfirmModal, { onConfirm: noop, onCancle: noop }, { question: '?' }); // ✗ unknown resolver "onCancle"
150
+ launchModal(ConfirmModal, { onConfirm: noop, onCancel: noop }); // ✗ prop "question" is missing
151
+ launchModal(ConfirmModal, { onConfirm: noop, onCancel: noop }, { question: 42 }); // ✗ "question" must be a string
134
152
  ```
135
153
 
136
- A function prop that is *not* listed in `resolvers` can be passed in `props`. It is then just a callback and does not close the modal.
137
-
138
154
  `launchModal` returns a handle, `{ id, close() }`. `close()` removes that modal without calling a resolver, for example after a timeout.
139
155
 
140
- ## Asynchronous resolution
156
+ ## Recipe: `await confirm()`
141
157
 
142
- If a resolver returns a promise, the modal stays open until it settles:
158
+ Because resolvers are plain functions, a promise-based API is a few lines on top of `launchModal`:
143
159
 
144
160
  ```tsx
161
+ import { useCallback } from 'react';
162
+ import { useModal } from 'react-modal-port';
163
+ import { ConfirmModal } from './confirm-modal';
164
+
165
+ export function useConfirm() {
166
+ const launchModal = useModal();
167
+ return useCallback(
168
+ (question: string) =>
169
+ new Promise<boolean>((resolve) => {
170
+ launchModal(
171
+ ConfirmModal,
172
+ { onConfirm: () => resolve(true), onCancel: () => resolve(false) },
173
+ { question },
174
+ { onDismiss: () => resolve(false) },
175
+ );
176
+ }),
177
+ [launchModal],
178
+ );
179
+ }
180
+
181
+ // In a component:
182
+ // const confirm = useConfirm();
183
+ // if (await confirm('Discard your changes?')) discard();
184
+ ```
185
+
186
+ ## Async resolvers
187
+
188
+ When a resolver returns a promise, the modal stays open until the promise settles:
189
+
190
+ ```tsx
191
+ // SaveModal is your component with these props; saveDraft() is your API call.
192
+ type SaveModalProps = { onSave: () => Promise<void>; onCancel: () => void };
193
+
145
194
  launchModal(SaveModal, {
146
- onSave: async (draft: Draft) => {
147
- await api.save(draft); // the modal is still shown while this runs
195
+ onSave: async () => {
196
+ await saveDraft(); // the modal is still shown while this runs
148
197
  },
198
+ onCancel: () => {},
149
199
  });
150
200
  ```
151
201
 
152
- - **Fulfilled:** the modal closes. This happens even if other modals were stacked on top of it in the meantime; only *this* modal is removed.
153
- - **Rejected or thrown:** the modal stays open and the promise returned to the modal component rejects with the same error, so the modal can show it and let the user retry. Resolvers can be called again after a failure.
202
+ - **Fulfilled:** the modal closes. Only *this* modal is removed, even if other modals were stacked on top of it in the meantime.
203
+ - **Rejected or thrown:** the modal stays open, and the promise returned to the modal component rejects with the same error. The modal can show the error and let the user try again.
154
204
 
155
- ## Dismissing modals
205
+ ## Dismissible modals
156
206
 
157
- Pass `onDismiss` in the options to make a modal dismissible from outside its content:
207
+ Pass `onDismiss` in the options to let users close a modal from outside its content:
158
208
 
159
209
  ```tsx
160
- launchModal(DecisionModal, resolvers, { question: 'Ship it?' }, { onDismiss: () => setDecision(null) });
210
+ // In DeleteButton from the quick start:
211
+ launchModal(
212
+ ConfirmModal,
213
+ { onConfirm: onDelete, onCancel: () => {} },
214
+ { question: 'Delete this file?' },
215
+ { onDismiss: () => console.log('dismissed') },
216
+ );
161
217
  ```
162
218
 
163
- The backdrop then receives `onBackdropClick`, which calls `onDismiss` (and closes the modal) only for clicks on the backdrop itself, not for clicks that bubble up from the modal. For modals launched without `onDismiss`, `onBackdropClick` is `undefined`, so the backdrop can tell whether the current modal is dismissible.
219
+ Your backdrop then receives `onBackdropClick`. It calls `onDismiss` and closes the modal, but only for clicks on the backdrop itself, not for clicks inside the modal. For modals launched without `onDismiss`, `onBackdropClick` is `undefined`, so the backdrop can tell whether the current modal is dismissible and wire Escape the same way.
164
220
 
165
- ## Stacking and modal state
221
+ ## Stacked modals and modal state
166
222
 
167
- Modals launched while another one is open are stacked. The port shows the top one and returns to the previous one when it closes. Each stacked modal keeps its own **modal state**, read and updated with `useModalState()` from inside the modal. Modal state survives while other modals are on top and is discarded when the modal closes.
223
+ A modal can launch another one. The port shows the top modal and returns to the previous one when the top one closes.
168
224
 
169
- Component state (`useState`) inside a modal does **not** survive being covered, because only the top modal is mounted. Use modal state for anything that should outlive a nested modal.
225
+ Only the top modal is mounted, so component state (`useState`) inside a covered modal is lost. For anything that should outlive a nested modal, use **modal state**: `useModalState()` gives each modal its own state, which is kept while other modals cover it and discarded when the modal closes. Since it's ordinary data, the modal can also hand it on to the next modal.
226
+
227
+ Here a name form opens the confirmation from the quick start on top of itself. The confirmation gets the name from the form's modal state, and confirming resolves the form with it as well:
170
228
 
171
229
  ```tsx
172
230
  import { useModal, useModalState } from 'react-modal-port';
173
231
  import { ConfirmModal } from './confirm-modal';
174
232
 
175
- type AskForNameProps = { provideName: (name: string) => void };
176
-
177
- export function AskForNameModal({ provideName }: AskForNameProps) {
233
+ export function AskForNameModal({ provideName }: { provideName: (name: string) => void }) {
178
234
  const launchModal = useModal();
179
235
  const [state, setState] = useModalState<{ name: string }>();
180
236
  const name = state?.name ?? '';
181
237
 
182
- const confirm = () => {
238
+ const next = () =>
183
239
  launchModal(
184
240
  ConfirmModal,
185
- // Resolving the confirmation also resolves this modal.
186
- { confirm: (ok: boolean) => { if (ok) provideName(name); } },
187
- { name },
241
+ {
242
+ onConfirm: () => provideName(name), // resolves this form too, with its state
243
+ onCancel: () => {}, // back to the form, input intact
244
+ },
245
+ { question: `Call you “${name}”?` }, // modal state handed on as a prop
188
246
  );
189
- };
190
247
 
191
248
  return (
192
- <div role="dialog" aria-modal="true" aria-labelledby="name-title">
249
+ <div className="modal" role="dialog" aria-modal="true" aria-labelledby="name-title">
193
250
  <h2 id="name-title">How should we call you?</h2>
194
251
  <input
195
252
  aria-label="Name"
@@ -199,17 +256,17 @@ export function AskForNameModal({ provideName }: AskForNameProps) {
199
256
  setState((prev) => ({ ...prev, name: value }));
200
257
  }}
201
258
  />
202
- <button type="button" onClick={confirm}>Set name</button>
259
+ <button type="button" onClick={next}>Next</button>
203
260
  </div>
204
261
  );
205
262
  }
206
263
  ```
207
264
 
208
- `setState` accepts a new state object or an updater function, like React's own `setState`. Prefer the updater form when the new state depends on the old one.
265
+ `setState` accepts a new state object or an updater function, like React's `setState`.
209
266
 
210
- ## An accessible backdrop with `<dialog>`
267
+ ## Accessible backdrop with native `<dialog>`
211
268
 
212
- The library leaves markup and accessibility to you, so they can match your design system. The native `<dialog>` element covers most of it: shown with `showModal()`, it sits in the top layer, makes the rest of the page inert, traps focus and fires `cancel` on Escape.
269
+ Accessibility stays in your hands so it can match your design system. The native `<dialog>` element does most of the work: opened with `showModal()`, it renders in the top layer, makes the rest of the page inert, traps focus and fires `cancel` on Escape.
213
270
 
214
271
  ```tsx
215
272
  import { useEffect, useRef } from 'react';
@@ -230,7 +287,7 @@ export function DialogBackdrop({ children, onBackdropClick }: ModalPortRenderPro
230
287
  className="modal-backdrop"
231
288
  onClick={onBackdropClick}
232
289
  onCancel={(event) => {
233
- event.preventDefault(); // let the library decide whether the modal closes
290
+ event.preventDefault(); // Escape: let the library decide whether the modal closes
234
291
  onBackdropClick?.(event);
235
292
  }}
236
293
  >
@@ -241,22 +298,30 @@ export function DialogBackdrop({ children, onBackdropClick }: ModalPortRenderPro
241
298
  ```
242
299
 
243
300
  ```css
301
+ .modal-backdrop {
302
+ /* clip, not hidden: focus() can't scroll a clipped dialog, which matters for
303
+ modals that animate in from off-screen */
304
+ overflow: clip;
305
+ }
306
+
244
307
  /* Lock page scroll while a modal is open */
245
308
  body:has(dialog.modal-backdrop[open]) { overflow: hidden; }
246
309
  ```
247
310
 
248
- Give each modal an accessible name (`aria-labelledby` or `aria-label`), and move focus into it if the first focusable element is not the right target.
311
+ Give each modal an accessible name (`aria-labelledby` or `aria-label`). The dialog stays open while stacked modals swap inside it, so move focus into each modal when it mounts.
249
312
 
250
313
  ## Animating modals in and out
251
314
 
252
- **Enter:** every modal remounts when it becomes the top of the stack, so a CSS animation on the modal's root element plays each time it appears, including when it reappears after a modal on top of it closes.
315
+ **Enter:** every modal remounts when it becomes the top of the stack, including when it reappears after the modal above it closes. A CSS animation on the modal's root element therefore plays each time:
253
316
 
254
317
  ```css
255
318
  .modal { animation: pop-in 250ms ease-out both; }
256
319
  @keyframes pop-in { from { opacity: 0; transform: scale(0.96); } }
257
320
  ```
258
321
 
259
- **Exit:** a modal stays mounted until its resolver settles, so a resolver that first awaits an exit animation gets a clean exit animation. A small wrapper around `launchModal` applies this to every resolver and to `onDismiss`:
322
+ If the animation moves the modal in from off-screen (a drawer or a bottom sheet), focus it with `element.focus({ preventScroll: true })`. A plain `focus()` scrolls the off-screen element into view and cancels the slide.
323
+
324
+ **Exit:** a modal stays mounted until its resolver settles, so a resolver that first awaits an exit animation gets a clean exit. A small wrapper around `launchModal` applies this to every resolver and to `onDismiss`:
260
325
 
261
326
  ```tsx
262
327
  import { useModal, type LaunchModal } from 'react-modal-port';
@@ -289,35 +354,73 @@ export function useAnimatedModal(): LaunchModal {
289
354
  }
290
355
  ```
291
356
 
292
- The backdrop receives `modalId` and `stackSize`, so `playExit` can check that the modal is the one on screen, and fade the backdrop out too when it is the last one. The [animated demo](https://github.com/oliverwehn/react-modal-port/tree/main/demo/codepen-animated) has a complete version using the Web Animations API, with drawer and bottom-sheet variants and support for `prefers-reduced-motion`.
357
+ The backdrop receives `modalId` and `stackSize`, so `playExit` can check that the modal is the one on screen, and fade the backdrop out too when it is the last one. The [animation examples](https://codepen.io/oliverwehn/pen/01a0fb4d-2c07-7c34-af66-1ac02a634b22) have a complete version using the Web Animations API, with a drawer, a bottom sheet, stacked modals shown as a deck, and support for `prefers-reduced-motion` (source in [`demo/codepen-animated/`](./demo/codepen-animated)).
358
+
359
+ ## Next.js and Server Components
360
+
361
+ The package is marked `"use client"`, so `ModalProvider` and `ModalPort` can be rendered from a Server Component layout, for example a Next.js `app/layout.tsx`. Your backdrop is passed to `ModalPort` as a component, so define the provider, port and backdrop together in a small client component and render that from the layout. The modals themselves are client components, since they use event handlers and hooks.
293
362
 
294
363
  ## API reference
295
364
 
296
365
  ### `<ModalProvider>`
297
366
 
298
- Holds the modal stack. Wrap your app (or the part that uses modals) in it. `ModalContextProvider` is a deprecated alias.
367
+ Holds the modal stack. Wrap your app, or the part of it that uses modals. `ModalContextProvider` is a deprecated alias.
299
368
 
300
369
  ### `<ModalPort>`
301
370
 
302
371
  | Prop | Type | Description |
303
372
  | --- | --- | --- |
304
- | `backdrop` | `ComponentType<ModalPortRenderProps>` | Renders around the current modal. Receives `children`, `onBackdropClick` (undefined unless the modal is dismissible), `modalId` and `stackSize`. Optional. |
305
- | `container` | `Element \| DocumentFragment` | Render into this element through a portal. |
373
+ | `backdrop` | `ComponentType<ModalPortRenderProps>` | Optional. Rendered around the current modal. |
374
+ | `container` | `Element \| DocumentFragment` | Render into this element through a portal instead of in place. |
306
375
  | `onModalLaunch` | `() => void` | The stack went from empty to non-empty. |
307
376
  | `onModalClose` | `() => void` | The stack became empty. |
308
- | `onStackChange` | `(size: number) => void` | Any modal was launched or closed. |
377
+ | `onStackChange` | `(size: number) => void` | A modal was launched or closed. |
309
378
  | `render` | | Deprecated alias of `backdrop`. |
310
379
 
380
+ The backdrop receives these props (`ModalPortRenderProps`):
381
+
382
+ | Prop | Type | Description |
383
+ | --- | --- | --- |
384
+ | `children` | `ReactNode` | The current modal. |
385
+ | `onBackdropClick` | `((event) => void) \| undefined` | Dismisses the modal. `undefined` unless it was launched with `onDismiss`. Ignores clicks that bubble up from the modal. |
386
+ | `modalId` | `number` | Id of the modal shown. |
387
+ | `stackSize` | `number` | Number of modals on the stack, including the one shown. |
388
+
389
+ ### `launchModal`
390
+
391
+ ```ts
392
+ launchModal(Component, resolvers, props?, options?): ModalHandle
393
+ ```
394
+
395
+ | | |
396
+ | --- | --- |
397
+ | `resolvers` | A subset of the modal's function props. Each is wrapped so that the modal closes after it has run or its promise has settled; on a throw or rejection the modal stays open. |
398
+ | `props` | The modal's remaining props. |
399
+ | `options.onDismiss` | Enables dismissing from outside the modal; wrapped like a resolver. |
400
+ | returns `{ id, close }` | `close()` removes the modal without calling a resolver. A no-op once the modal is gone. |
401
+
311
402
  ### Hooks
312
403
 
313
404
  | Hook | Returns |
314
405
  | --- | --- |
315
- | `useModal()` | `launchModal`. Stable across renders; components using only this hook do not re-render when modals open or close. |
316
- | `useModalState<S>()` | `[state \| null, setState]` for the modal the hook is called in. Called outside a modal, it refers to the top modal (`null` if none). |
406
+ | `useModal()` | `launchModal`. Stable across renders; components that only call this hook do not re-render when modals open or close. |
407
+ | `useModalState<S>()` | `[state \| null, setState]` for the modal the hook is called in. Outside a modal, it refers to the top modal (`null` if there is none). |
317
408
  | `useModalStack()` | A read-only array of `{ id, render, props, state }`, bottom first. |
318
409
  | `useModalContext()` | Deprecated. `{ stack, launchModal, updateState }`; re-renders on every stack change. |
319
410
 
320
- All hooks throw if used outside a `ModalProvider`.
411
+ All hooks throw when used outside a `ModalProvider`.
412
+
413
+ ### Types
414
+
415
+ | Type | Description |
416
+ | --- | --- |
417
+ | `LaunchModal` | The type of `launchModal`. |
418
+ | `LaunchOptions` | `{ onDismiss? }` |
419
+ | `ModalHandle` | `{ id, close }`, returned by `launchModal`. |
420
+ | `ModalPortProps`, `ModalPortRenderProps` | Props of `ModalPort` and of your backdrop. |
421
+ | `ModalState`, `UpdateModalState<S>` | Modal state and its setter. |
422
+ | `ModalStackEntry` | An item of `useModalStack()`. |
423
+ | `FunctionKeys<P>`, `Resolvers<P, R>`, `RestProps<P, R>` | Helpers behind the `launchModal` signature, useful for wrappers like `useConfirm`. |
321
424
 
322
425
  ## License
323
426