A side drawer on Radix Dialog: slides in with the drawer ease over 300ms, exits in 200ms, bottom variant with concentric top radius.
npx shadcn@latest add @paragon/sheet"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,
};