Skip to contentVibraUI
Navigation & layout

Sheet

A panel that slides in from an edge of the screen over a scrim.

Vibra casts --shadow-modal over the theme's --overlay scrim instead of shadow-lg over 10% black, and slides the panel on the emphasized ease over --duration-slow; the title is set in the display register at text-xl — a different register given to it (type-label, type-eyebrow) replaces that one whole, face, size, tracking and ink, rather than sharing the element with it — and below md the close button grows to a 40px thumb target. The close button sits at the inline end, where shadcn's sits at a physical right, over the start of a right-to-left title, and closeLabel names it in the sheet's own language. It is Base UI's Dialog: titled and described, it opens with the focus on its first control, keeps it inside, and hands it back to its trigger — or to finalFocus — when it closes.

Install

npx shadcn@latest add @vibra/sheet

Needs the @vibra registry in your components.json — set it up once.

Examples

Filters

An orders table's filters in a sheet, so the table keeps its width: the foot counts what they would show before they apply, and the trigger says how many are on.

Navigation from the left

The app's navigation on a phone, from the edge where the sidebar sits on a wide screen; the current page is marked on the brand tint and with aria-current.

Long content

An order's twelve events scroll in a named region that takes the focus, between a title that stays and an action at the foot.

From the bottom

What a phone offers for three selected invoices, in thumb's reach; each action closes the sheet and the focus returns to the bar.

A record from a list

One sheet for the whole list, opened with each row's record; finalFocus returns the reader to the row they came from.

Unsaved changes

Escape, the scrim and the close button all ask first when the form has an edit: keep editing, where the focus opens, or discard.

Right to left

The navigation comes in from the right; the title starts there and the close button, named in Arabic, sits at the other end.

Props

PropTypeDefaultDescription
SheetContent.side"top" | "right" | "bottom" | "left""right"The edge the sheet slides in from — in a right-to-left app, the navigation's is the right.
SheetContent.showCloseButtonbooleantrueDraws the close button in the corner; Escape and the scrim close it either way.
SheetContent.closeLabelstring"Close"The close button's name, in the sheet's own language.
SheetContent.finalFocusReact.RefObject<HTMLElement>—Where the focus goes when it closes, for a sheet opened from state rather than its trigger — the row's own button.
open / onOpenChangeboolean / (open: boolean) => void—Control it to ask before closing: keep open false out of onOpenChange while a form has edits.

Dependencies

Source

components/ui/sheet.tsx
"use client"

import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"

import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"

function Sheet({ ...props }: SheetPrimitive.Root.Props) {
  return <SheetPrimitive.Root data-slot="sheet" {...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"
      className={cn(
        "fixed inset-0 z-50 bg-overlay transition-opacity duration-(--duration-base) ease-(--ease-standard) data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
        className
      )}
      {...props}
    />
  )
}

function SheetContent({
  className,
  children,
  side = "right",
  showCloseButton = true,
  closeLabel = "Close",
  ...props
}: SheetPrimitive.Popup.Props & {
  side?: "top" | "right" | "bottom" | "left"
  showCloseButton?: boolean
  /** The close button's name, in the sheet's own language. */
  closeLabel?: string
}) {
  return (
    <SheetPortal>
      <SheetOverlay />
      <SheetPrimitive.Popup
        data-slot="sheet-content"
        data-side={side}
        className={cn(
          "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-(--shadow-modal) transition duration-(--duration-slow) ease-(--ease-emphasized) data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] 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-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] 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-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] 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-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
          className
        )}
        {...props}
      >
        {children}
        {showCloseButton && (
          <SheetPrimitive.Close
            data-slot="sheet-close"
            render={
              <Button
                variant="ghost"
                // A thumb needs 40px; the pointer size stays compact. At the
                // inline end, so it never sits on a right-to-left title.
                className="absolute end-3 top-3 max-md:end-2 max-md:top-2 max-md:size-10"
                size="icon-sm"
              />
            }
          >
            <XIcon
            />
            <span className="sr-only">{closeLabel}</span>
          </SheetPrimitive.Close>
        )}
      </SheetPrimitive.Popup>
    </SheetPortal>
  )
}

function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-header"
      className={cn("flex flex-col gap-0.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-2 p-4", className)}
      {...props}
    />
  )
}

/**
 * A class naming one of the theme's type registers — type-display and its kin.
 * A register sets the family, the weight and the tracking in one class, which
 * the class merge cannot see as replacing the title's own type, so the two
 * stayed side by side and the stylesheet's order decided between them.
 */
const TYPE_REGISTER = /(?:^|\s)type-[a-z]/

function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
  // The title's own type is a register too: one given to it replaces it
  // whole — family, weight, size, tracking and ink — rather than sharing the
  // element with it.
  const ownType = typeof className === "string" && TYPE_REGISTER.test(className)
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      className={cn(
        !ownType && "type-display text-xl text-foreground",
        className
      )}
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: SheetPrimitive.Description.Props) {
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Sheet,
  SheetTrigger,
  SheetClose,
  SheetContent,
  SheetHeader,
  SheetFooter,
  SheetTitle,
  SheetDescription,
}