# Next.js Offcanvas

A Next.js offcanvas client component for the App Router: Bootstrap side panels in the static HTML, a backdrop added after hydration, four placements.

`Offcanvas` slides a panel in from an edge of the window. It suits filters or extra navigation that should not cover the whole page. On small screens, the AdminKit sidebar is one. The panel is Bootstrap's offcanvas, opened from React state instead of Bootstrap's JavaScript. It is in the free and PRO versions of [AdminKit for Next.js](/nextjs/).

As with the [modal](/nextjs/docs/components/modal/), the state lives in your code: `show` opens the panel and `onHide` asks to close it. In the App Router, pages are server components: they render on the server and have no state. So put the button, the state and the `Offcanvas` in a small client component, a file that starts with `"use client"`. Then render that component from your page. `Offcanvas` itself is a client component too.

## Example

```html
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
  Filters
</button>

<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
  <div class="offcanvas-header">
    <h2 class="offcanvas-title h5" id="offcanvasFiltersLabel">Filters</h2>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  </div>
  <div class="offcanvas-body">
    <p>Show only the orders that match.</p>
    <button type="button" class="btn btn-primary" data-bs-dismiss="offcanvas">Apply</button>
  </div>
</div>
```
```tsx code title="src/components/order-filters.tsx"
"use client";

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

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

  return (
    <>
      <button
        className="btn btn-primary"
        type="button"
        aria-controls="offcanvasFilters"
        onClick={() => setShow(true)}
      >
        Filters
      </button>
      <Offcanvas
        id="offcanvasFilters"
        show={show}
        onHide={close}
        aria-labelledby="offcanvasFiltersLabel"
      >
        <div className="offcanvas-header">
          <h2 className="offcanvas-title h5" id="offcanvasFiltersLabel">Filters</h2>
          <CloseButton onClick={close} />
        </div>
        <div className="offcanvas-body">
          <p>Show only the orders that match.</p>
          <button type="button" className="btn btn-primary" onClick={close}>Apply</button>
        </div>
      </Offcanvas>
    </>
  );
}
```

The page renders it like any component:

```tsx title="src/app/(dashboard)/orders/page.tsx"
import { OrderFilters } from "@/components/order-filters";
import { pageMetadata } from "@/lib/site";

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

export default function OrdersPage() {
  return (
    <>
      <h1 className="h3 mb-3">Orders</h1>
      <OrderFilters />
    </>
  );
}
```

`pageMetadata()` reads the route's title and description 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)).

## Server rendering

Unlike the modal, the panel stays in the DOM while it is hidden. So it is part of the HTML that Next.js builds for the page, hidden by `.offcanvas`.

The backdrop is different. It is rendered into `document.body` through a portal, and there is no `document` on the server. So the component renders the backdrop only once `useMounted()` (`src/hooks/use-mounted.ts`) says the page runs in the browser. The server HTML and the browser's first render then match. When React takes over the static HTML in the browser (hydration), it finds nothing that differs, so there is no hydration error.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `show` | `boolean` | | Whether the panel is open |
| `onHide` | `() => void` | | Called on Escape and on a backdrop click; set `show` to false here |
| `onShown` | `() => void` | | Called when the slide-in transition has ended |
| `onHidden` | `() => void` | | Called when the slide-out transition has ended |
| `placement` | `"start" \| "end" \| "top" \| "bottom"` | `"start"` | The edge it slides in from |
| `responsive` | `"sm" \| "md" \| "lg" \| "xl" \| "xxl"` | | A sheet only below this breakpoint, part of the page from it up |
| `backdrop` | `boolean \| "static"` | `true` | `true`: a backdrop that closes the panel on click; `"static"`: a backdrop that does not; `false`: none |
| `scroll` | `boolean` | `false` | Keeps the page scrollable while the panel is open |
| `keyboard` | `boolean` | `true` | Escape closes the panel |
| `as` | `ElementType` | `"div"` | The element, such as `"nav"` or `"aside"` |

