---
title: Sheet
description: A dismissible panel that slides in from an edge.
group: Components
order: 59
---

A filled panel that slides in from an edge. Its free corners are rounded, and it closes from the button, Escape key, or scrim.

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function Example() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Open</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit profile</SheetTitle>
          <SheetDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </SheetDescription>
        </SheetHeader>
        <div className="grid gap-4 px-4">
          <div className="grid gap-1.5">
            <label htmlFor="sheet-name" className="text-sm tracking-wide">
              Name
            </label>
            <input
              id="sheet-name"
              defaultValue="Carol Williams"
              className="h-9 w-full rounded-md border border-border bg-transparent px-3 text-sm tracking-wide"
            />
          </div>
          <div className="grid gap-1.5">
            <label htmlFor="sheet-username" className="text-sm tracking-wide">
              Username
            </label>
            <input
              id="sheet-username"
              defaultValue="@peduarte"
              className="h-9 w-full rounded-md border border-border bg-transparent px-3 text-sm tracking-wide"
            />
          </div>
        </div>
        <SheetFooter>
          <Button type="submit" className="transition-colors">
            Save changes
          </Button>
          <SheetClose asChild>
            <Button variant="outline" className="transition-colors">
              Close
            </Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add sheet
```

## Usage

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
```

```tsx
<Sheet>
  <SheetTrigger asChild>
    <Button variant="outline">Open</Button>
  </SheetTrigger>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>Edit profile</SheetTitle>
      <SheetDescription>
        Update your details, then save.
      </SheetDescription>
    </SheetHeader>
    <SheetFooter>
      <Button type="submit">Save changes</Button>
      <SheetClose asChild>
        <Button variant="outline">Close</Button>
      </SheetClose>
    </SheetFooter>
  </SheetContent>
</Sheet>
```

## Composition

```tree
Sheet
├── SheetTrigger
└── SheetContent
    ├── SheetHeader
    │   ├── SheetTitle
    │   └── SheetDescription
    ├── SheetFooter
    │   └── SheetClose
    └── (close button)
```

## Basic

An edit-profile sheet with fields above and actions in the footer.

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function Example() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Open</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit profile</SheetTitle>
          <SheetDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </SheetDescription>
        </SheetHeader>
        <div className="grid gap-4 px-4">
          <div className="grid gap-1.5">
            <label htmlFor="sheet-name" className="text-sm tracking-wide">
              Name
            </label>
            <input
              id="sheet-name"
              defaultValue="Carol Williams"
              className="h-9 w-full rounded-md border border-border bg-transparent px-3 text-sm tracking-wide"
            />
          </div>
          <div className="grid gap-1.5">
            <label htmlFor="sheet-username" className="text-sm tracking-wide">
              Username
            </label>
            <input
              id="sheet-username"
              defaultValue="@peduarte"
              className="h-9 w-full rounded-md border border-border bg-transparent px-3 text-sm tracking-wide"
            />
          </div>
        </div>
        <SheetFooter>
          <Button type="submit" className="transition-colors">
            Save changes
          </Button>
          <SheetClose asChild>
            <Button variant="outline" className="transition-colors">
              Close
            </Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Side

Set `side` on `SheetContent` to `top`, `right`, `bottom`, or `left`. Each example keeps the content simple and includes footer actions.

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

const SIDES = ["top", "right", "bottom", "left"] as const

