A bottom action sheet on Radix Dialog with destructive zoning and a separated cancel, sliding in with the drawer ease.
npx shadcn@latest add @paragon/action-sheet"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
/*
* Bottom action sheet on Radix Dialog. The panel slides up 300ms with the
* drawer ease and exits 200ms with the exit ease, animating the `translate`
* property only (never layout). Reduced motion swaps the slide for a fade.
* The <style> is hoisted OUTSIDE the Portal (React 19 drops a bare <style>
* inside a Portal subtree, which would leave the exit keyframe missing).
*/
const actionSheetStyles = `
@keyframes pg-action-overlay-in { from { opacity: 0; } }
@keyframes pg-action-overlay-out { to { opacity: 0; } }
@keyframes pg-action-in { from { translate: 0 100%; } }
@keyframes pg-action-out { to { translate: 0 100%; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-action-in { from { opacity: 0; } }
@keyframes pg-action-out { to { opacity: 0; } }
}
`;
function ActionSheet(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="action-sheet" {...props} />;
}
function ActionSheetTrigger(
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
) {
return <DialogPrimitive.Trigger data-slot="action-sheet-trigger" {...props} />;
}
export interface ActionSheetContentProps
extends React.ComponentProps<typeof DialogPrimitive.Content> {
/** Optional heading shown above the actions. */
title?: string;
description?: string;
}
function ActionSheetContent({
className,
children,
title,
description,
...props
}: ActionSheetContentProps) {
const titleId = React.useId();
const descId = React.useId();
// Split the Cancel row out of the action list so it renders as its own
// separated card below, iOS-style. Everything else stays in the list card.
const items: React.ReactNode[] = [];
let cancel: React.ReactNode = null;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ActionSheetCancel) {
cancel = child;
} else {
items.push(child);
}
});
return (
<>
<style href="paragon-action-sheet" precedence="paragon">
{actionSheetStyles}
</style>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="action-sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/40 dark:bg-black/60",
"data-[state=open]:animate-[pg-action-overlay-in_300ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-action-overlay-out_200ms_var(--ease-exit)_forwards]",
)}
/>
<DialogPrimitive.Content
data-slot="action-sheet-content"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mx-auto flex max-w-md flex-col gap-2 p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] outline-none",
"data-[state=open]:animate-[pg-action-in_300ms_var(--ease-drawer)]",
"data-[state=closed]:animate-[pg-action-out_200ms_var(--ease-exit)_forwards]",
className,
)}
{...props}
>
<div className="flex flex-col overflow-hidden rounded-2xl bg-popover shadow-overlay">
{(title || description) && (
<div className="flex flex-col gap-0.5 border-b border-border px-4 py-3 text-center">
{title && (
<DialogPrimitive.Title
id={titleId}
className="text-sm font-semibold"
>
{title}
</DialogPrimitive.Title>
)}
{description && (
<DialogPrimitive.Description
id={descId}
className="text-xs text-muted-foreground"
>
{description}
</DialogPrimitive.Description>
)}
</div>
)}
<div className="flex flex-col">{items}</div>
</div>
{cancel}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</>
);
}
export interface ActionSheetItemProps
extends React.ComponentProps<"button"> {
variant?: "default" | "destructive";
/** Close the sheet after the click handler runs. */
closeOnSelect?: boolean;
}
/** One action row. Destructive actions tint red and zone at the list's foot. */
function ActionSheetItem({
variant = "default",
closeOnSelect = true,
className,
children,
...props
}: ActionSheetItemProps) {
const button = (
<button
type="button"
data-slot="action-sheet-item"
className={cn(
"flex min-h-12 items-center justify-center gap-2 border-t border-border px-4 text-sm font-medium outline-none transition-[background-color] duration-150 first:border-t-0 hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0",
variant === "destructive"
? "text-destructive"
: "text-foreground",
className,
)}
{...props}
>
{children}
</button>
);
return closeOnSelect ? (
<DialogPrimitive.Close asChild>{button}</DialogPrimitive.Close>
) : (
button
);
}
/** A prominent Cancel footer, visually separated from the action list. */
function ActionSheetCancel({
className,
children = "Cancel",
...props
}: React.ComponentProps<"button">) {
return (
<DialogPrimitive.Close asChild>
<button
type="button"
data-slot="action-sheet-cancel"
className={cn(
"flex min-h-12 items-center justify-center rounded-2xl bg-popover text-sm font-semibold text-foreground shadow-overlay outline-none transition-[background-color] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...props}
>
{children}
</button>
</DialogPrimitive.Close>
);
}
export {
ActionSheet,
ActionSheetTrigger,
ActionSheetContent,
ActionSheetItem,
ActionSheetCancel,
};