# Next.js Bootstrap Modal

A Next.js Bootstrap modal for the App Router: a client component opened from state in a server component page, with static, centered and fullscreen variants.

A modal asks for the user's attention before they go on: to confirm a delete, fill in a short form or read an important message. `Modal` gives your Next.js pages Bootstrap's dialog, with its look and fade, opened from React state instead of Bootstrap's JavaScript. It is in the free and PRO versions of [AdminKit for Next.js](/nextjs/).

In the App Router, pages are server components: they render on the server and have no state. A modal needs state to know whether it is open, so it goes in a small client component, a file that starts with `"use client"`. Put the button, the state and the `Modal` there, and render that component from your page. The page itself stays a server component and keeps its `metadata` export.

## Example

```html
<div class="modal" tabindex="-1" style="display: block; position: relative; z-index: 0;" aria-labelledby="modalExampleTitle">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h2 class="modal-title h5" id="modalExampleTitle">Delete project</h2>
        <button type="button" class="btn-close" aria-label="Close"></button>
      </div>
      <div class="modal-body">This cannot be undone.</div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary">Cancel</button>
        <button type="button" class="btn btn-danger">Delete</button>
      </div>
    </div>
  </div>
</div>
```
```tsx code title="src/components/delete-project.tsx"
"use client";

import { useState } from "react";
import { CloseButton } from "@/components/bootstrap/close-button";
import { Modal } from "@/components/bootstrap/modal";

export function DeleteProject() {
  const [show, setShow] = useState(false);
  const close = () => setShow(false);

  return (
    <>
      <button type="button" className="btn btn-danger" onClick={() => setShow(true)}>
        Delete project
      </button>
      <Modal show={show} onHide={close} aria-labelledby="delete-title">
        <div className="modal-header">
          <h2 className="modal-title h5" id="delete-title">Delete project</h2>
          <CloseButton onClick={close} />
        </div>
        <div className="modal-body">This cannot be undone.</div>
        <div className="modal-footer">
          <button type="button" className="btn btn-secondary" onClick={close}>Cancel</button>
          <button type="button" className="btn btn-danger" onClick={close}>Delete</button>
        </div>
      </Modal>
    </>
  );
}
```