const sideCopy = {
  top: {
    title: "What’s new this week",
    description:
      "A short tour of the updates that landed since your last visit — nothing you have to act on right away.",
    body: [
      "We’ve shipped a quieter notifications panel, faster search across your workspace, and clearer activity history so you can pick up where you left off without digging through old threads.",
      "Shared links now open with the right permission prompt, and comment mentions include a bit more context so you know why someone pulled you in.",
      "If something looks off after the update, you can roll individual features back from Settings → Experiments while we keep polishing the defaults.",
    ],
  },
  right: {
    title: "Project details",
    description:
      "Context that stays beside your canvas so you don’t lose place jumping between tabs.",
    body: [
      "Status, owners, and recent updates stay in view while you edit. Use this panel when you need a quick check without leaving the document you’re already reading.",
      "Timeline events are sorted newest first. Expand any row for the full note, attachments, and who made the change — useful when you’re catching up after time away.",
      "You can pin this sheet open while you work, or close it anytime. Nothing here edits the file until you choose an action at the bottom.",
    ],
  },
  bottom: {
    title: "Confirm before you leave",
    description:
      "You still have unsaved edits on this page. Take a second before discarding them.",
    body: [
      "Closing now will drop the draft you’ve been working on, including comments that haven’t been posted yet and any fields you changed in the last few minutes.",
      "If you meant to keep going, stay on this page and finish reviewing. When you’re ready, save from the main toolbar — this sheet is only a warning, not a commit.",
      "Sure you want to leave? Confirm below and we’ll return you to the previous view without storing this draft.",
    ],
  },
  left: {
    title: "Browse the library",
    description:
      "Collections, filters, and saved views — without covering the canvas you’re editing.",
    body: [
      "Pick a section here, then keep working on the right. The library stays in one continuous flow so you can jump between references without losing selection in the main editor.",
      "Starred items float to the top of each collection. Filters remember the last combination you used in this workspace, so opening the panel again should feel familiar.",
      "Need something new? Create a collection from the footer, or close this sheet and search from the command menu if you already know the name.",
    ],
  },
} as const

export function Example() {
  return (
    <div className="flex flex-wrap gap-2">
      {SIDES.map((side) => {
        const copy = sideCopy[side]
        return (
          <Sheet key={side}>
            <SheetTrigger asChild>
              <Button variant="outline" className="capitalize">
                {side}
              </Button>
            </SheetTrigger>
            <SheetContent side={side}>
              <SheetHeader>
                <SheetTitle>{copy.title}</SheetTitle>
                <SheetDescription>{copy.description}</SheetDescription>
              </SheetHeader>
              <div className="grid gap-3 overflow-y-auto px-4 text-sm tracking-wide text-muted-foreground">
                {copy.body.map((paragraph) => (
                  <p key={paragraph.slice(0, 24)}>{paragraph}</p>
                ))}
              </div>
              <SheetFooter>
                <Button type="button" className="transition-colors">
                  Continue
                </Button>
                <SheetClose asChild>
                  <Button variant="outline" className="transition-colors">
                    Close
                  </Button>
                </SheetClose>
              </SheetFooter>
            </SheetContent>
          </Sheet>
        )
      })}
    </div>
  )
}
```

## No Close Button

Pass `showCloseButton={false}` to hide the corner close control. The sheet still closes with Escape or the scrim.

```tsx
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function Example() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Open Sheet</Button>
      </SheetTrigger>
      <SheetContent showCloseButton={false}>
        <SheetHeader>
          <SheetTitle>No close button</SheetTitle>
          <SheetDescription>
            This sheet hides the default close control. Dismiss with Escape or
            by clicking the overlay.
          </SheetDescription>
        </SheetHeader>
      </SheetContent>
    </Sheet>
  )
}
```

## API Reference

### Sheet

| Prop | Type | Default |
| --- | --- | --- |
| open | boolean | — |
| defaultOpen | boolean | false |
| onOpenChange | (open: boolean) => void | — |

```tsx
<Sheet>
  <SheetTrigger asChild>
    <Button variant="outline">Open</Button>
  </SheetTrigger>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>Title</SheetTitle>
      <SheetDescription>Description</SheetDescription>
    </SheetHeader>
  </SheetContent>
</Sheet>
```

### SheetTrigger

| Prop | Type | Default |
| --- | --- | --- |
| asChild | boolean | false |
| className | string | — |

```tsx
<SheetTrigger asChild>
  <Button variant="outline">Open</Button>
</SheetTrigger>
```

### SheetClose

| Prop | Type | Default |
| --- | --- | --- |
| asChild | boolean | false |
| className | string | — |

```tsx
<SheetClose asChild>
  <Button variant="outline">Close</Button>
