# Sheet

> A panel that slides in from any edge, with swipe-to-dismiss, scroll lock and stacked nesting.

Docs: https://hextaui.com/docs/sheet
Markdown: https://hextaui.com/docs/sheet.md

```tsx title="components/examples/sheet/demo.tsx"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function SheetDemo() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Edit profile
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Edit profile</SheetTitle>
          <SheetDescription>
            Changes are saved to your account when you click save.
          </SheetDescription>
        </SheetHeader>
        <SheetBody>
          <div className="flex flex-col gap-4">
            <div className="flex flex-col gap-1.5">
              <label htmlFor="sheet-demo-name" className="text-sm font-medium">
                Name
              </label>
              <input
                id="sheet-demo-name"
                defaultValue="Olivia Martin"
                className="h-9 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
              />
            </div>
            <div className="flex flex-col gap-1.5">
              <label
                htmlFor="sheet-demo-username"
                className="text-sm font-medium"
              >
                Username
              </label>
              <input
                id="sheet-demo-username"
                defaultValue="@olivia"
                className="h-9 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
              />
            </div>
          </div>
        </SheetBody>
        <SheetFooter>
          <SheetClose render={<Button variant="outline" />}>Cancel</SheetClose>
          <SheetClose render={<Button />}>Save changes</SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/sheet.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/sheet.tsx"
"use client"

import * as React from "react"
import { Drawer as SheetPrimitive } from "@base-ui/react/drawer"
import { IconX } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Button } from "@/components/ui/button"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const DialogLayerContext = React.createContext(0)

function DialogLayerProvider({ children }: { children: React.ReactNode }) {
  const depth = React.useContext(DialogLayerContext)

  return (
    <DialogLayerContext.Provider value={depth + 1}>
      {children}
    </DialogLayerContext.Provider>
  )
}

function useDialogLayer() {
  return React.useContext(DialogLayerContext)
}

type SwipeDirection = "up" | "down" | "left" | "right"

const SheetRootContext = React.createContext<
  ((direction: SwipeDirection) => void) | null
>(null)

function Sheet<Payload>({
  swipeDirection,
  ...props
}: SheetPrimitive.Root.Props<Payload>) {
  const [contentDirection, setContentDirection] =
    React.useState<SwipeDirection>("right")

  return (
    <DialogLayerProvider>
      <SheetRootContext.Provider value={setContentDirection}>
        <SheetPrimitive.Root
          data-slot="sheet"
          swipeDirection={swipeDirection ?? contentDirection}
          {...props}
        />
      </SheetRootContext.Provider>
    </DialogLayerProvider>
  )
}

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

function SheetPortal(props: SheetPrimitive.Portal.Props) {
  return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}

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

const sheetOverlayVariants = cva(
  "fixed inset-0 z-50 min-h-dvh opacity-[calc(1-var(--drawer-swipe-progress,0))] transition-opacity duration-300 ease-drawer data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:duration-200 sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:ease-out-quint sm:has-[~[data-slot=sheet-viewport]>[data-slot$=dialog-content]]:data-ending-style:duration-150 [&[data-swiping]:not([data-ending-style])]:duration-0",
  {
    variants: {
      nested: {
        false: "bg-black/40 dark:bg-black/60",
        true: "bg-black/20 dark:bg-black/30",
      },
    },
    defaultVariants: {
      nested: false,
    },
  }
)

function SheetOverlay({
  className,
  forceRender,
  ...props
}: SheetPrimitive.Backdrop.Props) {
  const nested = useDialogLayer() > 1

  return (
    <SheetPrimitive.Backdrop
      data-slot="sheet-overlay"
      data-nested={nested ? "" : undefined}
      forceRender={forceRender ?? nested}
      className={mergeClassName(sheetOverlayVariants({ nested }), className)}
      {...props}
    />
  )
}

const sheetContentVariants = cva(
  "fixed z-50 flex flex-col gap-4 overflow-y-auto overscroll-none bg-popover text-popover-foreground shadow-lg ring-(length:--hairline) ring-foreground/10 transition-[transform,translate,scale,opacity,top] duration-300 ease-drawer outline-none [--bleed:3rem] focus-visible:outline-hidden data-ending-style:duration-[calc(var(--drawer-swipe-strength,0.7)*300ms)] data-nested-dialog-open:scale-[calc(1-0.02*var(--nested-dialogs))] data-nested-drawer-open:scale-[calc(1-0.02*var(--nested-drawers))] data-swiping:cursor-grabbing data-swiping:select-none motion-reduce:transition-opacity motion-reduce:data-ending-style:opacity-0 motion-reduce:data-starting-style:opacity-0 rtl:[--sheet-dir:-1] forced-colors:border [&[data-swiping]:not([data-ending-style])]:duration-0",
  {
    variants: {
      side: {
        top: "inset-x-0 top-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-b-2xl pt-(--bleed) [--sheet-close-top:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-y-[calc(-100%+var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(-100%+var(--bleed))]",
        right:
          "inset-y-0 end-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-s-2xl pe-(--bleed) [--sheet-close-end:calc(0.75rem+var(--bleed))] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(100%-var(--bleed)))] sm:max-w-[calc(24rem+var(--bleed))]",
        bottom:
          "inset-x-0 bottom-[calc(var(--bleed)*-1)] max-h-[calc(100dvh-2rem+var(--bleed))] [transform:translateY(var(--drawer-swipe-movement-y,0px))] rounded-t-2xl pb-[calc(var(--bleed)+env(safe-area-inset-bottom))] [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-y-[calc(100%-var(--bleed))] motion-safe:data-starting-style:translate-y-[calc(100%-var(--bleed))]",
        left: "inset-y-0 start-[calc(var(--bleed)*-1)] h-full w-[calc(75%+var(--bleed))] [transform:translateX(var(--drawer-swipe-movement-x,0px))] rounded-e-2xl ps-(--bleed) [--sheet-close-radius:calc(var(--radius-2xl)-0.75rem)] motion-safe:data-ending-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] motion-safe:data-starting-style:translate-x-[calc(var(--sheet-dir,1)*(var(--bleed)-100%))] sm:max-w-[calc(24rem+var(--bleed))]",
      },
    },
    defaultVariants: {
      side: "right",
    },
  }
)

const sheetHandleVariants = cva(
  "group/sheet-handle absolute z-10 flex cursor-grab touch-none items-center justify-center active:cursor-grabbing",
  {
    variants: {
      side: {
        top: "inset-x-0 bottom-0 h-6",
        right: "inset-y-0 start-0 w-6",
        bottom: "inset-x-0 top-0 h-6",
        left: "inset-y-0 end-0 w-6",
      },
    },
  }
)

const sheetHandleBarVariants = cva(
  "rounded-full bg-muted-foreground/30 transition-colors duration-150 group-hover/sheet-handle:bg-muted-foreground/50 group-active/sheet-handle:bg-muted-foreground/60",
  {
    variants: {
      side: {
        top: "h-1 w-10",
        right: "h-10 w-1",
        bottom: "h-1 w-10",
        left: "h-10 w-1",
      },
    },
  }
)

type SheetSide = NonNullable<VariantProps<typeof sheetContentVariants>["side"]>

const SheetContentContext = React.createContext<{
  showCloseButton: boolean
}>({ showCloseButton: false })

function resolveSwipeDirection(side: SheetSide, rtl: boolean): SwipeDirection {
  if (side === "top") {
    return "up"
  }
  if (side === "bottom") {
    return "down"
  }
  return (side === "right") !== rtl ? "right" : "left"
}

function SheetContent({
  className,
  children,
  side = "right",
  showCloseButton = true,
  dir,
  ...props
}: SheetPrimitive.Popup.Props &
  VariantProps<typeof sheetContentVariants> & {
    showCloseButton?: boolean
  }) {
  const resolvedSide = side ?? "right"
  const setSwipeDirection = React.useContext(SheetRootContext)
  const context = React.useMemo(() => ({ showCloseButton }), [showCloseButton])

  React.useEffect(() => {
    const rtl =
      (dir ?? getComputedStyle(document.documentElement).direction) === "rtl"
    setSwipeDirection?.(resolveSwipeDirection(resolvedSide, rtl))
  }, [dir, resolvedSide, setSwipeDirection])

  return (
    <SheetContentContext.Provider value={context}>
      <SheetPortal>
        <SheetOverlay />
        <SheetPrimitive.Viewport
          data-slot="sheet-viewport"
          dir={dir}
          className="fixed inset-0 z-50"
        >
          <SheetPrimitive.Popup
            data-slot="sheet-content"
            data-side={resolvedSide}
            dir={dir}
            className={mergeClassName(
              sheetContentVariants({ side: resolvedSide }),
              className
            )}
            {...props}
          >
            <div
              data-slot="sheet-handle"
              aria-hidden="true"
              className={sheetHandleVariants({ side: resolvedSide })}
            >
              <div className={sheetHandleBarVariants({ side: resolvedSide })} />
            </div>
            <SheetPrimitive.Content
              data-slot="sheet-inner"
              className="flex min-h-0 flex-1 flex-col gap-4"
            >
              {children}
            </SheetPrimitive.Content>
            {showCloseButton ? (
              <SheetPrimitive.Close
                data-slot="sheet-close-button"
                aria-label="Close"
                render={<Button variant="ghost" size="icon-sm" />}
                className="absolute end-(--sheet-close-end,0.75rem) top-(--sheet-close-top,0.75rem) z-20 rounded-(--sheet-close-radius,var(--radius-md)) text-muted-foreground hover:text-foreground"
              >
                <IconX />
              </SheetPrimitive.Close>
            ) : null}
          </SheetPrimitive.Popup>
        </SheetPrimitive.Viewport>
      </SheetPortal>
    </SheetContentContext.Provider>
  )
}

function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
  const { showCloseButton } = React.useContext(SheetContentContext)

  return (
    <div
      data-slot="sheet-header"
      className={cn(
        "flex flex-col gap-1.5 px-6 pt-6",
        showCloseButton && "pe-12",
        className
      )}
      {...props}
    />
  )
}

function SheetBody({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-body"
      className={cn(
        "min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-none px-6 wrap-anywhere",
        className
      )}
      {...props}
    />
  )
}

function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-footer"
      className={cn(
        "mt-auto flex flex-col-reverse gap-2 px-6 pb-6 sm:flex-row sm:justify-end max-sm:[&>*:not([data-slot=button-status])]:h-10",
        className
      )}
      {...props}
    />
  )
}

function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      className={mergeClassName(
        "min-w-0 text-lg leading-snug font-semibold text-pretty wrap-anywhere",
        className
      )}
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: SheetPrimitive.Description.Props) {
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      className={mergeClassName(
        "min-w-0 text-sm text-pretty wrap-anywhere text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

const createSheetHandle = SheetPrimitive.createHandle

export {
  Sheet,
  SheetTrigger,
  SheetPortal,
  SheetOverlay,
  SheetContent,
  SheetHeader,
  SheetBody,
  SheetFooter,
  SheetTitle,
  SheetDescription,
  SheetClose,
  sheetContentVariants,
  sheetOverlayVariants,
  createSheetHandle,
  DialogLayerProvider,
  useDialogLayer,
}
```

