# Sheet

Side panel on the native <dialog> element. It slides in from either edge, and you can fling it back to close.

**Category:** Overlays  
**Exports:** `Sheet`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/sheet.json
```
The file lands at `components/ui/sheet.tsx`; import from `@/components/ui/sheet`.

## API — Sheet
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open / onOpenChange` | `boolean / (open: boolean) => void` | — |  |
| `side` | `"left" \| "right"` | `"right"` |  |
| `title / description` | `ReactNode` | — |  |
| `footer` | `ReactNode` | — |  |

## Example — Sheet

```tsx
import * as React from "react";
import { Sheet } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";

export default function SheetDemo({
  side = "right",
  title = "Edit profile",
  description = "Changes are saved when you press Save.",
}: {
  side?: "left" | "right";
  title?: string;
  description?: string;
}) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Edit profile
      </Button>
      <Sheet
        open={open}
        onOpenChange={setOpen}
        side={side}
        title={title}
        description={description}
        footer={
          <>
            <Button variant="ghost" className="flex-1" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button className="flex-1" onClick={() => setOpen(false)}>
              Save profile
            </Button>
          </>
        }
      >
        <div className="grid gap-5">
          <Input label="Display name" defaultValue="Charcoal" />
          <Input label="Email" type="email" defaultValue="hello@charcoal.dev" />
          <label className="flex items-center justify-between text-sm font-medium">
            Show me as online <Switch defaultChecked aria-label="Show me as online" />
          </label>
        </div>
      </Sheet>
    </>
  );
}
```

## Source — components/ui/sheet.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type SheetProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  side?: "left" | "right";
  title: React.ReactNode;
  description?: React.ReactNode;
  footer?: React.ReactNode;
  className?: string;
  children?: React.ReactNode;
};

/** Native <dialog> gives us the top layer, focus trapping, inert background and Escape. */
function useModal(open: boolean, onOpenChange: (open: boolean) => void) {
  const ref = React.useRef<HTMLDialogElement>(null);
  const [mounted, setMounted] = React.useState(open);
  if (open && !mounted) setMounted(true);
  React.useEffect(() => {
    const d = ref.current;
    if (open && d && !d.open) d.showModal();
  }, [open, mounted]);
  return {
    mounted,
    onExitComplete: () => {
      ref.current?.close();
      setMounted(false);
    },
    dialogProps: {
      ref,
      onCancel: (e: React.SyntheticEvent) => {
        e.preventDefault();
        onOpenChange(false);
      },
      className: "fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent",
    },
  };
}

/** Side panel that slides in. Fling it back toward its edge to close. */
export function Sheet({ open, onOpenChange, side = "right", title, description, footer, className, children }: SheetProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const { mounted, dialogProps, onExitComplete } = useModal(open, onOpenChange);
  if (!mounted) return null;
  const off = side === "right" ? "100%" : "-100%";
  const dir = side === "right" ? 1 : -1;

  return (
    <dialog {...dialogProps} aria-labelledby={`${id}-title`}>
      <AnimatePresence onExitComplete={onExitComplete}>
        {open && (
          <motion.div key="layer" className="absolute inset-0">
            <motion.div className="absolute inset-0 bg-background/60 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} />
            <motion.div
              initial={{ x: reduce ? 0 : off, opacity: reduce ? 0 : 1 }}
              animate={{ x: 0, opacity: 1 }}
              exit={{ x: reduce ? 0 : off, opacity: reduce ? 0 : 1 }}
              transition={{ type: "spring", stiffness: 380, damping: 40 }}
              drag={reduce ? false : "x"}
              dragConstraints={{ left: 0, right: 0 }}
              dragElastic={side === "right" ? { left: 0.04, right: 0.8 } : { left: 0.8, right: 0.04 }}
              onDragEnd={(_, info) => {
                if (info.offset.x * dir > 120 || info.velocity.x * dir > 500) onOpenChange(false);
              }}
              className={cn(
                "absolute inset-y-0 flex w-full max-w-sm flex-col border bg-popover text-popover-foreground shadow-[0_0_60px_-20px_rgb(0_0_0/0.6)]",
                side === "right" ? "right-0 rounded-l-2xl" : "left-0 rounded-r-2xl",
                className,
              )}
            >
              <div className="flex items-start justify-between gap-4 border-b p-5">
                <div>
                  <h2 id={`${id}-title`} className="text-lg font-semibold tracking-tight">
                    {title}
                  </h2>
                  {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
                </div>
                <button
                  type="button"
                  onClick={() => onOpenChange(false)}
                  aria-label="Close"
                  className="grid size-8 shrink-0 cursor-pointer place-items-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
                    <path d="M6 6l12 12M18 6 6 18" />
                  </svg>
                </button>
              </div>
              <div className="flex-1 overflow-y-auto p-5">{children}</div>
              {footer && <div className="flex gap-2 border-t p-5">{footer}</div>}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </dialog>
  );
}
```