a panel sliding in from an edge of the window, the page held behind it
npx shadcn add @ag/sheetAlready installed? Preview an update
npx shadcn add @ag/sheet --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Changed: the same as dialog: the panel stays whole and its content scrolls in
data-slot="sheet-body", which holds the padding and the gap and fades. Move padding, gap or scroll classes fromSheetContentto[data-slot=sheet-body].
also called drawer, side panel
shelf controls
uses dialog, material, portal, scrims, scroll-area
used by components
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
type SheetSide,
SheetTitle,
SheetTrigger,
} from "@/registry/base-nova/ui/sheet"
import { Switch } from "@/registry/base-nova/ui/switch"
// #region filters
// A sheet of filters from whichever edge: its own state, a foot with what to do.
function Filters({ side }: { side: SheetSide }) {
const [moving, setMoving] = React.useState(true)
const [faces, setFaces] = React.useState(false)
return (
<Sheet>
<SheetTrigger render={<Button variant="secondary" />}>
from the {side}
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>filters</SheetTitle>
<SheetDescription>what the library shows</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-3">
<Switch checked={moving} onCheckedChange={setMoving}>
only what moves
</Switch>
<Switch checked={faces} onCheckedChange={setFaces}>
only with faces
</Switch>
</div>
<SheetFooter>
<SheetClose render={<Button variant="ghost" />}>done</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// #endregion
export default function SheetDemo() {
return (
<div className="space-y-8">
<Sample name="sides" glance title="from any edge" with="filters">
<div className="flex flex-wrap gap-2">
<Filters side="right" />
<Filters side="left" />
<Filters side="top" />
<Filters side="bottom" />
</div>
</Sample>
<Sample
name="glass"
title="in glass"
description="a pane of the material's glass, rounded only where it meets the page"
with="filters"
>
<MaterialProvider material="glass">
<div className="flex flex-wrap gap-2 rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
<Filters side="right" />
<Filters side="bottom" />
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A panel that slides in from an edge of the window (shadcn's sheet): the dialog's
// logic, its focus trapped and given back, Esc and a press on the dimmed page closing
// it, the page held still behind it (dialog's `Dialog` is its root). What is its own
// is only where it stands, `side`: along the right or the left, across the top or the
// foot, edge to edge, sliding in from beyond it and back out the same way. Its head,
// title, description and foot are the dialog's parts, under the sheet's names:
//
// <Sheet>
// <SheetTrigger render={<Button />}>filters</SheetTrigger>
// <SheetContent side="left">
// <SheetHeader><SheetTitle>filters</SheetTitle></SheetHeader>
// …
// </SheetContent>
// </Sheet>
//
// Plain, the page's popover surface with a hairline on its inner edge. Glass
// (`material`), a pane of the material's glass, rounded only where it meets the page.
import { Dialog as Base } from "@base-ui/react/dialog"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
DIALOG_BACKDROP,
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/base-nova/ui/dialog"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import { usePortalTarget } from "@/registry/base-nova/ui/portal"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
export type SheetSide = "right" | "left" | "top" | "bottom"
/** The sheet's state: a modal dialog (the page dimmed and held behind it). */
export function Sheet(
props: Omit<React.ComponentProps<typeof Dialog>, "place">,
) {
return <Dialog place="center" {...props} />
}
export const SheetTrigger = DialogTrigger
export const SheetClose = DialogClose
export const SheetHeader = DialogHeader
export const SheetTitle = DialogTitle
export const SheetDescription = DialogDescription
export const SheetFooter = DialogFooter
/** Where each side puts it, and the edge it slides in from. */
const SIDE: Record<SheetSide, string> = {
right:
"inset-y-0 right-0 h-dvh w-[min(24rem,88vw)] data-[ending-style]:translate-x-full data-[starting-style]:translate-x-full",
left: "inset-y-0 left-0 h-dvh w-[min(24rem,88vw)] data-[ending-style]:-translate-x-full data-[starting-style]:-translate-x-full",
top: "inset-x-0 top-0 max-h-[85dvh] data-[ending-style]:-translate-y-full data-[starting-style]:-translate-y-full",
bottom:
"inset-x-0 bottom-0 max-h-[85dvh] data-[ending-style]:translate-y-full data-[starting-style]:translate-y-full",
}
/** Plain: the hairline on the edge it meets the page by. */
const PLAIN_EDGE: Record<SheetSide, string> = {
right: "border-l",
left: "border-r",
top: "border-b",
bottom: "border-t",
}
/** Glass: rounded only where it meets the page. */
/** The glass sheet's corners: 20 px on the side that faces the page, square against
* the screen's edge, and the edge's light told which of its two lit corners (top
* left, bottom right) is square. */
const GLASS_ROUND: Record<SheetSide, string> = {
right: "[--glass-radius:20px] rounded-r-none [--glass-corner-far:0px]",
left: "[--glass-radius:20px] rounded-l-none [--glass-corner:0px]",
top: "[--glass-radius:20px] rounded-t-none [--glass-corner:0px]",
bottom: "[--glass-radius:20px] rounded-b-none [--glass-corner-far:0px]",
}
export function SheetContent({
side = "right",
container,
material,
scheme,
className,
children,
ref,
...props
}: React.ComponentProps<typeof Base.Popup> & {
side?: SheetSide
/** Where it portals (a player's own layer, so it shows in fullscreen). */
container?: React.ComponentProps<typeof Base.Portal>["container"]
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}) {
const m = useMaterial(material)
const target = usePortalTarget()
// Over a picture (a player's layer, in fullscreen) it is read by the part of it
// under the sheet. The dim between them darkens that part as it darkens the
// whole, so the part is still the better witness of what it stands over.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
return (
<Base.Portal container={container ?? target}>
<Base.Backdrop data-slot="sheet-backdrop" className={DIALOG_BACKDROP} />
<Base.Popup
ref={here}
data-slot="sheet-content"
data-side={side}
data-material={m}
className={cn(
schemeOf(s),
m === "glass"
? [
glassVariants({ shape: "surface" }),
"[--glass-blur:28px]",
GLASS_ROUND[side],
// Its motion through the material, which owns its transition.
"[--glass-moves:translate_300ms_cubic-bezier(0.32,0.72,0,1),opacity_300ms_cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:[--glass-moves:translate_200ms_cubic-bezier(0.32,0.72,0,1),opacity_200ms_cubic-bezier(0.32,0.72,0,1)]",
]
: [
"border-border bg-popover text-popover-foreground shadow-xl",
PLAIN_EDGE[side],
"transition-[translate,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:duration-200 motion-reduce:transition-none",
],
// After the material, whose `relative` its `fixed` must win over. The
// frame stays whole; what it holds scrolls inside it (below).
"fixed z-50 flex flex-col outline-none",
SIDE[side],
className,
)}
{...props}
>
{/* Fills the frame and scrolls there, its cut edges fading where there is
more (scroll-area), the room round the content inside it so nothing at
its edge is cut; an overlay, so its scroll stops at its ends. Its slot
rides on `render`: the scroller's own props name it scroll-area. */}
<ScrollArea
render={<div data-slot="sheet-body" />}
className="flex flex-1 flex-col gap-4 overscroll-contain p-5"
>
<MaterialProvider material={m} scheme={s}>
{children}
</MaterialProvider>
</ScrollArea>
</Base.Popup>
</Base.Portal>
)
}