```tsx title="components/ui/button.tsx"
"use client"

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
```

```tsx
<Sheet>
  <SheetTrigger render={<Button variant="outline" />}>Open</SheetTrigger>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>Edit profile</SheetTitle>
      <SheetDescription>Make changes to your profile.</SheetDescription>
    </SheetHeader>
    <SheetBody>{/* content */}</SheetBody>
    <SheetFooter>
      <SheetClose render={<Button />}>Save changes</SheetClose>
    </SheetFooter>
  </SheetContent>
</Sheet>
```

## Composition

```text
Sheet
├── SheetTrigger
└── SheetContent
    ├── SheetHeader
    │   ├── SheetTitle
    │   └── SheetDescription
    ├── SheetBody
    └── SheetFooter
        └── SheetClose
```

## Examples

### Sides

Set `side` to slide in from any edge. Each sheet can be swiped back towards its own edge, and the close gesture follows your finger with momentum.

```tsx title="components/examples/sheet/sides.tsx"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

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

export function SheetSides() {
  return (
    <div className="grid grid-cols-2 gap-2">
      {sides.map((side) => (
        <Sheet key={side}>
          <SheetTrigger render={<Button variant="outline" />}>
            {side}
          </SheetTrigger>
          <SheetContent side={side}>
            <SheetHeader>
              <SheetTitle>Opens from the {side}</SheetTitle>
              <SheetDescription>
                Swipe towards the {side} edge or press Esc to close it.
              </SheetDescription>
            </SheetHeader>
            <SheetFooter>
              <SheetClose render={<Button />}>Done</SheetClose>
            </SheetFooter>
          </SheetContent>
        </Sheet>
      ))}
    </div>
  )
}
```

