← ui

sheet

ui

a panel sliding in from an edge of the window, the page held behind it

npx shadcn add @ag/sheet
Already installed? Preview an update
npx shadcn add @ag/sheet --dry-run

Your 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 from SheetContent to [data-slot=sheet-body].
Updated 2026-10-12 · release notes

also called drawer, side panel

shelf controls

uses dialog, material, portal, scrims, scroll-area

used by components

the demo above, verbatim

sheet.demo.tsx
"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

sheet.tsx
"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>
  )
}