A panel that slides in from a screen edge — built on the exact same @radix-ui/react-dialog primitive as Dialog, just styled differently. Every Dialog behavior (focus trap, scroll lock, dismiss logic) applies identically here.
npx chandra-asri-ui add sheetThe side prop picks which edge it slides in from — top, right (default), bottom, left, or fullscreen (fade only, no directional slide). Pass hideCloseButton to drop the built-in close button when a custom one is rendered inside the header instead, via SheetClose asChild.
"use client";
import { buttonVariants } from "@/app/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/app/components/ui/sheet";
import { cn } from "@/lib/utils";
const SIDES = [
{ side: "top", label: "Top" },
{ side: "right", label: "Right" },
{ side: "bottom", label: "Bottom" },
{ side: "left", label: "Left" },
{ side: "fullscreen", label: "Fullscreen" },
] as const;
/**
* One Sheet per `side` — all five slide-in directions. `fullscreen` only
* fades in/out (no directional slide), the other four slide in from their
* named edge. Every Sheet here shares the same modal behavior as Dialog
* (focus trap, scroll lock, Escape/overlay dismiss) since it's built on the
* exact same Radix Dialog primitive.
*/
export function SheetDemo() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
{SIDES.map(({ side, label }) => (
<Sheet key={side}>
<SheetTrigger className={cn(buttonVariants({ variant: "outline" }))}>
{label}
</SheetTrigger>
<SheetContent side={side}>
<SheetHeader>
<SheetTitle>{label} sheet</SheetTitle>
<SheetDescription>
Slides in from the {label.toLowerCase()}
{side === "fullscreen" ? " — fade only, no slide." : "."}
</SheetDescription>
</SheetHeader>
</SheetContent>
</Sheet>
))}
</div>
);
}
Overlay is bg-black/80(much darker than Dialog's bg-black/10) even though both come from the identical Radix primitive — an intentional visual difference, not a bug. Open/close durations differ from Dialog too: 500ms opening, 300ms closing (Dialog uses a uniform 200ms for both). Sheet stays at z-50 — Dialog is bumped to z-[75] so it always layers above an open Sheet if both happen to be open at once.
"use client";
import { cn } from "@/lib/utils";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import * as React from "react";
/**
* Sheet isn't a different component from Dialog under the hood — it
* imports the exact same `@radix-ui/react-dialog` primitive, so every
* behavior documented on Dialog (focus trap, scroll lock, `aria-hidden`
* siblings, Escape/overlay dismiss, controlled/uncontrolled via
* `open`/`onOpenChange`) applies identically here, including the
* framer-motion + `forceMount` animation setup (see the comment at the top
* of `dialog.tsx` for why it's built that way instead of CSS keyframes).
* Sheet needs its own `SheetOpenContext` since it's a separate `open`
* value from any Dialog that might also be open elsewhere on the page.
*
* Styling is the only real difference: Sheet slides in from a screen edge
* instead of appearing centered, and stays at `z-50` (Dialog is bumped to
* `z-[75]` so it always layers above an open Sheet). Sliding a fixed,
* edge-anchored panel has none of Dialog's centering-transform complexity,
* so — unlike `DialogContent` — there's no need to split the static
* positioning and the animated motion across two nested elements here;
* `SheetPrimitive.Content` is animated directly.
*/
const SheetOpenContext = React.createContext(false);
export function Sheet({
open,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentPropsWithoutRef<typeof SheetPrimitive.Root>) {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : uncontrolledOpen;
return (
<SheetOpenContext.Provider value={currentOpen}>
<SheetPrimitive.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={(next) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
}}
{...props}
/>
</SheetOpenContext.Provider>
);
}
export const SheetTrigger = SheetPrimitive.Trigger;
export const SheetClose = SheetPrimitive.Close;
const OVERLAY_TRANSITION = { duration: 0.15, ease: "easeOut" as const };
const sheetVariants = cva(
"bg-background fixed z-50 gap-4 p-6 shadow-lg outline-none",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b",
bottom: "inset-x-0 bottom-0 border-t",
left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
fullscreen: "inset-0 h-full w-full border-none",
},
},
defaultVariants: {
side: "right",
},
},
);
type SheetSide = NonNullable<VariantProps<typeof sheetVariants>["side"]>;
const SLIDE_MOTION: Record<
SheetSide,
{
initial: Record<string, string | number>;
animate: Record<string, string | number>;
}
> = {
top: {
initial: { y: "-100%", opacity: 0 },
animate: { y: "0%", opacity: 1 },
},
bottom: {
initial: { y: "100%", opacity: 0 },
animate: { y: "0%", opacity: 1 },
},
left: {
initial: { x: "-100%", opacity: 0 },
animate: { x: "0%", opacity: 1 },
},
right: {
initial: { x: "100%", opacity: 0 },
animate: { x: "0%", opacity: 1 },
},
fullscreen: { initial: { opacity: 0 }, animate: { opacity: 1 } },
};
export interface SheetContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
/** Skip the built-in top-right close button — useful when the caller
* wants a custom close trigger (e.g. inside its own header row) via
* `SheetClose asChild`. */
hideCloseButton?: boolean;
}
export const SheetContent = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", hideCloseButton, className, children, ...props }, ref) => {
const open = React.useContext(SheetOpenContext);
const resolvedSide = side ?? "right";
const { initial, animate } = SLIDE_MOTION[resolvedSide];
return (
<AnimatePresence>
{open && (
<SheetPrimitive.Portal forceMount>
<SheetPrimitive.Overlay forceMount asChild>
<motion.div
className="fixed inset-0 z-50 bg-black/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={OVERLAY_TRANSITION}
/>
</SheetPrimitive.Overlay>
<SheetPrimitive.Content ref={ref} forceMount asChild {...props}>
<motion.div
initial={initial}
animate={{
...animate,
transition: { duration: 0.5, ease: "easeInOut" },
}}
exit={{
...initial,
transition: { duration: 0.3, ease: "easeInOut" },
}}
className={cn(
sheetVariants({ side: resolvedSide }),
"border-border",
className,
)}
>
{children}
{!hideCloseButton && (
<SheetPrimitive.Close
className={cn(
"ring-offset-background focus:ring-primary-30 data-[state=open]:bg-gray-70 absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none",
)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</motion.div>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>
)}
</AnimatePresence>
);
});
SheetContent.displayName = "SheetContent";
export function SheetHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
}
SheetHeader.displayName = "SheetHeader";
export function SheetFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
}
SheetFooter.displayName = "SheetFooter";
export const SheetTitle = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-foreground text-lg font-semibold", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
export const SheetDescription = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-muted text-sm", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;