Aura Design System

Fab Menu

A floating button that opens a stack of related actions.

Preview

Loading...
import { ImageIcon, Link2Icon } from "@radix-ui/react-icons";
import {
  FabMenu,
  FabMenuItem,
  FabMenuList,
  FabMenuTrigger,
} from "@/components/ui/FabMenu";

export function FabMenuDemo() {
  return (
    <FabMenu defaultOpen>
      <div className="relative h-24 w-full rounded-sm border border-gray-6 bg-gray-2">
        <FabMenuList position="absolute">
          <FabMenuItem label="Image">
            <ImageIcon className="icon" />
          </FabMenuItem>
          <FabMenuItem label="Link">
            <Link2Icon className="icon" />
          </FabMenuItem>
        </FabMenuList>
        <FabMenuTrigger position="absolute" />
      </div>
    </FabMenu>
  );
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/fab-menu

Manual

Install the following dependencies:

pnpm install @radix-ui/react-icons

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the fab component into your components/ui/Fab.tsx file.

"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";

const fabVariants = cva(
  "inline-flex items-center justify-center gap-0.5 rounded-full bg-accent-9 text-accent-contrast shadow-md transition hover:bg-accent-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8 motion-reduce:transition-none",
  {
    variants: {
      size: {
        default: "size-4",
        sm: "size-3",
        lg: "h-5 min-w-5 px-1.5",
      },
      position: {
        fixed: "fixed end-2 bottom-2 z-40",
        absolute: "absolute end-2 bottom-2 z-10",
        inline: "relative",
      },
    },
    defaultVariants: {
      size: "default",
      position: "fixed",
    },
  },
);

function Fab({
  className,
  size,
  position,
  label,
  children,
  ...props
}: React.ComponentProps<"button"> &
  VariantProps<typeof fabVariants> & { label?: string }) {
  const extended = Boolean(label);
  return (
    <button
      type="button"
      data-slot="fab"
      data-extended={extended ? "" : undefined}
      className={cn(
        fabVariants({
          size: extended ? "lg" : size,
          position,
        }),
        className,
      )}
      {...props}
    >
      {children}
      {label ? <span>{label}</span> : null}
    </button>
  );
}

export { Fab, fabVariants };

Copy and paste the FabMenu component into your components/ui/FabMenu.tsx file.

"use client";

import * as React from "react";
import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons";
import { cn } from "@/utils/class-names";
import { Fab } from "@/components/ui/Fab";

type FabMenuContextValue = {
  open: boolean;
  setOpen: (open: boolean) => void;
};

const FabMenuContext = React.createContext<FabMenuContextValue | null>(null);

function useFabMenu() {
  const context = React.useContext(FabMenuContext);
  if (!context) {
    throw new Error("FabMenu parts must be used within FabMenu.");
  }
  return context;
}

function FabMenu({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  children,
}: {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
}) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultOpen);
  const open = openProp ?? uncontrolled;
  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [onOpenChange, openProp],
  );

  return (
    <FabMenuContext.Provider value={{ open, setOpen }}>
      <div data-slot="fab-menu" className="contents">
        {children}
      </div>
    </FabMenuContext.Provider>
  );
}

function FabMenuList({
  className,
  position = "fixed",
  ...props
}: React.ComponentProps<"div"> & { position?: "fixed" | "absolute" }) {
  const { open } = useFabMenu();
  if (!open) return null;
  return (
    <div
      data-slot="fab-menu-list"
      className={cn(
        "z-40 flex flex-col items-end gap-1",
        position === "fixed" ? "fixed end-2 bottom-7" : "absolute end-2 bottom-7",
        className,
      )}
      {...props}
    />
  );
}

function FabMenuItem({
  className,
  label,
  children,
  onClick,
  ...props
}: React.ComponentProps<"button"> & { label: string }) {
  const { setOpen } = useFabMenu();
  return (
    <button
      type="button"
      data-slot="fab-menu-item"
      className={cn(
        "inline-flex items-center gap-0.5 rounded-full bg-gray-2 px-1.5 py-0.5 text-gray-12 shadow-md hover:bg-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8",
        className,
      )}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) setOpen(false);
      }}
      {...props}
    >
      {children}
      <span>{label}</span>
    </button>
  );
}

function FabMenuTrigger({
  className,
  label = "Open actions",
  closeLabel = "Close actions",
  position = "fixed",
  onClick,
  ...props
}: React.ComponentProps<typeof Fab> & {
  label?: string;
  closeLabel?: string;
}) {
  const { open, setOpen } = useFabMenu();
  return (
    <Fab
      data-slot="fab-menu-trigger"
      position={position}
      aria-expanded={open}
      aria-label={open ? closeLabel : label}
      className={cn(
        "motion-safe:transition-transform motion-reduce:transition-none",
        className,
      )}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) setOpen(!open);
      }}
      {...props}
    >
      {open ? (
        <Cross2Icon className="icon" />
      ) : (
        props.children ?? <PlusIcon className="icon" />
      )}
    </Fab>
  );
}

const SpeedDial = FabMenu;
const SpeedDialList = FabMenuList;
const SpeedDialItem = FabMenuItem;
const SpeedDialTrigger = FabMenuTrigger;

export {
  FabMenu,
  FabMenuList,
  FabMenuItem,
  FabMenuTrigger,
  SpeedDial,
  SpeedDialList,
  SpeedDialItem,
  SpeedDialTrigger,
};