Overlays

Sheet

A side drawer on Radix Dialog: slides in with the drawer ease over 300ms, exits in 200ms, bottom variant with concentric top radius.

Install

npx shadcn@latest add @paragon/sheet

sheet.tsx

"use client";

import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";

/*
 * Side drawer on Radix Dialog. Translates in over 300ms with the drawer
 * ease, exits in 200ms with the exit ease. Keyframes animate the
 * individual `translate` property (never layout). Reduced motion swaps
 * the slide for a plain fade.
 *
 * The bottom sheet's top radius is concentric: outer radius =
 * inner control radius (--radius) + the p-6 content padding (1.5rem).
 */
const sheetStyles = `
@keyframes pg-sheet-overlay-in { from { opacity: 0; } }
@keyframes pg-sheet-overlay-out { to { opacity: 0; } }
@keyframes pg-sheet-in-right { from { translate: 100% 0; } }
@keyframes pg-sheet-out-right { to { translate: 100% 0; } }
@keyframes pg-sheet-in-left { from { translate: -100% 0; } }
@keyframes pg-sheet-out-left { to { translate: -100% 0; } }
@keyframes pg-sheet-in-bottom { from { translate: 0 100%; } }
@keyframes pg-sheet-out-bottom { to { translate: 0 100%; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-sheet-in-right { from { opacity: 0; } }
  @keyframes pg-sheet-out-right { to { opacity: 0; } }
  @keyframes pg-sheet-in-left { from { opacity: 0; } }
  @keyframes pg-sheet-out-left { to { opacity: 0; } }
  @keyframes pg-sheet-in-bottom { from { opacity: 0; } }
  @keyframes pg-sheet-out-bottom { to { opacity: 0; } }
}
`;

const sheetVariants = cva(
  "fixed z-50 flex flex-col gap-4 overflow-y-auto bg-card p-6 text-card-foreground shadow-overlay outline-none",
  {
    variants: {
      side: {
        right: [
          "inset-y-0 right-0 h-full w-3/4 max-w-sm",
          "data-[state=open]:animate-[pg-sheet-in-right_300ms_var(--ease-drawer)]",
          "data-[state=closed]:animate-[pg-sheet-out-right_200ms_var(--ease-exit)_forwards]",
        ],
        left: [
          "inset-y-0 left-0 h-full w-3/4 max-w-sm",
          "data-[state=open]:animate-[pg-sheet-in-left_300ms_var(--ease-drawer)]",
          "data-[state=closed]:animate-[pg-sheet-out-left_200ms_var(--ease-exit)_forwards]",
        ],
        bottom: [
          "inset-x-0 bottom-0 max-h-[85svh] rounded-t-[calc(var(--radius)+1.5rem)]",
          "data-[state=open]:animate-[pg-sheet-in-bottom_300ms_var(--ease-drawer)]",
          "data-[state=closed]:animate-[pg-sheet-out-bottom_200ms_var(--ease-exit)_forwards]",
        ],
      },
    },
    defaultVariants: {
      side: "right",
    },
  },
);

function Sheet(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
  return <DialogPrimitive.Root data-slot="sheet" {...props} />;
}

function SheetTrigger(
  props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
  return <DialogPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}

function SheetClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
  return <DialogPrimitive.Close data-slot="sheet-close" {...props} />;
}

export interface SheetContentProps
  extends React.ComponentProps<typeof DialogPrimitive.Content>,
    VariantProps<typeof sheetVariants> {
  /** Hide the top-right close button. */
  showClose?: boolean;
}

function SheetContent({
  className,
  children,
  side = "right",
  showClose = true,
  ...props
}: SheetContentProps) {
  return (
    <>
      {/* Hoisted outside the Portal: React 19 does not reliably render a
          bare <style> inside a Portal subtree, dropping the keyframes. */}
      <style href="paragon-sheet" precedence="paragon">{sheetStyles}</style>
      <DialogPrimitive.Portal>
        <DialogPrimitive.Overlay
          data-slot="sheet-overlay"
          className={cn(
            "fixed inset-0 z-50 bg-black/40 dark:bg-black/60",
            "data-[state=open]:animate-[pg-sheet-overlay-in_300ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-sheet-overlay-out_200ms_var(--ease-exit)_forwards]",
          )}
        />
        <DialogPrimitive.Content
          data-slot="sheet-content"
          data-side={side}
          className={cn(sheetVariants({ side }), className)}
          {...props}
        >
          {children}
          {showClose && (
            <DialogPrimitive.Close
              aria-label="Close"
              className="absolute top-4 right-4 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 outline-none after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X className="size-4" />
            </DialogPrimitive.Close>
          )}
        </DialogPrimitive.Content>
      </DialogPrimitive.Portal>
    </>
  );
}

function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="sheet-header"
      className={cn("flex flex-col gap-1.5", 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 sm:flex-row sm:justify-end",
        className,
      )}
      {...props}
    />
  );
}

function SheetTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      data-slot="sheet-title"
      className={cn("text-base leading-none font-semibold", className)}
      {...props}
    />
  );
}

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

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