Action Bar
A floating action bar that appears at the bottom or top of the viewport to display contextual actions for selected items.
"use client";
import { Copy, Trash2, X } from "lucide-react";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
ActionBar,
ActionBarClose,
ActionBarItem,
ActionBarSelection,
ActionBarSeparator,
} from "@/components/ui/action-bar";
interface Task {
id: string;
name: string;
}
export function ActionBarDemo() {
const [tasks, setTasks] = React.useState<Task[]>([
{ id: crypto.randomUUID(), name: "Weekly Status Report" },
{ id: crypto.randomUUID(), name: "Client Invoice Review" },
{ id: crypto.randomUUID(), name: "Product Roadmap" },
{ id: crypto.randomUUID(), name: "Team Standup Notes" },
]);
const [selectedTaskIds, setSelectedTaskIds] = React.useState<Set<string>>(
new Set(),
);
const open = selectedTaskIds.size > 0;
const onOpenChange = React.useCallback((open: boolean) => {
if (!open) {
setSelectedTaskIds(new Set());
}
}, []);
const onItemSelect = React.useCallback(
(id: string, checked: boolean) => {
const newSelected = new Set(selectedTaskIds);
if (checked) {
newSelected.add(id);
} else {
newSelected.delete(id);
}
setSelectedTaskIds(newSelected);
},
[selectedTaskIds],
);
const onDuplicate = React.useCallback(() => {
const selectedItems = tasks.filter((task) => selectedTaskIds.has(task.id));
const duplicates = selectedItems.map((task) => ({
...task,
id: crypto.randomUUID(),
name: `${task.name} (copy)`,
}));
setTasks([...tasks, ...duplicates]);
setSelectedTaskIds(new Set());
}, [tasks, selectedTaskIds]);
const onDelete = React.useCallback(() => {
setTasks(tasks.filter((task) => !selectedTaskIds.has(task.id)));
setSelectedTaskIds(new Set());
}, [tasks, selectedTaskIds]);
return (
<div className="flex w-full flex-col gap-2.5">
<h3 className="font-semibold text-lg">Tasks</h3>
<div className="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
{tasks.map((task) => (
<Label
key={task.id}
className={cn(
"flex cursor-pointer items-center gap-2.5 rounded-md border bg-card/70 px-3 py-2.5 transition-colors hover:bg-accent/70",
selectedTaskIds.has(task.id) && "bg-accent/70",
)}
>
<Checkbox
checked={selectedTaskIds.has(task.id)}
onCheckedChange={(checked) =>
onItemSelect(task.id, checked === true)
}
/>
<span className="truncate font-medium text-sm">{task.name}</span>
</Label>
))}
</div>
<ActionBar open={open} onOpenChange={onOpenChange}>
<ActionBarSelection>
{selectedTaskIds.size} selected
<ActionBarSeparator />
<ActionBarClose>
<X />
</ActionBarClose>
</ActionBarSelection>
<ActionBarSeparator />
<ActionBarItem onSelect={onDuplicate}>
<Copy />
Duplicate
</ActionBarItem>
<ActionBarItem variant="destructive" onSelect={onDelete}>
<Trash2 />
Delete
</ActionBarItem>
</ActionBar>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://diceui.com/r/action-bar"Manual
Install the following dependencies:
npm install @radix-ui/react-slotCopy and paste the portal component into your components/portal.tsx file.
"use client";
import { Slot, type SlotProps } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
interface PortalProps extends SlotProps {
container?: Element | DocumentFragment | null;
}
function Portal(props: PortalProps) {
const { container: containerProp, ...portalProps } = props;
const [mounted, setMounted] = React.useState(false);
React.useLayoutEffect(() => setMounted(true), []);
const container =
containerProp ?? (mounted ? globalThis.document?.body : null);
if (!container) return null;
return ReactDOM.createPortal(<Slot {...portalProps} />, container);
}
export { Portal };
export type { PortalProps };Copy and paste the refs composition utilities into your lib/compose-refs.ts file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Button } from "@/components/ui/button";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
const ROOT_NAME = "ActionBar";
const ITEM_NAME = "ActionBarItem";
const CLOSE_NAME = "ActionBarClose";
const ITEM_SELECT = "actionbar.itemSelect";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type RootElement = React.ComponentRef<typeof ActionBarRoot>;
type ItemElement = React.ComponentRef<typeof ActionBarItem>;
type CloseElement = React.ComponentRef<typeof ActionBarClose>;
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
interface ActionBarContextValue {
onOpenChange?: (open: boolean) => void;
}
const ActionBarContext = React.createContext<ActionBarContextValue | null>(
null,
);
function useActionBarContext(consumerName: string) {
const context = React.useContext(ActionBarContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface ActionBarRootProps extends DivProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onEscapeKeyDown?: (event: KeyboardEvent) => void;
align?: "start" | "center" | "end";
alignOffset?: number;
side?: "top" | "bottom";
sideOffset?: number;
portalContainer?: Element | DocumentFragment | null;
}
function ActionBarRoot(props: ActionBarRootProps) {
const {
open = false,
onOpenChange,
onEscapeKeyDown,
side = "bottom",
alignOffset = 0,
align = "center",
sideOffset = 16,
portalContainer: portalContainerProp,
className,
style,
ref,
asChild,
...rootProps
} = props;
const [mounted, setMounted] = React.useState(false);
const rootRef = React.useRef<RootElement>(null);
const composedRef = useComposedRefs(ref, rootRef);
const propsRef = useAsRef({
onEscapeKeyDown,
onOpenChange,
});
React.useLayoutEffect(() => {
setMounted(true);
}, []);
React.useEffect(() => {
if (!open) return;
const ownerDocument = rootRef.current?.ownerDocument ?? document;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
propsRef.current.onEscapeKeyDown?.(event);
if (!event.defaultPrevented) {
propsRef.current.onOpenChange?.(false);
}
}
}
ownerDocument.addEventListener("keydown", onKeyDown);
return () => ownerDocument.removeEventListener("keydown", onKeyDown);
}, [open, propsRef]);
const contextValue = React.useMemo<ActionBarContextValue>(
() => ({
onOpenChange,
}),
[onOpenChange],
);
const portalContainer =
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
if (!portalContainer || !open) return null;
const RootPrimitive = asChild ? Slot : "div";
return (
<ActionBarContext.Provider value={contextValue}>
{ReactDOM.createPortal(
<RootPrimitive
data-slot="action-bar"
data-side={side}
data-align={align}
{...rootProps}
ref={composedRef}
className={cn(
"fixed z-50 flex items-center gap-2 rounded-lg border bg-card px-2 py-1.5 shadow-lg",
"fade-in-0 zoom-in-95 animate-in duration-250 [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]",
"data-[side=bottom]:slide-in-from-bottom-4 data-[side=top]:slide-in-from-top-4",
"motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
style={{
[side]: `${sideOffset}px`,
...(align === "center" && {
left: "50%",
translate: "-50% 0",
}),
...(align === "start" && { left: `${alignOffset}px` }),
...(align === "end" && { right: `${alignOffset}px` }),
...style,
}}
/>,
portalContainer,
)}
</ActionBarContext.Provider>
);
}
function ActionBarSelection(props: DivProps) {
const { className, asChild, ...selectionProps } = props;
const SelectionPrimitive = asChild ? Slot : "div";
return (
<SelectionPrimitive
data-slot="action-bar-selection"
{...selectionProps}
className={cn(
"flex items-center gap-1 rounded-sm border px-2 py-1 font-medium text-sm",
className,
)}
/>
);
}
interface ActionBarItemProps
extends Omit<React.ComponentProps<typeof Button>, "onSelect"> {
onSelect?: (event: Event) => void;
}
function ActionBarItem(props: ActionBarItemProps) {
const { onSelect, onClick, ref, ...itemProps } = props;
const itemRef = React.useRef<ItemElement>(null);
const composedRef = useComposedRefs(ref, itemRef);
const { onOpenChange } = useActionBarContext(ITEM_NAME);
const onItemSelect = React.useCallback(() => {
const item = itemRef.current;
if (!item) return;
const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
bubbles: true,
cancelable: true,
});
item.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
once: true,
});
item.dispatchEvent(itemSelectEvent);
if (!itemSelectEvent.defaultPrevented) {
onOpenChange?.(false);
}
}, [onOpenChange, onSelect]);
const onItemClick = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (onSelect) {
onItemSelect();
}
},
[onClick, onSelect, onItemSelect],
);
return (
<Button
type="button"
data-slot="action-bar-item"
variant="secondary"
size="sm"
{...itemProps}
ref={composedRef}
onClick={onItemClick}
/>
);
}
interface ActionBarCloseProps extends React.ComponentProps<"button"> {
asChild?: boolean;
}
function ActionBarClose(props: ActionBarCloseProps) {
const { asChild, className, onClick, ...closeProps } = props;
const { onOpenChange } = useActionBarContext(CLOSE_NAME);
const onCloseClick = React.useCallback(
(event: React.MouseEvent<CloseElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
onOpenChange?.(false);
},
[onOpenChange, onClick],
);
const ClosePrimitive = asChild ? Slot : "button";
return (
<ClosePrimitive
type="button"
data-slot="action-bar-close"
{...closeProps}
className={cn(
"rounded-xs opacity-70 outline-none hover:opacity-100 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-3.5 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
onClick={onCloseClick}
/>
);
}
function ActionBarSeparator(props: DivProps) {
const { asChild, className, ...separatorProps } = props;
const SeparatorPrimitive = asChild ? Slot : "div";
return (
<SeparatorPrimitive
role="separator"
aria-orientation="vertical"
data-slot="action-bar-separator"
{...separatorProps}
className={cn(
"in-data-[slot=action-bar-selection]:ml-0.5 h-6 in-data-[slot=action-bar-selection]:h-4 w-px bg-border",
className,
)}
/>
);
}
export {
ActionBarRoot as Root,
ActionBarSelection as Selection,
ActionBarItem as Item,
ActionBarClose as Close,
ActionBarSeparator as Separator,
//
ActionBarRoot as ActionBar,
ActionBarSelection,
ActionBarItem,
ActionBarClose,
ActionBarSeparator,
};Update the import paths to match your project setup.
Layout
import * as ActionBar from "@/components/ui/action-bar";
<ActionBar.Root open={open} onOpenChange={setOpen}>
<ActionBar.Selection>
<ActionBar.Close />
</ActionBar.Selection>
<ActionBar.Separator />
<ActionBar.Item />
</ActionBar.Root>Examples
Position
Use the side and align props to control where the action bar appears.
"use client";
import { Archive, Star, X } from "lucide-react";
import * as React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
ActionBar,
ActionBarClose,
ActionBarItem,
ActionBarSelection,
ActionBarSeparator,
} from "@/components/ui/action-bar";
export function ActionBarPositionDemo() {
const [open, setOpen] = React.useState(false);
const [side, setSide] = React.useState<"top" | "bottom">("bottom");
const [align, setAlign] = React.useState<"start" | "center" | "end">(
"center",
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Switch id="open" checked={open} onCheckedChange={setOpen} />
<Label htmlFor="open">Show Action Bar</Label>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="side" className="w-14">
Side
</Label>
<Select
value={side}
onValueChange={(value) => setSide(value as "top" | "bottom")}
>
<SelectTrigger id="side" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="align" className="w-14">
Align
</Label>
<Select
value={align}
onValueChange={(value) =>
setAlign(value as "start" | "center" | "end")
}
>
<SelectTrigger id="align" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start">Start</SelectItem>
<SelectItem value="center">Center</SelectItem>
<SelectItem value="end">End</SelectItem>
</SelectContent>
</Select>
</div>
<ActionBar open={open} onOpenChange={setOpen} side={side} align={align}>
<ActionBarSelection>
3 selected
<ActionBarSeparator />
<ActionBarClose>
<X />
</ActionBarClose>
</ActionBarSelection>
<ActionBarItem>
<Star />
Favorite
</ActionBarItem>
<ActionBarSeparator />
<ActionBarItem>
<Archive />
Archive
</ActionBarItem>
</ActionBar>
</div>
);
}API Reference
Root
The root component that controls the visibility and position of the action bar.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-side] | "top" | "bottom" |
[data-align] | "start" | "center" | "end" |
Selection
Displays selection information, typically used to show how many items are selected.
Prop
Type
Item
An interactive button item within the action bar.
Prop
Type
Close
A button that closes the action bar by calling the onOpenChange callback with false.
Prop
Type
Separator
A visual separator between action bar items.
Prop
Type