The page imports it like any other component. `pageMetadata()` reads the route from `src/data/pages.json`, so a new page needs an entry there (see [Add a page](/nextjs/docs/getting-started/project-structure/#add-a-page)):

```tsx title="src/app/(dashboard)/projects/page.tsx"
import { DeleteProject } from "@/components/delete-project";
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/projects");

export default function ProjectsPage() {
  return (
    <>
      <h1 className="h3 mb-3">Projects</h1>
      <DeleteProject />
    </>
  );
}
```

The modal only exists in the browser. The HTML that Next.js builds for the page has the button but not the dialog, so the server and browser output never disagree. When it opens, the dialog is added at the end of `<body>`.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `show` | `boolean` | | Whether the modal is open |
| `onHide` | `() => void` | | Called on Escape and on a backdrop click; set `show` to false here |
| `onExited` | `() => void` | | Called once the modal has faded out |
| `backdrop` | `boolean \| "static"` | `true` | `"static"`: a click outside shakes the dialog instead of closing it; `false`: no backdrop |
| `keyboard` | `boolean` | `true` | Escape closes the modal |
| `dialogClassName` | `string` | | Classes for `.modal-dialog`: `modal-lg`, `modal-dialog-centered`, ... |
| `contentClassName` | `string` | | Classes for `.modal-content` |
| `contentAs` | `ElementType` | `"div"` | `"form"` for a dialog that is a form |
| `contentProps` | `object` | | Other props for `.modal-content`, such as `onSubmit` |

Other props, such as `className`, `id` and `aria-labelledby`, go to the `.modal` element. A server component cannot pass a function, so the component that passes `onHide` is a client component too. The modal's code is in `src/components/bootstrap/modal.tsx`.

## Static backdrop

When the user must make a choice first, set `backdrop="static"` and `keyboard={false}`. A click next to the dialog or Escape then makes it bounce instead of closing.

```html
<div class="modal fade" id="modalStatic" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="modalStaticTitle" aria-hidden="true">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h2 class="modal-title h5" id="modalStaticTitle">Unsaved changes</h2>
      </div>
      <div class="modal-body">Save your changes before you leave this page?</div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Discard</button>
        <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Save</button>
      </div>
    </div>
  </div>
</div>

<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalStatic">
  Leave page
</button>
```
```tsx code
<Modal
  show={show}
  onHide={close}
  backdrop="static"
  keyboard={false}
  aria-labelledby="unsaved-title"
>
  <div className="modal-header">
    <h2 className="modal-title h5" id="unsaved-title">Unsaved changes</h2>
  </div>
  <div className="modal-body">Save your changes before you leave this page?</div>
  <div className="modal-footer">
    <button type="button" className="btn btn-secondary" onClick={discard}>Discard</button>
    <button type="button" className="btn btn-primary" onClick={save}>Save</button>
  </div>
</Modal>
```

## Centered, scrollable and sizes

`dialogClassName` takes Bootstrap's `.modal-dialog` classes:

| Class | Effect |
| --- | --- |
| `modal-dialog-centered` | Centers the dialog vertically |
| `modal-dialog-scrollable` | Scrolls the body instead of the page when the content is long |
| `modal-sm`, `modal-lg`, `modal-xl` | 400px, 900px and 1140px wide (600px by default) |
| `modal-fullscreen` | Covers the whole window; `modal-fullscreen-md-down` only below a breakpoint |

```html
<div class="modal fade" id="modalCentered" tabindex="-1" aria-labelledby="modalCenteredTitle" aria-hidden="true">
  <div class="modal-dialog modal-dialog-centered modal-lg">
    <div class="modal-content">
      <div class="modal-header">
        <h2 class="modal-title h5" id="modalCenteredTitle">Invite members</h2>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">A large, vertically centered dialog.</div>
    </div>
  </div>
</div>

<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#modalCentered">
  Invite members
</button>
```
```tsx code
<Modal
  show={show}
  onHide={close}
  dialogClassName="modal-dialog-centered modal-lg"
  aria-labelledby="invite-title"
>
  <div className="modal-header">
    <h2 className="modal-title h5" id="invite-title">Invite members</h2>
    <CloseButton onClick={close} />
  </div>
  <div className="modal-body">A large, vertically centered dialog.</div>
</Modal>
```

`className="modal-colored modal-primary"` (or another theme color) gives the modal a colored background, as in the demo.

## Form in a modal

AdminKit for Next.js builds to static files (`output: "export"`), so there is no server to run Server Actions. A form in a modal sends its data from the browser instead, for example with `fetch` to your API. Make the modal itself the form with `contentAs="form"`:

```tsx
<Modal
  show={show}
  onHide={close}
  contentAs="form"
  contentProps={{
    onSubmit: async (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      await fetch("/api/projects/rename", {
        method: "POST",
        body: new FormData(event.currentTarget),
      });
      close();
    },
  }}
  aria-labelledby="rename-title"
>
  <div className="modal-header">
    <h2 className="modal-title h5" id="rename-title">Rename project</h2>
    <CloseButton onClick={close} />
  </div>
  <div className="modal-body">
    <label htmlFor="project-name" className="form-label">Name</label>
    <input id="project-name" name="name" className="form-control" required />
  </div>
  <div className="modal-footer">
    <button type="submit" className="btn btn-primary">Save</button>
  </div>
</Modal>
```

## Accessibility

- The modal has `role="dialog"` and `aria-modal="true"`. Give it a title and point `aria-labelledby` at the title's `id`.
- While it is open, the focus stays inside the modal. When it closes, the focus returns to the element that opened it.
- Escape closes the modal, unless `keyboard={false}`. Then keep a visible button that closes it.
- `CloseButton` has `aria-label="Close"`.

## Related

- [React Bootstrap modal](/react/docs/components/modal/): the same component in AdminKit for React (Vite)
- [Bootstrap 5 modal](/bootstrap/docs/components/modal/): the modal of the HTML version
- [Offcanvas](/nextjs/docs/components/offcanvas/): a panel that slides in from the side
- [Modals demo](https://nextjs-demo.adminkit.io/ui-modals): default, colored, centered and sized modals

---

Canonical HTML page: https://adminkit.io/nextjs/docs/components/modal/

Site overview for agents: https://adminkit.io/llms.txt (expanded: https://adminkit.io/llms-full.txt) · Documentation: https://adminkit.io/bootstrap/docs/ · Questions: support@adminkit.io