### Long content

`<SheetBody />` scrolls on its own, so the header and footer stay in place however long the content gets.

```tsx title="components/examples/sheet/long-content.tsx"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetBody,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function SheetLongContent() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Release notes
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>What’s new</SheetTitle>
          <SheetDescription>Version 2.4.0</SheetDescription>
        </SheetHeader>
        <SheetBody>
          <div className="flex flex-col gap-3 text-sm text-muted-foreground">
            {Array.from({ length: 24 }, (_, index) => (
              <p key={index}>
                {index + 1}. Improved performance of the dashboard charts and
                fixed an issue where filters reset after navigation.
              </p>
            ))}
          </div>
        </SheetBody>
        <SheetFooter>
          <SheetClose render={<Button />}>Got it</SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

### Without the close button

Set `showCloseButton={false}` when the footer already has a way out. Escape, the backdrop and swiping still close it.

```tsx title="components/examples/sheet/no-close-button.tsx"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function SheetNoCloseButton() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>Share</SheetTrigger>
      <SheetContent side="bottom" showCloseButton={false}>
        <SheetHeader>
          <SheetTitle>Share link</SheetTitle>
          <SheetDescription>
            Anyone with the link can view this document.
          </SheetDescription>
        </SheetHeader>
        <SheetFooter>
          <SheetClose render={<Button />}>Done</SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  )
}
```

### Nested

A sheet or alert dialog opened from a sheet layers on top. The parent scales back slightly and each extra layer adds a lighter backdrop, so the stack stays readable. Escape closes only the top layer.

```tsx title="components/examples/sheet/nested.tsx"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetBody,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function SheetNested() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Workspace settings
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Workspace settings</SheetTitle>
          <SheetDescription>
            Manage members and the danger zone.
          </SheetDescription>
        </SheetHeader>
        <SheetBody>
          <div className="flex flex-col items-start gap-2">
            <Sheet>
              <SheetTrigger render={<Button variant="outline" />}>
                Manage members
              </SheetTrigger>
              <SheetContent>
                <SheetHeader>
                  <SheetTitle>Members</SheetTitle>
                  <SheetDescription>
                    The parent sheet steps back while this one is open.
                  </SheetDescription>
                </SheetHeader>
              </SheetContent>
            </Sheet>
            <AlertDialog>
              <AlertDialogTrigger render={<Button variant="destructive" />}>
                Delete workspace
              </AlertDialogTrigger>
              <AlertDialogContent>
                <AlertDialogHeader>
                  <AlertDialogTitle>Delete workspace?</AlertDialogTitle>
                  <AlertDialogDescription>
                    All projects in this workspace will be removed.
                  </AlertDialogDescription>
                </AlertDialogHeader>
                <AlertDialogFooter>
                  <AlertDialogCancel>Cancel</AlertDialogCancel>
                  <AlertDialogAction variant="destructive">
                    Delete
                  </AlertDialogAction>
                </AlertDialogFooter>
              </AlertDialogContent>
            </AlertDialog>
          </div>
        </SheetBody>
      </SheetContent>
    </Sheet>
  )
}
```

### Controlled

Pass `open` and `onOpenChange` to open it from anywhere, without a trigger.

```tsx title="components/examples/sheet/controlled.tsx"
"use client"

