Skip to content

Rises from the bottom, swipes away, and holds for slow actions.

Menus & tooltips@base-ui/reactmotion

01Preview

Finance

q3-forecast.xlsx

248 KB · Edited today by Maya Chen

Owner
Maya Chen
Shared with
Finance team

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion

03Usage

import { ActionSheet, ActionSheetContent, ActionSheetItem, ActionSheetTrigger } from "@/components/ui/action-sheet";

<ActionSheet>
  <ActionSheetTrigger aria-label="Actions for q3-forecast.xlsx">
    <MoreH />
  </ActionSheetTrigger>
  <ActionSheetContent title="q3-forecast.xlsx" description="248 KB · Edited today">
    <ActionSheetItem onSelect={copyLink}>Copy link</ActionSheetItem>
    {/* Returning a promise keeps the sheet up until it settles. */}
    <ActionSheetItem onSelect={() => download(file)}>Download</ActionSheetItem>
    <ActionSheetItem variant="danger" onSelect={remove}>Delete file</ActionSheetItem>
  </ActionSheetContent>
</ActionSheet>

04Source

"use client";
import { Drawer } from "@base-ui/react/drawer";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useId, useState } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

type Ctx = {
  close: () => void;
  busy: string | null;
  setBusy: (id: string | null) => void;
  contained: boolean;
};
const SheetContext = createContext<Ctx | null>(null);
const PortalTarget = createContext<HTMLElement | undefined>(undefined);
const useSheet = () => {
  const ctx = useContext(SheetContext);
  if (!ctx) throw new Error("ActionSheet parts must be used inside <ActionSheet>");
  return ctx;
};

export type ActionSheetProps = Omit<Drawer.Root.Props, "open" | "defaultOpen" | "onOpenChange" | "swipeDirection" | "children"> & {
  children?: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /**
   * Render inside this element instead of over the page: the backdrop and sheet
   * fill it, and page scroll stays unlocked. Give it `position: relative` and
   * `overflow: hidden`.
   */
  container?: HTMLElement | null;
};

/** A list of actions that rises from the bottom edge: the phone's version of a menu. */
export function ActionSheet({ open, defaultOpen = false, onOpenChange, container, modal, children, ...rest }: ActionSheetProps) {
  const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
  const [busy, setBusy] = useState<string | null>(null);
  const contained = container !== undefined;
  return (
    <SheetContext.Provider value={{ close: () => setOpen(false), busy, setBusy, contained }}>
      <Drawer.Root
        open={isOpen}
        onOpenChange={(next) => setOpen(next)}
        onOpenChangeComplete={(next) => !next && setBusy(null)}
        swipeDirection="down"
        modal={modal ?? (contained ? "trap-focus" : true)}
        {...rest}
      >
        <PortalTarget.Provider value={container ?? undefined}>{children}</PortalTarget.Provider>
      </Drawer.Root>
    </SheetContext.Provider>
  );
}

export type ActionSheetTriggerProps = Drawer.Trigger.Props;

/** Opens the sheet. Unstyled: pass `render` to use your own button, or style it with className. */
export function ActionSheetTrigger(props: ActionSheetTriggerProps) {
  return <Drawer.Trigger {...props} />;
}

export type ActionSheetContentProps = Omit<Drawer.Popup.Props, "title"> & {
  /** A short line naming what the actions apply to. */
  title?: React.ReactNode;
  /** One more line of context under the title. */
  description?: React.ReactNode;
  /** Accessible name when there is no visible title. */
  label?: string;
  cancelLabel?: string;
};

/**
 * The sheet: a card of actions and, apart from it, Cancel, where the thumb rests.
 * Swipe it down, tap outside or press Escape to dismiss.
 */
