Offcanvas

Slide-out panel from left or right edge with backdrop, scroll lock, keyboard dismiss, and multiple sizes — ideal for forms, navigation, and detail views.

Preview

Basic right offcanvas

import { Offcanvas, OffcanvasTitle, OffcanvasDescription, OffcanvasBody } from '@/registry/ui-kit/tailwind-offcanvas/react'
import { useState } from 'react'

function Example() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Open panel</button>
      <Offcanvas open={open} onClose={() => setOpen(false)}>
        <div className="px-6 py-4">
          <OffcanvasTitle>Panel title</OffcanvasTitle>
          <OffcanvasDescription>Some description text.</OffcanvasDescription>
        </div>
        <OffcanvasBody className="px-6 py-4">
          <p>Content goes here.</p>
        </OffcanvasBody>
      </Offcanvas>
    </>
  )
}

Component API

PropDefaultDescription
openfalseWhether the offcanvas panel is visible.
onCloseCallback fired when the panel should close.
position"right"Which edge the panel slides in from.
size"md"Max-width of the panel.
OffcanvasTitleHeading rendered inside the panel.
OffcanvasDescriptionSecondary text below the title.
OffcanvasBodyScrollable content area of the panel.

Source Code

"use client";

import React, { useEffect } from "react";

const sizeMap: Record<string, string> = {
  sm: "max-w-sm",
  md: "max-w-md",
  lg: "max-w-lg",
};

/* ── Offcanvas ──────────────────────────────────────── */
export function Offcanvas({
  open,
  onClose,
  position = "right",
  size = "md",
  children,
  className = "",
}: {
  open: boolean;
  onClose: () => void;
  position?: "left" | "right";
  size?: "sm" | "md" | "lg";
  children: React.ReactNode;
  className?: string;
}) {
  // Escape key
  useEffect(() => {
    function handleKey(e: KeyboardEvent) {
      if (e.key === "Escape") onClose();
    }
    if (open) {
      document.addEventListener("keydown", handleKey);
      return () => document.removeEventListener("keydown", handleKey);
    }
  }, [open, onClose]);

  // Body scroll lock
  useEffect(() => {
    if (open) {
      const original = document.body.style.overflow;
      document.body.style.overflow = "hidden";
      return () => {
        document.body.style.overflow = original;
      };
    }
  }, [open]);

  if (!open) return null;

  const sizeClass = sizeMap[size] || sizeMap.md;
  const isRight = position === "right";

  return (
    <div className="fixed inset-0 z-50">
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-zinc-950/25 backdrop-blur-sm dark:bg-zinc-950/50"
        aria-hidden="true"
        onClick={onClose}
      />
      {/* Panel */}
      <div
        className={`fixed inset-y-0 ${isRight ? "right-0" : "left-0"} flex w-full ${sizeClass} flex-col ${isRight ? "border-l" : "border-r"} border-zinc-200 bg-white shadow-xl dark:border-zinc-700 dark:bg-zinc-900 ${className}`}
      >
        {/* Header with close button */}
        <div className="flex items-center justify-between border-b border-zinc-100 px-6 py-4 dark:border-zinc-800">
          <div className="flex-1" />
          <button
            type="button"
            onClick={onClose}
            className="rounded-lg p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
            aria-label="Close"
          >
            <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
              <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        </div>
        {children}
      </div>
    </div>
  );
}

/* ── OffcanvasTitle ─────────────────────────────────── */
export function OffcanvasTitle({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <h3 className={`text-base font-semibold text-zinc-900 dark:text-zinc-100 ${className}`}>
      {children}
    </h3>
  );
}

/* ── OffcanvasDescription ───────────────────────────── */
export function OffcanvasDescription({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <p className={`text-sm text-zinc-500 dark:text-zinc-400 ${className}`}>
      {children}
    </p>
  );
}

/* ── OffcanvasBody ──────────────────────────────────── */
export function OffcanvasBody({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div className={`flex-1 overflow-y-auto ${className}`}>
      {children}
    </div>
  );
}