Dice UI
Components

Action Bar

A floating action bar that appears at the bottom or top of the viewport to display contextual actions for selected items.

API
"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-slot

Copy 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 AttributeValue
[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

On this page