Sheet

Extends the Dialog component to display content that complements the main content of the screen.

import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { VStack } from "@/components/ui/stack"

export default function SheetDemo() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Open</SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit profile</SheetTitle>
          <SheetDescription>
            Make changes to your profile here. Click save when you&apos;re done.
          </SheetDescription>
        </SheetHeader>
        <VStack grow paddingInline={4}>
          <FieldGroup gap={6}>
            <Field>
              <FieldLabel>Name</FieldLabel>
              <Input id="sheet-demo-name" defaultValue="Pedro Duarte" />
            </Field>
            <Field>
              <FieldLabel>Username</FieldLabel>
              <Input id="sheet-demo-username" defaultValue="@peduarte" />
            </Field>
          </FieldGroup>
        </VStack>
        <SheetFooter>
          <Button type="submit">Save changes</Button>
          <SheetClose render={<Button variant="secondary" />}>Close</SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and copy state-styles.ts from the same page. Copy states.ts for the shared popup surface and install the Button for the close control.

Copy and paste the following code into your project.

components/ui/sheet.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import type { CSSProperties } from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { stateStyles } from "@/lib/state-styles"
import { breakpoints } from "@/theme/breakpoints.stylex"
import { states } from "@/theme/states"
import { color, duration, ease, layer, leading, shadow, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  overlayHidden: { opacity: 0 },
  hidden: { transform: "var(--carbon-sheet-closed-transform)" },
  ending: {
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
  },
  overlay: {
    position: "fixed",
    inset: 0,
    zIndex: layer.popup,
    backgroundColor: color.overlay,
    backdropFilter: "blur(2px)",
    transitionProperty: "opacity",
    transitionDuration: {
      default: duration.sheet,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.decelerate,
  },
  content: {
    position: "fixed",
    zIndex: layer.popup,
    display: "flex",
    flexDirection: "column",
    gap: space.s4,
    boxShadow: shadow.popupLifted,
    fontSize: text.sm,
    lineHeight: leading.sm,
    transitionProperty: "transform",
    transitionDuration: {
      default: duration.sheet,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.decelerate,
  },
  close: { position: "absolute", top: space.s2_5, right: space.s2_5 },
  header: { display: "grid", rowGap: space.s1, padding: space.s4 },
  footer: {
    display: "flex",
    flexDirection: "column",
    gap: space.s2,
    marginBlockStart: "auto",
  },
  title: { fontSize: text.base, lineHeight: leading.base, fontWeight: 500 },
  description: { color: color.mutedForeground },
})

const sides = stylex.create({
  top: {
    top: 0,
    insetInline: 0,
    height: "auto",
    "--carbon-sheet-closed-transform": "translate3d(0, calc(-100% - 2px), 0)",
  },
  right: {
    right: 0,
    insetBlock: 0,
    height: "100%",
    width: { default: "75%", [breakpoints.md]: "24rem" },
    "--carbon-sheet-closed-transform": "translate3d(calc(100% + 2px), 0, 0)",
  },
  bottom: {
    bottom: 0,
    insetInline: 0,
    height: "auto",
    "--carbon-sheet-closed-transform": "translate3d(0, calc(100% + 2px), 0)",
  },
  left: {
    left: 0,
    insetBlock: 0,
    height: "100%",
    width: { default: "75%", [breakpoints.md]: "24rem" },
    "--carbon-sheet-closed-transform": "translate3d(calc(-100% - 2px), 0, 0)",
  },
})

const sheetStyles = { content: [states.popupSurface, styles.content], sides }

type SheetSide = keyof typeof sides

type Overrides = { xstyle?: StyleXStyles; className?: string }
type StateOverrides = Overrides & { style?: CSSProperties }

function Sheet<Payload>(props: SheetPrimitive.Root.Props<Payload>) {
  return <SheetPrimitive.Root {...props} />
}

function SheetTrigger(props: SheetPrimitive.Trigger.Props) {
  return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}

function SheetPortal(props: SheetPrimitive.Portal.Props) {
  return <SheetPrimitive.Portal {...props} />
}

function SheetClose(props: SheetPrimitive.Close.Props) {
  return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}

type SheetOverlayProps = Omit<SheetPrimitive.Backdrop.Props, "className" | "style"> & StateOverrides

function SheetOverlay({ xstyle, className, style, ...props }: SheetOverlayProps) {
  return (
    <SheetPrimitive.Backdrop
      data-slot="sheet-overlay"
      {...props}
      {...stateStyles(
        (state) => [
          styles.overlay,
          (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
            styles.overlayHidden,
          state.transitionStatus === "ending" && styles.ending,
          xstyle,
        ],
        { className, style },
      )}
    />
  )
}

type SheetContentProps = Omit<SheetPrimitive.Popup.Props, "className" | "style"> &
  StateOverrides & {
    side?: SheetSide
    showCloseButton?: boolean
    overlayProps?: SheetOverlayProps
  }

function SheetContent({
  xstyle,
  className,
  style,
  children,
  side = "right",
  showCloseButton = true,
  overlayProps,
  ...props
}: SheetContentProps) {
  return (
    <SheetPortal>
      <SheetOverlay {...overlayProps} />
      <SheetPrimitive.Popup
        data-slot="sheet-content"
        data-side={side}
        {...props}
        {...stateStyles(
          (state) => [
            states.popupSurface,
            styles.content,
            sides[side] ?? sides.right,
            (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
              styles.hidden,
            state.transitionStatus === "ending" && styles.ending,
            xstyle,
          ],
          { className, style },
        )}
      >
        {children}
        {showCloseButton && (
          <SheetClose
            render={<Button variant="ghost" size="icon-sm" xstyle={styles.close} />}
            aria-label="Close"
          >
            <X />
          </SheetClose>
        )}
      </SheetPrimitive.Popup>
    </SheetPortal>
  )
}

function SheetHeader({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.header, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "sheet-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function SheetFooter({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(states.trayFooter, styles.footer, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "sheet-footer",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function SheetTitle({ xstyle, className, ...props }: SheetPrimitive.Title.Props & Overrides) {
  const sx = stylex.props(styles.title, xstyle)
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function SheetDescription({
  xstyle,
  className,
  ...props
}: SheetPrimitive.Description.Props & Overrides) {
  const sx = stylex.props(styles.description, xstyle)
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetOverlay,
  SheetPortal,
  SheetTitle,
  SheetTrigger,
  sheetStyles,
  type SheetSide,
}

Update the import paths to match your project setup.

Usage

import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>Open</SheetTrigger>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>Are you absolutely sure?</SheetTitle>
      <SheetDescription>This action cannot be undone.</SheetDescription>
    </SheetHeader>
  </SheetContent>
</Sheet>

Composition

Sheet
├── SheetTrigger
└── SheetContent          portal + overlay + popup
    ├── SheetHeader
    │   ├── SheetTitle
    │   └── SheetDescription
    └── SheetFooter
        └── SheetClose

SheetContent renders the portal, the overlay and the popup in one part. Reach the overlay through overlayProps, or compose SheetPortal and SheetOverlay yourself.

SheetHeader and SheetFooter carry their own padding, and the popup spaces its children with a gap. A body between them needs only inline padding, so give it a VStack with paddingInline={4}.

Side

Use the side prop on SheetContent to set the edge of the screen where the sheet appears. Values are top, right, bottom, or left.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { HStack, VStack } from "@/components/ui/stack"

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

const styles = stylex.create({
  short: { maxHeight: "50vh" },
})

export default function SheetSide() {
  return (
    <HStack gap={2} wrap="wrap" justify="center">
      {sides.map((side) => (
        <Sheet key={side}>
          <SheetTrigger render={<Button variant="outline" />}>{side}</SheetTrigger>
          <SheetContent
            side={side}
            xstyle={side === "top" || side === "bottom" ? styles.short : undefined}
          >
            <SheetHeader>
              <SheetTitle>Edit profile</SheetTitle>
              <SheetDescription>
                Make changes to your profile here. Click save when you&apos;re done.
              </SheetDescription>
            </SheetHeader>
            <VStack grow scrollable gap={2} paddingInline={4} className="no-scrollbar">
              {Array.from({ length: 10 }).map((_, index) => (
                <p key={index}>
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
                  incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
                  exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute
                  irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
                  pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui
                  officia deserunt mollit anim id est laborum.
                </p>
              ))}
            </VStack>
            <SheetFooter>
              <Button type="submit">Save changes</Button>
              <SheetClose render={<Button variant="secondary" />}>Cancel</SheetClose>
            </SheetFooter>
          </SheetContent>
        </Sheet>
      ))}
    </HStack>
  )
}

A side sheet is 75% of the viewport width, or 24rem from 48rem up. A top or bottom sheet is the height of its content. Cap it with maxHeight through xstyle, and make the body a flex child with grow and overflow-y: auto. VStack's scrollable sets both.

No close button

Use showCloseButton={false} on SheetContent to hide the close button.

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

export default function SheetNoCloseButton() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Open Sheet</SheetTrigger>
      <SheetContent showCloseButton={false}>
        <SheetHeader>
          <SheetTitle>No Close Button</SheetTitle>
          <SheetDescription>
            This sheet doesn&apos;t have a close button in the top-right corner. Click outside to
            close.
          </SheetDescription>
        </SheetHeader>
      </SheetContent>
    </Sheet>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Dialog part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className.

Sheet

PropTypeDefault
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean, details) => void—
modalboolean | "trap-focus"true
disablePointerDismissalbooleanfalse

SheetContent

PropTypeDefault
side"top" | "right" | "bottom" | "left""right"
showCloseButtonbooleantrue
overlayPropsSheetOverlayProps—
initialFocusboolean | RefObject | (type) => …—
finalFocusboolean | RefObject | (type) => …—

sheetStyles

The style map behind SheetContent, exported so the same box can be put on another element.

KeyDescription
contentThe fixed panel: the shared surface, shadow and transition
sidesOne style per side, with the edge, size and closed offset

content is a pair: states.popupSurface from theme/states.ts and the panel's own box over it. The panel takes the surface's shadow a level deeper, as the dialog does. There is no border. The shadow's hairline ring draws the edge that faces the page, and the other three sides sit past the viewport.

Data attributes

AttributeOnEffect
data-slot="sheet-content"the popupPopup slot
data-slot="sheet-overlay"the scrimBackdrop surface
data-sidethe popupThe side prop, "right" by default
data-starting-style, data-ending-stylepopup and overlayBase UI writes them while the sheet animates
data-open, data-closedpopup and overlayBase UI writes them