export function ActionSheetContent({ title, description, label = "Actions", cancelLabel = "Cancel", className, children, ...rest }: ActionSheetContentProps) {
  const { contained } = useSheet();
  const container = useContext(PortalTarget);

  // Arrow keys walk the actions like a menu; Tab still works as in any dialog.
  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
    const buttons = [...e.currentTarget.querySelectorAll<HTMLButtonElement>("[data-sheet-action]:not(:disabled):not([aria-disabled=true])")];
    if (!buttons.length) return;
    e.preventDefault();
    const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
    const next =
      e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : e.key === "ArrowDown" ? (at + 1) % buttons.length : (at - 1 + buttons.length) % buttons.length;
    buttons[next]?.focus();
  };

  return (
    <Drawer.Portal container={container}>
      <Drawer.Backdrop
        className={cn(
          contained ? "absolute" : "fixed",
          "inset-0 z-(--z-overlay) bg-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]",
          "transition-opacity duration-400 ease-drawer data-swiping:duration-0",
          "data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*280ms)]",
        )}
      />
      <Drawer.Viewport className={cn(contained ? "absolute" : "fixed", "inset-0 z-(--z-dialog) flex items-end justify-center")}>
        <Drawer.Popup
          onKeyDown={onKeyDown}
          className={(state) =>
            cn(
              "pointer-events-none flex w-full max-w-[420px] flex-col gap-2 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] outline-none",
              // Follows the finger 1:1 while swiped; otherwise rides the sheet curve in and out.
              "[transform:translateY(var(--drawer-swipe-movement-y))] transition-transform duration-400 ease-drawer data-swiping:duration-0 data-swiping:select-none",
              "data-starting-style:[transform:translateY(calc(100%+8px))] data-ending-style:[transform:translateY(calc(100%+8px))]",
              "data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*280ms)]",
              typeof className === "function" ? className(state) : className,
            )
          }
          {...rest}
        >
          <Drawer.Content className="pointer-events-auto overflow-hidden rounded-2xl border border-line-2 bg-raised shadow-pop">
            {title || description ? (
              <div className="flex flex-col items-center gap-0.5 border-b border-line px-5 py-3.5 text-center">
                {title && <Drawer.Title className="text-balance text-[13px] font-medium leading-[18px] text-fg-2">{title}</Drawer.Title>}
                {description && <Drawer.Description className="text-balance text-[12.5px] leading-[18px] text-fg-3">{description}</Drawer.Description>}
                {!title && <Drawer.Title className="sr-only">{label}</Drawer.Title>}
              </div>
            ) : (
              <Drawer.Title className="sr-only">{label}</Drawer.Title>
            )}
            <div role="group" aria-label={typeof title === "string" ? title : label} className="flex flex-col divide-y divide-line">
              {children}
            </div>
          </Drawer.Content>
          <Drawer.Close data-sheet-action="" className={cn(rowClass, "pointer-events-auto rounded-2xl text-fg border border-line-2 bg-raised font-medium shadow-pop")}>
            {cancelLabel}
          </Drawer.Close>
        </Drawer.Popup>
      </Drawer.Viewport>
    </Drawer.Portal>
  );
}

const rowClass = cn(
  "relative flex min-h-13 w-full select-none flex-col items-center justify-center px-12 py-2 text-center text-[15px] leading-5 outline-none",
  "touch-manipulation [-webkit-tap-highlight-color:transparent] transition-colors duration-100",
  "hover:bg-fg/[0.04] active:bg-fg/[0.08] focus-visible:bg-fg/[0.04] focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-4 focus-visible:outline-fg-3",
  "disabled:opacity-40 disabled:hover:bg-transparent aria-disabled:opacity-40 aria-disabled:hover:bg-transparent aria-disabled:active:bg-transparent",
);

export type ActionSheetItemProps = Omit<React.ComponentProps<"button">, "onSelect"> & {
  /**
   * Runs the action. Return a promise to keep the sheet up with a spinner on this
   * row until it settles: it closes on success and says so on the row if it fails.
   */
  onSelect?: () => void | Promise<unknown>;
  /** Destructive: danger text. Put it last. */
  variant?: "default" | "danger";
  /** Shown under the label if the returned promise rejects without a message. */
  errorLabel?: string;
};