import * as React from "react"

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

export function SheetControlled() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open from state
      </Button>
      <span className="text-sm text-muted-foreground">
        {open ? "Open" : "Closed"}
      </span>
      <Sheet open={open} onOpenChange={setOpen}>
        <SheetContent>
          <SheetHeader>
            <SheetTitle>Edit profile</SheetTitle>
            <SheetDescription>
              Changes are saved to your account when you click save.
            </SheetDescription>
          </SheetHeader>
          <SheetBody>
            <div className="flex flex-col gap-4">
              <div className="flex flex-col gap-1.5">
                <label
                  htmlFor="sheet-controlled-name"
                  className="text-sm font-medium"
                >
                  Name
                </label>
                <input
                  id="sheet-controlled-name"
                  defaultValue="Olivia Martin"
                  className="h-9 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
                />
              </div>
              <div className="flex flex-col gap-1.5">
                <label
                  htmlFor="sheet-controlled-username"
                  className="text-sm font-medium"
                >
                  Username
                </label>
                <input
                  id="sheet-controlled-username"
                  defaultValue="@olivia"
                  className="h-9 rounded-md border border-input bg-transparent px-3 text-sm transition-shadow outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden pointer-coarse:text-touch"
                />
              </div>
            </div>
          </SheetBody>
          <SheetFooter>
            <SheetClose render={<Button variant="outline" />}>
              Cancel
            </SheetClose>
            <SheetClose render={<Button />}>Save changes</SheetClose>
          </SheetFooter>
        </SheetContent>
      </Sheet>
    </div>
  )
}
```

### Detached triggers

Share one sheet between several triggers with `createSheetHandle`. Each trigger passes a `payload` that the sheet renders through a function child.

```tsx title="components/examples/sheet/detached.tsx"
"use client"

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

