component — overlays
Quick Actions
Everything you can do from here, in one place: a trigger — a header Button or a floating round button in the bottom-right corner — opens a panel listing this screen's actions, grouped, and picking one runs it and closes the panel. The panel is a Drawer that comes up from the bottom on a narrow viewport and in from the right on a wide one, and the list is a react-aria Menu built from the library's menu items, with icons, descriptions, shortcut hints, danger intent and link actions. No search field: that is what tells it apart from Command Menu.
- overlay
- actions
- menu
- fab
- floating
- sheet
- drawer
- mobile
- touch
use a quick actions when
- The handful of things that matter on this screen, collected behind one button so the page does not need a toolbar of them.
- A mobile layout whose header has no room for actions —
QuickActionsFabsits in the thumb's corner. - Pointer and touch users who want to see what is possible rather than type what they want.
don't
- A list long enough to need searching. That is a
CommandMenu, which is keyboard-first search over many commands. - A global keyboard shortcut to open it. That job belongs to
CommandMenu. - Two or three actions that fit in the header. Show them as
Buttons — a hidden action is one more tap. - Actions on one row or one object on the page. That is a
Menuanchored to it, or aContextMenu. - A speed-dial of mini buttons fanning out of the FAB. It does not scale past about four actions and is hard to make accessible; the panel covers that case.
05 examples
Header trigger
QuickActionsTrigger is a plain Button for a header or Navbar. The panel comes up from the bottom on a narrow viewport and in from the right on a wide one.
Nothing picked yet.
Floating button on a phone
QuickActionsFab is a round button fixed to the bottom-right corner, clear of the safe-area inset and below the overlay layer. It is what a mobile layout reaches for when the header has no room.
Inbox
Nothing picked yet.
Grouped sections
QuickActionsSection gives a group its heading; `title` names the panel. Items take a description, a shortcut hint, and `isDisabled`.
Nothing picked yet.
A destructive action asks first
`intent="danger"` marks it; `await confirm(...)` from useConfirm() asks the question once the panel has closed. Never window.confirm().
Nothing deleted yet.
A link action
An item with `href` is a link: it navigates, and closes the panel like any other action.
what to use instead
by jobBy what the quick actions was doing.
- Finding a command by name
- Actions on one thing
- Menu — Anchored to a button beside the thing.
- ContextMenu — On right-click or long-press.
- A few actions that fit
source
quick-actions.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { Zap } from "lucide-react"
import { use, useSyncExternalStore } from "react"
import type { DialogTriggerProps, MenuProps } from "react-aria-components"
import {
composeRenderProps,
DialogTrigger,
Menu as MenuPrimitive,
OverlayTriggerStateContext,
} from "react-aria-components"
import { Button, type ButtonProps } from "@/components/button"
import { DrawerContent, DrawerHeader, DrawerTitle } from "@/components/drawer"
import {
MenuDescription,
MenuItem,
type MenuItemProps,
MenuLabel,
MenuSection,
type MenuSectionProps,
MenuSeparator,
MenuShortcut,
menuContentStyles,
} from "@/components/menu"
import { cn } from "@/lib/utils"
/**
* Quick Actions — quebi design system
*
* "Everything you can do from here", in one place: a trigger opens a panel
* listing the actions on this screen, grouped, and picking one runs it and
* closes the panel.
*
* The question that chooses between this and `CommandMenu` is whether the
* reader knows what they want. A command menu is keyboard-first search over
* many commands; Quick Actions is a pointer- and touch-first list of the
* handful that matter here — so it has no search field, and a list that needs
* one is a `CommandMenu`.
*
* Nothing in it is a new overlay. The panel is a `DrawerContent` — a bottom
* panel a thumb should be able to throw away is a Drawer, and that is what the
* narrow-viewport side is — and the list is a react-aria `Menu` built from the
* library's `MenuItem`/`MenuSection`/`MenuSeparator`, so `role="menu"`, arrow
* keys, typeahead and `onAction` come from the same code as every other menu.
*
* Two triggers, both optional and both usable at once: `QuickActionsTrigger` is
* a plain Button for a header, and `QuickActionsFab` is a round button fixed to
* the bottom-right corner, clear of the safe-area inset and one z-layer below
* the overlays (`z-40` under their `z-50`), for a layout whose header has no
* room. Focus returns to whichever of them opened the panel.
*/
const QuickActions = (props: DialogTriggerProps) => <DialogTrigger {...props} />
const WIDE_QUERY = "(min-width: 768px)"
const subscribeToWidth = (onChange: () => void) => {
const mql = window.matchMedia(WIDE_QUERY)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}
/**
* Bottom on a phone, right on a desktop. The server snapshot is "bottom", and
* that is safe rather than a guess: the panel renders only while open, so the
* value never reaches prerendered HTML, and by the time anyone opens it the
* client snapshot has taken over.
*/
const useAutoSide = (): "bottom" | "right" =>
useSyncExternalStore(
subscribeToWidth,
() => (window.matchMedia(WIDE_QUERY).matches ? "right" : "bottom"),
() => "bottom",
)
/**
* The header trigger. The library's Button, `outline` by default so it sits
* beside a navbar's links rather than competing with the page's primary action.
*/
const QuickActionsTrigger = ({ intent = "outline", ...props }: ButtonProps) => (
<Button intent={intent} {...props} />
)
interface QuickActionsFabProps extends ButtonProps {
/** The FAB is icon-only, so it needs a name. Defaults to "Actions". */
"aria-label"?: string
}
/**
* The floating trigger. `fixed` to the viewport's bottom-right, with its
* offset added to `env(safe-area-inset-*)` so it clears a phone's home
* indicator and rounded corners; the inset is 0 wherever there is none.
* Children replace the default icon.
*/
const QuickActionsFab = ({
className,
children,
"aria-label": ariaLabel = "Actions",
intent = "primary",
...props
}: QuickActionsFabProps) => (
<Button
aria-label={ariaLabel}
intent={intent}
size="sq-lg"
isCircle
className={composeRenderProps(className, (resolved) =>
cn(
"fixed right-[calc(--spacing(4)+env(safe-area-inset-right))] bottom-[calc(--spacing(4)+env(safe-area-inset-bottom))] z-40",
resolved,
),
)}
{...props}
>
{children ?? <Zap data-slot="icon" strokeWidth={1.5} aria-hidden />}
</Button>
)
interface QuickActionsContentProps<T>
extends Pick<
MenuProps<T>,
"children" | "items" | "onAction" | "disabledKeys" | "dependencies"
> {
/** The panel's heading and the accessible name of both the dialog and the menu. */
title?: string
/** Pin the side. Unset, the panel comes up from the bottom below 768px and in from the right above it. */
side?: "bottom" | "right"
/** Controlled open state, for a panel with no `QuickActions` around it. */
isOpen?: boolean
onOpenChange?: (isOpen: boolean) => void
className?: string
}
const QuickActionsContent = <T extends object>({
title = "Actions",
side,
isOpen,
onOpenChange,
className,
...props
}: QuickActionsContentProps<T>) => {
const autoSide = useAutoSide()
const resolvedSide = side ?? autoSide
return (
<DrawerContent side={resolvedSide} isOpen={isOpen} onOpenChange={onOpenChange}>
<DrawerHeader className="pb-2">
<DrawerTitle>{title}</DrawerTitle>
</DrawerHeader>
<QuickActionsList aria-label={title} className={className} {...props} />
</DrawerContent>
)
}
/**
* The Menu, split out so it can read the overlay's state: `Modal` provides
* `OverlayTriggerStateContext` inside the panel, and a Menu closes through
* `onClose` after any item's action — the Menu-level `onAction`, an item's own
* `onAction`, or an `href` — so every way of picking one closes the panel.
*/
const QuickActionsList = <T extends object>({
className,
...props
}: Omit<MenuProps<T>, "className"> & { className?: string }) => {
const state = use(OverlayTriggerStateContext)
return (
<MenuPrimitive
autoFocus="first"
onClose={() => state?.close()}
className={menuContentStyles({
className: cn(
"min-h-0 flex-1 overscroll-contain px-2 pb-[calc(--spacing(4)+env(safe-area-inset-bottom))] [clip-path:none]",
className,
),
})}
{...props}
/>
)
}
interface QuickActionsItemProps extends Omit<MenuItemProps, "children"> {
/** The action's name. A string also becomes the item's typeahead text. */
children: React.ReactNode
/** A component, not an element: `icon={Trash2}`. It is decorative — the label names the action. */
icon?: React.ElementType
description?: React.ReactNode
/** A shortcut hint, e.g. "⌘N". Shown from `lg` up, like every `Keyboard`. */
shortcut?: string
}
/**
* One action. `intent="danger"` for a destructive one, `href` for one that
* navigates, `isDisabled` for one that is visible but not available.
*/
const QuickActionsItem = ({
children,
icon: Icon,
description,
shortcut,
textValue,
className,
...props
}: QuickActionsItemProps) => (
<MenuItem
textValue={textValue ?? (typeof children === "string" ? children : undefined)}
className={composeRenderProps(className, (resolved) => cn("sm:py-2", resolved))}
{...props}
>
{Icon && <Icon data-slot="icon" aria-hidden />}
<MenuLabel>{children}</MenuLabel>
{description && <MenuDescription>{description}</MenuDescription>}
{shortcut && <MenuShortcut>{shortcut}</MenuShortcut>}
</MenuItem>
)
/** A group of actions under a heading. `label` is the heading. */
const QuickActionsSection = <T extends object>(props: MenuSectionProps<T>) => (
<MenuSection {...props} />
)
const QuickActionsSeparator = MenuSeparator
export type { QuickActionsContentProps, QuickActionsFabProps, QuickActionsItemProps }
export {
QuickActions,
QuickActionsContent,
QuickActionsFab,
QuickActionsItem,
QuickActionsSection,
QuickActionsSeparator,
QuickActionsTrigger,
}