Other props, such as `id`, `className`, `ref` and `aria-labelledby`, go to the `.offcanvas` element. `onHide`, `onShown` and `onHidden` are functions: only a client component can pass them. The component is in `src/components/bootstrap/offcanvas.tsx`.

## Placement

The panel comes in from the left unless you choose another edge. `placement="end"` adds `.offcanvas-end` and slides the panel in from the right. `"top"` and `"bottom"` use the other edges.

```html
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasActivity" aria-controls="offcanvasActivity">
  Activity
</button>

<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasActivity" aria-labelledby="offcanvasActivityLabel">
  <div class="offcanvas-header">
    <h2 class="offcanvas-title h5" id="offcanvasActivityLabel">Activity</h2>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  </div>
  <div class="offcanvas-body">No new activity today.</div>
</div>
```
```tsx code
<Offcanvas
  id="offcanvasActivity"
  placement="end"
  show={show}
  onHide={close}
  aria-labelledby="offcanvasActivityLabel"
>
  <div className="offcanvas-header">
    <h2 className="offcanvas-title h5" id="offcanvasActivityLabel">Activity</h2>
    <CloseButton onClick={close} />
  </div>
  <div className="offcanvas-body">No new activity today.</div>
</Offcanvas>
```

## Backdrop and scrolling

The defaults are a backdrop and a locked page. When the user should keep using the page next to the panel, add `scroll` and `backdrop={false}`. The page then stays scrollable and usable:

```html
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasNotes" aria-controls="offcanvasNotes">
  Notes
</button>

<div class="offcanvas offcanvas-start" data-bs-scroll="true" data-bs-backdrop="false" tabindex="-1" id="offcanvasNotes" aria-labelledby="offcanvasNotesLabel">
  <div class="offcanvas-header">
    <h2 class="offcanvas-title h5" id="offcanvasNotesLabel">Notes</h2>
    <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  </div>
  <div class="offcanvas-body">Scroll the page: it stays scrollable while these notes are open.</div>
</div>
```
```tsx code
<Offcanvas
  id="offcanvasNotes"
  scroll
  backdrop={false}
  show={show}
  onHide={close}
  aria-labelledby="offcanvasNotesLabel"
>
  <div className="offcanvas-header">
    <h2 className="offcanvas-title h5" id="offcanvasNotesLabel">Notes</h2>
    <CloseButton onClick={close} />
  </div>
  <div className="offcanvas-body">Scroll the page: it stays scrollable while these notes are open.</div>
</Offcanvas>
```

With `backdrop="static"`, the backdrop is there but a click on it does nothing. Escape and your buttons still close the panel.

## Responsive sidebar

The sidebar is a sheet on small screens and part of the page on large ones. `responsive="lg"` does that: it renders `.offcanvas-lg`, a sheet below the `lg` breakpoint and part of the page from `lg` up. `src/components/sidebar/sidebar.tsx` uses it with `as="nav"`, and the navbar toggle opens it through the layout context.

```tsx
<Offcanvas
  as="nav"
  id="sidebar"
  responsive="lg"
  show={sheetOpen}
  onHide={() => setSheetOpen(false)}
  aria-label="Sidebar"
>
  ...
</Offcanvas>
```

## Accessibility

- While open, the panel has `role="dialog"` and `aria-modal="true"`. Name it with `aria-labelledby` or `aria-label`.
- When it has slid in, the focus moves to the panel and Tab stays inside. On close, the focus returns to the element that opened it. A panel with `scroll` and `backdrop={false}` does not trap the focus, as in Bootstrap.
- Escape closes it unless `keyboard={false}`.
- `CloseButton` has `aria-label="Close"`. Give the opening button `aria-controls` with the panel's `id`.

## Related

- [React offcanvas](/react/docs/components/offcanvas/): the same component in AdminKit for React (Vite)
- [Bootstrap 5 offcanvas](/bootstrap/docs/components/offcanvas/): the offcanvas of the HTML version
- [Modal](/nextjs/docs/components/modal/): a dialog in the middle of the window
- [Offcanvas demo](https://nextjs-demo.adminkit.io/ui-offcanvas): panels from every edge, with and without a backdrop

---

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

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
