AdminKitDocs
Components

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.

As with the 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

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:

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).

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

PropTypeDefaultDescription
showbooleanWhether the panel is open
onHide() => voidCalled on Escape and on a backdrop click; set show to false here
onShown() => voidCalled when the slide-in transition has ended
onHidden() => voidCalled 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
backdropboolean | "static"truetrue: a backdrop that closes the panel on click; "static": a backdrop that does not; false: none
scrollbooleanfalseKeeps the page scrollable while the panel is open
keyboardbooleantrueEscape closes the panel
asElementType"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.

<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:

<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.

<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.

Last updated on

On this page