export function ActionSheetItem({ onSelect, variant = "default", errorLabel = "Couldn’t finish. Try again.", disabled, className, children, onClick, ...rest }: ActionSheetItemProps) {
  const { close, busy, setBusy } = useSheet();
  const id = useId();
  const reduce = useReducedMotion();
  const [error, setError] = useState<string | null>(null);
  const mine = busy === id;
  const blocked = busy !== null && !mine;

  return (
    <button
      type="button"
      data-sheet-action=""
      data-variant={variant}
      data-busy={mine ? "" : undefined}
      disabled={disabled}
      aria-disabled={blocked || undefined}
      aria-busy={mine || undefined}
      onClick={async (e) => {
        onClick?.(e);
        if (e.defaultPrevented || busy !== null) return;
        setError(null);
        const result = onSelect?.();
        if (!result || typeof (result as Promise<unknown>).then !== "function") return close();
        setBusy(id);
        try {
          await result;
          close();
        } catch (err) {
          setBusy(null);
          setError(err instanceof Error && err.message ? err.message : errorLabel);
        }
      }}
      className={cn(rowClass, variant === "danger" ? "text-danger" : "text-fg", mine && "cursor-progress bg-fg/[0.04]", className)}
      {...rest}
    >
      <span className="max-w-full truncate">{children}</span>
      <AnimatePresence initial={false}>
        {error && (
          <motion.span
            key="error"
            role="alert"
            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: "auto" }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.2, ease: ease.out }}
            className="block overflow-hidden text-[12.5px] leading-[18px] text-danger"
          >
            {error}
          </motion.span>
        )}
      </AnimatePresence>
      <AnimatePresence>
        {mine && (
          <motion.span
            key="busy"
            aria-hidden
            initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, transition: { duration: 0.1 } }}
            transition={{ duration: 0.18, ease: ease.out }}
            className="absolute end-4 top-1/2 -mt-2 grid size-4 place-items-center text-fg-3"
          >
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="animate-spin motion-reduce:animate-spin-slow" aria-hidden>
              <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
              <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
            </svg>
          </motion.span>
        )}
      </AnimatePresence>
    </button>
  );
}

05Props

ActionSheet

PropTypeDefaultDescription
openbooleanControlled open state. Use with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => voidCalled when the sheet opens or closes, by any route: action, Cancel, swipe, backdrop or Escape.
containerHTMLElement | nullRender inside this element instead of over the page. It needs position: relative and overflow: hidden. Page scroll stays unlocked.
modalboolean | "trap-focus"true, or "trap-focus" with a containerWhether page scroll locks and outside interaction is blocked while open.

ActionSheetTrigger

PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementPut the behavior on your own button. Unstyled otherwise; the open trigger carries data-popup-open.

ActionSheetContent

PropTypeDefaultDescription
titleReactNodeNames what the actions apply to. Becomes the sheet's accessible name.
descriptionReactNodeOne more line of context under the title.
labelstring"Actions"Accessible name when there is no visible title.
cancelLabelstring"Cancel"Label of the separate dismiss button.

ActionSheetItem

PropTypeDefaultDescription
onSelect() => void | Promise<unknown>Runs the action and closes the sheet. A returned promise shows a spinner on this row and closes on success.
variant"default" | "danger""default"Danger text for destructive actions. Put them last.
errorLabelstring"Couldn’t finish. Try again."Shown on the row when the promise rejects without a message of its own.
disabledbooleanfalseDimmed and skipped by the arrow keys.

06Notes

Behavior

  • Cancel sits apart from the actions, at the bottom where the thumb already is, and destructive actions stay in the list in the danger color.
  • An action that returns a promise keeps the sheet open: its row holds a spinner while the others step back and ignore taps; success closes the sheet, failure says so on that row and leaves it open to retry.
  • Swipe down past the threshold, or flick, to dismiss; a short drag springs back. Mouse drags inside the card select text rather than swipe.
  • With a container it renders inside that element and leaves page scroll alone, which is how the preview stays inside the phone.

Motion

  • Rises from below the edge on the sheet curve in 400ms and follows the finger 1:1 while swiped; the backdrop fades with the swipe.
  • Leaving takes 280ms scaled by the swipe's speed, so a hard flick is gone faster than a slow drag.
  • Rows darken on press in 100ms. Reduced motion shows and hides the sheet without travel.

Accessibility

  • A Base UI Drawer: a dialog named by the title, focus moved inside and trapped, Escape closes and focus returns to the trigger.
  • Every action is a native button. Up and Down (and Home, End) move between them like a menu; Tab still works.
  • The busy row is aria-busy while its work runs; a failure is announced with role alert.