Mobile & Consumer

Action Sheet

A bottom action sheet on Radix Dialog with destructive zoning and a separated cancel, sliding in with the drawer ease.

Install

npx shadcn@latest add @paragon/action-sheet

action-sheet.tsx

"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,
};