Rises from the bottom, swipes away, and holds for slow actions.
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 motion03Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. Use with onOpenChange. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | — | Called when the sheet opens or closes, by any route: action, Cancel, swipe, backdrop or Escape. |
| container | HTMLElement | null | — | Render inside this element instead of over the page. It needs position: relative and overflow: hidden. Page scroll stays unlocked. |
| modal | boolean | "trap-focus" | true, or "trap-focus" with a container | Whether page scroll locks and outside interaction is blocked while open. |
ActionSheetTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| render | ReactElement | (props, state) => ReactElement | — | Put the behavior on your own button. Unstyled otherwise; the open trigger carries data-popup-open. |
ActionSheetContent
| Prop | Type | Default | Description |
|---|---|---|---|
| title | ReactNode | — | Names what the actions apply to. Becomes the sheet's accessible name. |
| description | ReactNode | — | One more line of context under the title. |
| label | string | "Actions" | Accessible name when there is no visible title. |
| cancelLabel | string | "Cancel" | Label of the separate dismiss button. |
ActionSheetItem
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| errorLabel | string | "Couldn’t finish. Try again." | Shown on the row when the promise rejects without a message of its own. |
| disabled | boolean | false | Dimmed 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.