const orders = createSheetHandle<{ id: string; status: string }>()

export function SheetDetached() {
  return (
    <div className="flex flex-wrap justify-center gap-2">
      <SheetTrigger
        handle={orders}
        payload={{ id: "#1042", status: "Shipped" }}
        render={<Button variant="outline" size="sm" />}
      >
        Order #1042
      </SheetTrigger>
      <SheetTrigger
        handle={orders}
        payload={{ id: "#1043", status: "Processing" }}
        render={<Button variant="outline" size="sm" />}
      >
        Order #1043
      </SheetTrigger>
      <Sheet handle={orders}>
        {({ payload }) => (
          <SheetContent>
            <SheetHeader>
              <SheetTitle>Order {payload?.id}</SheetTitle>
              <SheetDescription>Status: {payload?.status}</SheetDescription>
            </SheetHeader>
          </SheetContent>
        )}
      </Sheet>
    </div>
  )
}
```

### Right to left

With `dir="rtl"`, the right side mirrors to the left edge, slides in from there and swipes out the same way.

```tsx title="components/examples/sheet/rtl.tsx"
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function SheetRtl() {
  return (
    <div dir="rtl">
      <Sheet>
        <SheetTrigger render={<Button variant="outline" />}>افتح</SheetTrigger>
        <SheetContent dir="rtl">
          <SheetHeader>
            <SheetTitle>تعديل الملف الشخصي</SheetTitle>
            <SheetDescription>
              يتم حفظ التغييرات عند النقر على حفظ.
            </SheetDescription>
          </SheetHeader>
          <SheetFooter>
            <SheetClose render={<Button variant="outline" />}>إلغاء</SheetClose>
            <SheetClose render={<Button />}>حفظ</SheetClose>
          </SheetFooter>
        </SheetContent>
      </Sheet>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Enter` `Space` | On the trigger, opens the sheet and moves focus inside it. |
| `Tab` `Shift + Tab` | Moves between focusable elements. Focus stays inside the sheet. |
| `Esc` | Closes the topmost sheet and returns focus to its trigger. |

## Accessibility

- The sheet is a modal dialog. `<SheetTitle />` labels it and `<SheetDescription />` describes it, so always include a title.
- Page scroll is locked and content behind it is hidden from assistive tech while it is open.
- The close button is labelled “Close”. The handle on the inner edge shows the sheet can be dragged and is hidden from assistive tech.
- With reduced motion enabled, the sheet fades in and out instead of sliding.

## API reference

Built on the Base UI drawer. Every part accepts the props of the primitive it wraps.

### Sheet

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `false` |  |
| `open` | `boolean` | – |  |
| `onOpenChange` | `(open: boolean, details) => void` | – |  |
| `onOpenChangeComplete` | `(open: boolean) => void` | – | Called after the open or close animation ends. |
| `modal` | `boolean \| "trap-focus"` | `true` |  |
| `disablePointerDismissal` | `boolean` | `false` | Keep it open when the backdrop is clicked. |
| `swipeDirection` | `"up" \| "down" \| "left" \| "right"` | – | Set automatically from the content’s side and direction. |
| `handle` | `SheetHandle<Payload>` | – |  |
| `children` | `ReactNode \| ({ payload }) => ReactNode` | – |  |

### SheetTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `handle` | `SheetHandle<Payload>` | – |  |
| `payload` | `Payload` | – |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-trigger"` | Target the trigger in CSS. |
| `data-popup-open` | Present while its sheet is open. |

### SheetContent

Renders the portal, backdrop, viewport and popup, plus the drag handle and close button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` |  |
| `showCloseButton` | `boolean` | `true` |  |
| `dir` | `"ltr" \| "rtl"` | – | Sets the direction for the sheet. right and left mirror in rtl. |
| `initialFocus` | `boolean \| RefObject \| (type) => HTMLElement \| boolean` | – |  |
| `finalFocus` | `boolean \| RefObject \| (type) => HTMLElement \| boolean` | – |  |
| `className` | `string \| (state) => string` | – | Side sheets are 75% wide, up to 24rem from the sm breakpoint. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-content"` | The sheet panel. |
| `data-side` | The side it opens from. |
| `data-open` | Present while the sheet is open. |
| `data-starting-style` | Present while it animates in. |
| `data-ending-style` | Present while it animates out. |
| `data-swiping` | Present while it is being dragged. |
| `data-swipe-direction` | The direction that dismisses it. |
| `data-nested-drawer-open` | Present while a nested sheet is open on top. |
| `data-slot="sheet-handle"` | The drag handle on the inner edge. |
| `data-slot="sheet-close-button"` | The built-in close button. |
| `--drawer-swipe-movement-x` | Horizontal drag distance. A -y variable exists too. |
| `--nested-drawers` | How many nested sheets are open on top. |

### SheetOverlay

Rendered by `<SheetContent />`. Nested layers get a lighter backdrop.

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-overlay"` | The backdrop. |
| `data-nested` | Present on backdrops of nested layers. |

### SheetHeader, SheetBody, SheetFooter

Plain `<div>` elements that lay out the sheet. The header leaves room for the close button, the body scrolls and takes the remaining height, and the footer stacks its actions on small screens and lines them up at the end from the sm breakpoint.

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-header"` | Title and description. |
| `data-slot="sheet-body"` | Scrollable content. |
| `data-slot="sheet-footer"` | Actions. |

### SheetTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<h2>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-title"` | Labels the sheet. |

### SheetDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<p>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-description"` | Describes the sheet. |

### SheetClose

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="sheet-close"` | Closes the sheet when pressed. |

### createSheetHandle

`createSheetHandle<Payload>()` returns a handle that connects a `<Sheet />` to triggers rendered elsewhere. Create it once, outside your component.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
