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-menuManual
Install the following dependencies:
pnpm install @radix-ui/react-iconsCopy 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,
};