Skip to content

A floating button that fans out labeled actions, nearest first, on a spring.

Actionsmotion@base-ui/react

01Preview

Documents

3 items

  • Q3 planningEdited 2h ago
  • Brand assets24 items
  • q3-forecast.xlsx1.8 MB

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 motion @base-ui/react

03Usage

import { SpeedDial, SpeedDialAction } from "@/components/ui/speed-dial";
import { File, Folder, Upload } from "@/lib/icons";

<SpeedDial label="Create">
  <SpeedDialAction icon={<Upload />} onClick={openUpload}>Upload a file</SpeedDialAction>
  <SpeedDialAction icon={<Folder />} onClick={createFolder}>New folder</SpeedDialAction>
  <SpeedDialAction icon={<File />} onClick={createDoc}>New document</SpeedDialAction>
</SpeedDial>

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import { createContext, useContext } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

const ReduceContext = createContext(false);

export type SpeedDialProps = {
  /** What the button does, e.g. "Create". Its accessible name, and a hint beside it on hover. */
  label: string;
  /** The actions, as SpeedDialAction elements, listed top to bottom. */
  children: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /**
   * Where the button sits. `fixed` pins it to the viewport's bottom-right corner above the
   * safe area; `absolute` to the nearest positioned ancestor's; `inline` leaves it in the flow.
   */
  placement?: "fixed" | "absolute" | "inline";
  /** Render the actions into this element instead of document.body. */
  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
  /** The glyph on the button. Rotates 45° when open, so a plus becomes a cross. */
  icon?: React.ReactNode;
  /** Dim what's behind the actions so their labels stay readable. */
  scrim?: boolean;
  className?: string;
  disabled?: boolean;
};

export function SpeedDial({
  label,
  children,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  placement = "fixed",
  container,
  icon,
  scrim = true,
  className,
  disabled,
}: SpeedDialProps) {
  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });
  const reduce = !!useReducedMotion();

  // Nearest action first on the way out, a quicker reverse on the way back.
  const list: Variants = {
    open: { transition: { staggerChildren: reduce ? 0 : 0.035, staggerDirection: -1 } },
    closed: { transition: { staggerChildren: reduce ? 0 : 0.02, staggerDirection: 1 } },
  };

  return (
    <ReduceContext.Provider value={reduce}>
      <Menu.Root open={open} onOpenChange={(next) => setOpen(next)} modal={false} disabled={disabled}>
        <div
          className={cn(
            "group/dial isolate",
            // Above the scrim, so the button that closes the dial is never dimmed.
            placement === "fixed" && "fixed bottom-[max(24px,env(safe-area-inset-bottom))] right-6 z-(--z-dropdown)",
            placement === "absolute" && "absolute bottom-4 right-4 z-(--z-dropdown)",
            placement === "inline" && "relative z-(--z-dropdown) inline-flex",
            className,
          )}
        >
          <Menu.Trigger
            aria-label={label}
            className={cn(
              "relative grid size-12 place-items-center rounded-full bg-fg text-frame shadow-pop outline-none",
              "transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.94] active:duration-75",
              "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
              "data-disabled:pointer-events-none data-disabled:opacity-50",
            )}
          >
            <motion.span
              aria-hidden
              className="grid place-items-center"
              initial={false}
              animate={{ rotate: open ? 45 : 0 }}
              transition={reduce ? { duration: 0 } : spring.snappy}
            >
              {icon ?? (
                <svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round">
                  <path d="M8 3.5v9M3.5 8h9" />
                </svg>
              )}
            </motion.span>
          </Menu.Trigger>
          {/* A quiet name beside the button on hover; the button already carries it as its label. */}
          <span
            aria-hidden
            className={cn(
              "pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 translate-x-1 whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] font-medium text-fg opacity-0 shadow-pop",
              "transition-[opacity,translate] duration-150 ease-out",
              !open && "group-hover/dial:translate-x-0 group-hover/dial:opacity-100 group-hover/dial:delay-300",
            )}
          >
            {label}
          </span>
        </div>

        <AnimatePresence>
          {open && (
            <Menu.Portal keepMounted container={container}>
              {scrim && (
                <Menu.Backdrop
                  className={cn(container ? "absolute" : "fixed", "inset-0 z-(--z-sticky) bg-frame/80")}
                  render={<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.14 } }} transition={{ duration: 0.2, ease: ease.out }} />}
                />
              )}
              <Menu.Positioner side="top" align="end" sideOffset={12} className="z-(--z-dropdown) outline-none">
                <Menu.Popup
                  className="flex flex-col items-end gap-2.5 pr-1 outline-none"
                  render={<motion.div variants={list} initial="closed" animate="open" exit="closed" />}
                >
                  {children}
                </Menu.Popup>
              </Menu.Positioner>
            </Menu.Portal>
          )}
        </AnimatePresence>
      </Menu.Root>
    </ReduceContext.Provider>
  );
}

