Use Sheet for navigation, filters, or an auxiliary task needing more room than a popover. It uses dialog semantics and includes a portal and backdrop. Use Drawer when drawer interaction fits the task better.
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
export default function SheetDemo() {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
return (
<div className="grid justify-items-center gap-3">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={<Button />}>Open sheet</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit profile</SheetTitle>
<SheetDescription>
Edit this local profile preview. No account request is sent.
</SheetDescription>
</SheetHeader>
<form
id="sheet-profile-form"
className="grid flex-1 auto-rows-min gap-6 px-4"
onSubmit={(event) => {
event.preventDefault();
setMessage("Profile preview updated locally.");
setOpen(false);
}}
>
<div className="grid gap-3">
<Label htmlFor="sheet-demo-name">Name</Label>
<Input id="sheet-demo-name" defaultValue="John Doe" required />
</div>
<div className="grid gap-3">
<Label htmlFor="sheet-demo-username">Username</Label>
<Input id="sheet-demo-username" defaultValue="@johndoe" required />
</div>
</form>
<SheetFooter>
<Button form="sheet-profile-form" type="submit">
Save changes
</Button>
<SheetClose render={<Button type="button" variant="neutral" />}>Close</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
<output className="block min-h-5 text-sm" aria-live="polite">
{message}
</output>
</div>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/sheet.jsonnpm install @base-ui/react@^1.8.0Also install Button, then create components/ui/sheet.tsx:
"use client";
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type SheetContentProps = SheetPrimitive.Popup.Props & {
showCloseButton?: boolean;
side?: "top" | "right" | "bottom" | "left";
};
function Sheet(props: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root {...props} />;
}
function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose(props: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal(props: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
{...props}
className={(state) =>
cn(
"pointer-events-none fixed inset-0 z-40 bg-overlay transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetContentProps) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
{...props}
className={(state) =>
cn(
"fixed z-50 flex max-h-dvh flex-col gap-4 overflow-y-auto border-2 border-border bg-background transition ease-in-out data-closed:animate-out data-closed:duration-300 data-open:animate-in data-open:duration-500 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-b data-[side=bottom]:data-closed:slide-out-to-bottom data-[side=bottom]:data-open:slide-in-from-bottom data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-closed:slide-out-to-left data-[side=left]:data-open:slide-in-from-left data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-closed:slide-out-to-right data-[side=right]:data-open:slide-in-from-right data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-closed:slide-out-to-top data-[side=top]:data-open:slide-in-from-top data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
{showCloseButton && (
<SheetClose
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetClose>
)}
</SheetPrimitive.Popup>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-3 p-4", className)}
{...props}
/>
);
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
{...props}
className={(state) =>
cn(
"font-heading text-foreground",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
{...props}
className={(state) =>
cn(
"text-sm font-base text-foreground",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
The profile form in the live preview additionally uses Input and Label. Install them before copying the Code example:
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/input.json https://neobrutal-ui.andongmin.com/r/label.json#Usage
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
export default function WorkspacePanel() {
return (
<Sheet>
<SheetTrigger render={<Button variant="outline" />}>Workspace details</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Workspace details</SheetTitle>
<SheetDescription>Settings shared with all project members.</SheetDescription>
</SheetHeader>
<div className="p-4">
<p>Connect your application settings here.</p>
</div>
</SheetContent>
</Sheet>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
Sheet.open / onOpenChange | Controlled visibility | Uncontrolled |
Sheet.defaultOpen | Initial visibility | false |
SheetTrigger.render / SheetClose.render | Button composition | Primitive button |
SheetContent.side | top, right, bottom, left | right |
SheetContent.showCloseButton | Built-in close control | true |
SheetContent.initialFocus / finalFocus | Focus destinations | Base UI behavior |
#Accessibility
Provide SheetTitle and a useful description. Keep the close control reachable when content scrolls. Test Escape and focus return. Avoid essential actions below an unreachable mobile viewport. Removing the default close button requires another clear dismissal action.
Every side is bounded by the viewport height. For a persistent header and footer, put long body content in an overflow-y-auto container, as in the Side example.
#Examples
#Side
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
const SHEET_SIDES = ["top", "right", "bottom", "left"] as const;
const reviewNotes = [
"Review the summary, owner, and due date before sharing this workspace with the team.",
"Confirm that keyboard navigation, empty states, and narrow-screen layouts are in the release checklist.",
"Record unresolved decisions next to the component they affect so the next review starts with context.",
];
export default function SheetDemo() {
return (
<div className="flex flex-wrap gap-2">
{SHEET_SIDES.map((side) => (
<Sheet key={side}>
<SheetTrigger render={<Button variant="noShadow" className="capitalize" />}>
{side} sheet
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle className="capitalize">{side} panel review</SheetTitle>
<SheetDescription>
Review how the panel enters from the {side} edge and handles long content.
</SheetDescription>
</SheetHeader>
<div className="overflow-y-auto px-4 text-sm">
<h4 className="mb-4 text-lg leading-none font-medium">Project review notes</h4>
{Array.from({ length: 9 }, (_, index) => (
<p key={index} className="mb-4 leading-normal">
{reviewNotes[index % reviewNotes.length]}
</p>
))}
</div>
<SheetFooter>
<SheetClose render={<Button />}>Close panel</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
))}
</div>
);
}