</SheetClose>
```

### SheetContent

Filled panel. Radius on the free edge only (opposite `side`).

| Prop | Type | Default |
| --- | --- | --- |
| side | "top" \| "right" \| "bottom" \| "left" | "right" |
| showCloseButton | boolean | true |
| className | string | — |

```tsx
<SheetContent side="right">
  Content
</SheetContent>
```

### SheetHeader

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<SheetHeader>
  <SheetTitle>Title</SheetTitle>
  <SheetDescription>Description</SheetDescription>
</SheetHeader>
```

### SheetFooter

Pinned to the bottom of tall side sheets via `mt-auto`.

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<SheetFooter>
  <Button>Save</Button>
</SheetFooter>
```

### SheetTitle

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<SheetTitle>Title</SheetTitle>
```

### SheetDescription

| Prop | Type | Default |
| --- | --- | --- |
| className | string | — |

```tsx
<SheetDescription>Description</SheetDescription>
```

## Source

```tsx
"use client";

import {
  cloneElement,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useEffect,
  useId,
  useMemo,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type MouseEvent,
  type ReactElement,
  type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useBodyScrollLock } from "@/lib/use-body-scroll-lock";

type SheetSide = "top" | "right" | "bottom" | "left";

type SheetContextValue = {
  open: boolean;
  setOpen: (open: boolean) => void;
  titleId: string;
  descriptionId: string;
};

const SheetContext = createContext<SheetContextValue | null>(null);

function useSheet() {
  const context = useContext(SheetContext);
  if (!context) {
    throw new Error("Sheet parts must be used within <Sheet>");
  }
  return context;
}

type SheetProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: ReactNode;
};

export function Sheet({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  children,
}: SheetProps) {
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const controlled = openProp !== undefined;
  const open = controlled ? openProp : uncontrolled;
  const titleId = useId();
  const descriptionId = useId();

  const setOpen = useCallback(
    (next: boolean) => {
      if (!controlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [controlled, onOpenChange],
  );

  const value = useMemo(
    () => ({ open, setOpen, titleId, descriptionId }),
    [open, setOpen, titleId, descriptionId],
  );

  return (
    <SheetContext.Provider value={value}>{children}</SheetContext.Provider>
  );
}

type SheetTriggerProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  asChild?: boolean;
  children: ReactNode;
};

export function SheetTrigger({
  asChild = false,
  className,
  children,
  onClick,
  ...props
}: SheetTriggerProps) {
  const { setOpen } = useSheet();

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (!event.defaultPrevented) setOpen(true);
  }

  if (asChild && isValidElement(children)) {
    const child = children as ReactElement<{
      className?: string;
      onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
    }>;
    return cloneElement(child, {
      ...props,
      className: cn(child.props.className, className),
      onClick: (event: MouseEvent<HTMLButtonElement>) => {
        child.props.onClick?.(event);
        handleClick(event);
      },
    });
  }

  return (
    <button
      type="button"
      data-slot="sheet-trigger"
      className={cn(className)}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}

type SheetCloseProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  asChild?: boolean;
  children: ReactNode;
};

export function SheetClose({
  asChild = false,
  className,
  children,
  onClick,
  ...props
}: SheetCloseProps) {
  const { setOpen } = useSheet();

  function handleClick(event: MouseEvent<HTMLButtonElement>) {
    onClick?.(event);
    if (!event.defaultPrevented) setOpen(false);
  }

  if (asChild && isValidElement(children)) {
    const child = children as ReactElement<{
      className?: string;
      onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
    }>;
    return cloneElement(child, {
      ...props,
      className: cn(child.props.className, className),
      onClick: (event: MouseEvent<HTMLButtonElement>) => {
        child.props.onClick?.(event);
        handleClick(event);
      },
    });
  }

  return (
    <button
      type="button"
      data-slot="sheet-close"
      className={cn(className)}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}

type SheetContentProps = HTMLAttributes<HTMLDivElement> & {
  side?: SheetSide;
  showCloseButton?: boolean;
  children: ReactNode;
};

export function SheetContent({
  side = "right",
  showCloseButton = true,
  className,
  children,
  ...props
}: SheetContentProps) {
  const { open, setOpen, titleId, descriptionId } = useSheet();
  const [portalReady, setPortalReady] = useState(false);
  const [present, setPresent] = useState(false);
  const [entered, setEntered] = useState(false);

  useBodyScrollLock(present);

  useEffect(() => {
    setPortalReady(true);
  }, []);

  useEffect(() => {
    if (open) {
      setPresent(true);
      let frame2 = 0;
      const frame1 = requestAnimationFrame(() => {
        frame2 = requestAnimationFrame(() => setEntered(true));
      });
      return () => {
        cancelAnimationFrame(frame1);
        cancelAnimationFrame(frame2);
      };
    }

    setEntered(false);
    const timeout = window.setTimeout(() => setPresent(false), 320);
    return () => window.clearTimeout(timeout);
  }, [open]);

  useEffect(() => {
    if (!present) return;

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") setOpen(false);
    }

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
    };
  }, [present, setOpen]);

  if (!portalReady || !present) return null;

  return createPortal(
    <div className="fixed inset-0 z-50">
      <div
        aria-hidden="true"
        data-slot="sheet-overlay"
        className={cn(
          "arctis-overlay-backdrop absolute inset-0 bg-black/40",
          entered && "arctis-overlay-backdrop-open",
        )}
        onClick={() => setOpen(false)}
      />
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={descriptionId}
        data-slot="sheet-content"
        data-side={side}
        className={cn(
          "arctis-sheet isolate fixed z-50 flex flex-col bg-surface text-foreground shadow-lg",
          side === "right" &&
            "inset-y-0 right-0 h-full w-3/4 rounded-l-md sm:max-w-sm",
          side === "left" &&
            "inset-y-0 left-0 h-full w-3/4 rounded-r-md sm:max-w-sm",
          side === "top" && "inset-x-0 top-0 h-auto rounded-b-md",
          side === "bottom" && "inset-x-0 bottom-0 h-auto rounded-t-md",
          showCloseButton && "[&_[data-slot=sheet-header]]:pr-12",
          entered && "arctis-sheet-open",
          className,
        )}
        {...props}
      >
        {children}
        {showCloseButton ? (
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            data-slot="sheet-close"
            aria-label="Close"
            className="absolute top-3 right-3 text-muted-foreground hover:text-foreground"
            onClick={() => setOpen(false)}
          >
            <XIcon className="size-4" />
          </Button>
        ) : null}
      </div>
    </div>,
    document.body,
  );
}

type SheetHeaderProps = HTMLAttributes<HTMLDivElement> & {
  children: ReactNode;
};

export function SheetHeader({
  className,
  children,
  ...props
}: SheetHeaderProps) {
  return (
    <div
      data-slot="sheet-header"
      className={cn("flex flex-col gap-1.5 p-4", className)}
      {...props}
    >
      {children}
    </div>
  );
}

type SheetFooterProps = HTMLAttributes<HTMLDivElement> & {
  children: ReactNode;
};

export function SheetFooter({
  className,
  children,
  ...props
}: SheetFooterProps) {
  return (
    <div
      data-slot="sheet-footer"
      className={cn("mt-auto flex flex-col gap-2 p-4", className)}
      {...props}
    >
      {children}
    </div>
  );
}

type SheetTitleProps = HTMLAttributes<HTMLHeadingElement> & {
  children: ReactNode;
};

export function SheetTitle({
  className,
  children,
  ...props
}: SheetTitleProps) {
  const { titleId } = useSheet();

  return (
    <h2
      id={titleId}
      data-slot="sheet-title"
      className={cn("text-base font-medium tracking-wide text-foreground", className)}
      {...props}
    >
      {children}
    </h2>
  );
}

type SheetDescriptionProps = HTMLAttributes<HTMLParagraphElement> & {
  children: ReactNode;
};

export function SheetDescription({
  className,
  children,
  ...props
}: SheetDescriptionProps) {
  const { descriptionId } = useSheet();

  return (
    <p
      id={descriptionId}
      data-slot="sheet-description"
      className={cn("text-sm tracking-wide text-muted-foreground", className)}
      {...props}
    >
      {children}
    </p>
  );
}

function XIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
  );
}
```