export type SpeedDialActionProps = {
  /** The action's name, shown in a pill beside its button. */
  children: React.ReactNode;
  icon: React.ReactNode;
  onClick?: (event: React.MouseEvent<HTMLElement>) => void;
  disabled?: boolean;
  /** Keep the dial open after this action, e.g. for toggles. */
  closeOnClick?: boolean;
  className?: string;
};

export function SpeedDialAction({ children, icon, onClick, disabled, closeOnClick = true, className }: SpeedDialActionProps) {
  const reduce = useContext(ReduceContext);
  const row: Variants = reduce
    ? { closed: { opacity: 0, transition: { duration: 0.1 } }, open: { opacity: 1, transition: { duration: 0.15 } } }
    : {
        closed: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.12, ease: ease.in } },
        open: { opacity: 1, y: 0, scale: 1, transition: spring.snappy },
      };
  // The label trails its button slightly, sliding in from the button's side.
  const pill: Variants = reduce
    ? { closed: {}, open: {} }
    : { closed: { opacity: 0, x: 8, transition: { duration: 0.1 } }, open: { opacity: 1, x: 0, transition: { ...spring.snappy, delay: 0.04 } } };

  return (
    <Menu.Item
      disabled={disabled}
      closeOnClick={closeOnClick}
      onClick={onClick}
      render={<motion.div variants={row} style={{ originX: 1, originY: 1 }} />}
      className={cn(
        "group/action flex cursor-default select-none items-center gap-3 outline-none",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
        className,
      )}
    >
      <motion.span
        variants={pill}
        className={cn(
          "whitespace-nowrap rounded-lg border border-line-2 bg-raised px-2.5 py-1.5 text-[12.5px] font-medium leading-none text-fg-2 shadow-pop",
          "transition-[color,border-color] duration-150",
          "group-data-highlighted/action:border-fg-4 group-data-highlighted/action:text-fg",
        )}
      >
        {children}
      </motion.span>
      <span
        className={cn(
          "grid size-10 shrink-0 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-pop",
          "transition-[background-color,color,border-color,scale] duration-150 ease-out group-active/action:scale-[0.92]",
          "group-data-highlighted/action:border-fg-4 group-data-highlighted/action:bg-hover group-data-highlighted/action:text-fg",
        )}
      >
        {icon}
      </span>
    </Menu.Item>
  );
}

05Props

SpeedDial

PropTypeDefaultDescription
label*stringWhat the button does. Its accessible name, and a hint beside it on hover.
children*React.ReactNodeSpeedDialAction elements, top to bottom. The last one sits nearest the button. Three to five reads best.
placement"fixed" | "absolute" | "inline""fixed"Pinned to the viewport's bottom-right above the safe area, to the nearest positioned ancestor, or left in the flow.
containerHTMLElement | RefObject<HTMLElement>Portal the actions and scrim into this element instead of the body.
scrimbooleantrueDims what's behind the actions so their labels stay readable.
iconReact.ReactNodeThe button's glyph. Defaults to a plus, which rotates 45° into a cross when open.
openbooleanControlled open state.
defaultOpenbooleanfalseUncontrolled starting state.
onOpenChange(open: boolean) => voidFires when the dial opens or closes.
disabledbooleanfalseDims the button and ignores presses.

SpeedDialAction

PropTypeDefaultDescription
children*React.ReactNodeThe action's name, shown in a pill and used for typeahead.
icon*React.ReactNodeDrawn in the 40px round button.
onClick(event) => voidRuns the action. The dial closes after it unless closeOnClick is false.
closeOnClickbooleantrueKeep the dial open after this action.
disabledbooleanfalseDims the action and skips it in keyboard navigation.

06Notes

Behavior

  • Closes on Escape, on a press outside (including the scrim), and after an action runs; focus returns to the button every time.
  • The actions are anchored above the button's right edge with collision handling, and their round buttons are centered on the main button's axis.
  • Non-modal: the page keeps scrolling while it's open, and the scrim is only visual, so a press on it just closes the dial.
  • With placement="absolute" and a container, the whole thing lives inside a panel or preview, like the demo.

Motion

  • Actions arrive nearest first, 35ms apart, rising 10px from 0.8 scale on the snappy spring (520/38), with each label trailing its button by 40ms from 8px to the side.
  • Closing runs the reverse at 20ms apart with a 120ms ease-in fade; the plus rotates to a cross on the same spring, and the scrim fades in 200ms and out in 140ms.
  • Reduced motion keeps short opacity fades for the actions and scrim, with no stagger, travel, scale or rotation.

Accessibility

  • A Base UI Menu: the button has aria-haspopup and aria-expanded, and the actions are menuitems with roving focus.
  • Enter, Space or ArrowDown opens it and focuses the first action; arrows move, Home and End jump, typing a letter jumps to a matching action, and Escape closes.
  • Each action is named by its visible label; the icons are decorative.