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,
ActionBarGroup,
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 />
<ActionBarGroup>
<ActionBarItem onSelect={onDuplicate}>
<Copy />
Duplicate
</ActionBarItem>
<ActionBarItem variant="destructive" onSelect={onDelete}>
<Trash2 />
Delete
</ActionBarItem>
</ActionBarGroup>
</ActionBar>
</div>
);
}Installation
CLI
npx shadcn@latest add "@diceui/action-bar"Manual
Install the following dependencies:
npm install @radix-ui/react-slot @radix-ui/react-directionCopy 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 hooks into your hooks directory.
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };Copy and paste the following code into your project.
"use client";
import { useDirection } from "@radix-ui/react-direction";
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";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
const ROOT_NAME = "ActionBar";
const GROUP_NAME = "ActionBarGroup";
const ITEM_NAME = "ActionBarItem";
const CLOSE_NAME = "ActionBarClose";
const SEPARATOR_NAME = "ActionBarSeparator";
const ITEM_SELECT = "actionbar.itemSelect";
const ENTRY_FOCUS = "actionbarFocusGroup.onEntryFocus";
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
type Direction = "ltr" | "rtl";
type Orientation = "horizontal" | "vertical";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type RootElement = React.ComponentRef<typeof ActionBar>;
type ItemElement = React.ComponentRef<typeof ActionBarItem>;
type CloseElement = React.ComponentRef<typeof ActionBarClose>;
function focusFirst(
candidates: React.RefObject<HTMLElement | null>[],
preventScroll = false,
) {
const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
for (const candidateRef of candidates) {
const candidate = candidateRef.current;
if (!candidate) continue;
if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
candidate.focus({ preventScroll });
if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
}
}
function wrapArray<T>(array: T[], startIndex: number) {
return array.map<T>(
(_, index) => array[(startIndex + index) % array.length] as T,
);
}
function getDirectionAwareKey(key: string, dir?: Direction) {
if (dir !== "rtl") return key;
return key === "ArrowLeft"
? "ArrowRight"
: key === "ArrowRight"
? "ArrowLeft"
: key;
}
interface ItemData {
id: string;
ref: React.RefObject<ItemElement | null>;
disabled: boolean;
}
interface ActionBarContextValue {
onOpenChange?: (open: boolean) => void;
dir: Direction;
orientation: Orientation;
loop: boolean;
}
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 FocusContextValue {
tabStopId: string | null;
onItemFocus: (tabStopId: string) => void;
onItemShiftTab: () => void;
onFocusableItemAdd: () => void;
onFocusableItemRemove: () => void;
onItemRegister: (item: ItemData) => void;
onItemUnregister: (id: string) => void;
getItems: () => ItemData[];
}
const FocusContext = React.createContext<FocusContextValue | null>(null);
function useFocusContext(consumerName: string) {
const context = React.useContext(FocusContext);
if (!context) {
throw new Error(
`\`${consumerName}\` must be used within \`FocusProvider\``,
);
}
return context;
}
interface ActionBarProps 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;
dir?: Direction;
orientation?: Orientation;
loop?: boolean;
}
function ActionBar(props: ActionBarProps) {
const {
open = false,
onOpenChange,
onEscapeKeyDown,
side = "bottom",
alignOffset = 0,
align = "center",
sideOffset = 16,
portalContainer: portalContainerProp,
dir: dirProp,
orientation = "horizontal",
loop = true,
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,
});
const dir = useDirection(dirProp);
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,
dir,
orientation,
loop,
}),
[onOpenChange, dir, orientation, loop],
);
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
role="toolbar"
aria-orientation={orientation}
data-slot="action-bar"
data-side={side}
data-align={align}
data-orientation={orientation}
dir={dir}
{...rootProps}
ref={composedRef}
className={cn(
"fixed z-50 rounded-lg border bg-card shadow-lg outline-none",
"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",
orientation === "horizontal"
? "flex flex-row items-center gap-2 px-2 py-1.5"
: "flex flex-col items-start gap-2 px-1.5 py-2",
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 tabular-nums",
className,
)}
/>
);
}
function ActionBarGroup(props: DivProps) {
const {
onBlur: onBlurProp,
onFocus: onFocusProp,
onMouseDown: onMouseDownProp,
className,
asChild,
ref,
...groupProps
} = props;
const [tabStopId, setTabStopId] = React.useState<string | null>(null);
const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false);
const [focusableItemCount, setFocusableItemCount] = React.useState(0);
const groupRef = React.useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, groupRef);
const isClickFocusRef = React.useRef(false);
const itemsRef = React.useRef<Map<string, ItemData>>(new Map());
const { dir, orientation } = useActionBarContext(GROUP_NAME);
const onItemFocus = React.useCallback((tabStopId: string) => {
setTabStopId(tabStopId);
}, []);
const onItemShiftTab = React.useCallback(() => {
setIsTabbingBackOut(true);
}, []);
const onFocusableItemAdd = React.useCallback(() => {
setFocusableItemCount((prevCount) => prevCount + 1);
}, []);
const onFocusableItemRemove = React.useCallback(() => {
setFocusableItemCount((prevCount) => prevCount - 1);
}, []);
const onItemRegister = React.useCallback((item: ItemData) => {
itemsRef.current.set(item.id, item);
}, []);
const onItemUnregister = React.useCallback((id: string) => {
itemsRef.current.delete(id);
}, []);
const getItems = React.useCallback(() => {
return Array.from(itemsRef.current.values())
.filter((item) => item.ref.current)
.sort((a, b) => {
const elementA = a.ref.current;
const elementB = b.ref.current;
if (!elementA || !elementB) return 0;
const position = elementA.compareDocumentPosition(elementB);
if (position & Node.DOCUMENT_POSITION_FOLLOWING) {
return -1;
}
if (position & Node.DOCUMENT_POSITION_PRECEDING) {
return 1;
}
return 0;
});
}, []);
const onBlur = React.useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
onBlurProp?.(event);
if (event.defaultPrevented) return;
setIsTabbingBackOut(false);
},
[onBlurProp],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
onFocusProp?.(event);
if (event.defaultPrevented) return;
const isKeyboardFocus = !isClickFocusRef.current;
if (
event.target === event.currentTarget &&
isKeyboardFocus &&
!isTabbingBackOut
) {
const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS);
event.currentTarget.dispatchEvent(entryFocusEvent);
if (!entryFocusEvent.defaultPrevented) {
const items = Array.from(itemsRef.current.values()).filter(
(item) => !item.disabled,
);
const currentItem = items.find((item) => item.id === tabStopId);
const candidateItems = [currentItem, ...items].filter(
Boolean,
) as ItemData[];
const candidateRefs = candidateItems.map((item) => item.ref);
focusFirst(candidateRefs, false);
}
}
isClickFocusRef.current = false;
},
[onFocusProp, isTabbingBackOut, tabStopId],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
onMouseDownProp?.(event);
if (event.defaultPrevented) return;
isClickFocusRef.current = true;
},
[onMouseDownProp],
);
const focusContextValue = React.useMemo<FocusContextValue>(
() => ({
tabStopId,
onItemFocus,
onItemShiftTab,
onFocusableItemAdd,
onFocusableItemRemove,
onItemRegister,
onItemUnregister,
getItems,
}),
[
tabStopId,
onItemFocus,
onItemShiftTab,
onFocusableItemAdd,
onFocusableItemRemove,
onItemRegister,
onItemUnregister,
getItems,
],
);
const GroupPrimitive = asChild ? Slot : "div";
return (
<FocusContext.Provider value={focusContextValue}>
<GroupPrimitive
role="group"
data-slot="action-bar-group"
data-orientation={orientation}
dir={dir}
tabIndex={isTabbingBackOut || focusableItemCount === 0 ? -1 : 0}
{...groupProps}
ref={composedRef}
className={cn(
"flex gap-2 outline-none",
orientation === "horizontal"
? "items-center"
: "w-full flex-col items-start",
className,
)}
onBlur={onBlur}
onFocus={onFocus}
onMouseDown={onMouseDown}
/>
</FocusContext.Provider>
);
}
interface ActionBarItemProps
extends Omit<React.ComponentProps<typeof Button>, "onSelect"> {
onSelect?: (event: Event) => void;
}
function ActionBarItem(props: ActionBarItemProps) {
const {
onSelect,
onClick: onClickProp,
onFocus: onFocusProp,
onKeyDown: onKeyDownProp,
onMouseDown: onMouseDownProp,
className,
disabled,
ref,
...itemProps
} = props;
const itemRef = React.useRef<ItemElement>(null);
const composedRef = useComposedRefs(ref, itemRef);
const isMouseClickRef = React.useRef(false);
const { onOpenChange, dir, orientation, loop } =
useActionBarContext(ITEM_NAME);
const focusContext = useFocusContext(ITEM_NAME);
const itemId = React.useId();
const isTabStop = focusContext.tabStopId === itemId;
useIsomorphicLayoutEffect(() => {
focusContext.onItemRegister({
id: itemId,
ref: itemRef,
disabled: !!disabled,
});
if (!disabled) {
focusContext.onFocusableItemAdd();
}
return () => {
focusContext.onItemUnregister(itemId);
if (!disabled) {
focusContext.onFocusableItemRemove();
}
};
}, [focusContext, itemId, disabled]);
const onClick = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
onClickProp?.(event);
if (event.defaultPrevented) return;
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);
}
},
[onClickProp, onOpenChange, onSelect],
);
const onFocus = React.useCallback(
(event: React.FocusEvent<ItemElement>) => {
onFocusProp?.(event);
if (event.defaultPrevented) return;
focusContext.onItemFocus(itemId);
isMouseClickRef.current = false;
},
[onFocusProp, focusContext, itemId],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<ItemElement>) => {
onKeyDownProp?.(event);
if (event.defaultPrevented) return;
if (event.key === "Tab" && event.shiftKey) {
focusContext.onItemShiftTab();
return;
}
if (event.target !== event.currentTarget) return;
const key = getDirectionAwareKey(event.key, dir);
let focusIntent: "first" | "last" | "prev" | "next" | undefined;
if (orientation === "horizontal") {
if (key === "ArrowLeft") focusIntent = "prev";
else if (key === "ArrowRight") focusIntent = "next";
else if (key === "Home") focusIntent = "first";
else if (key === "End") focusIntent = "last";
} else {
if (key === "ArrowUp") focusIntent = "prev";
else if (key === "ArrowDown") focusIntent = "next";
else if (key === "Home") focusIntent = "first";
else if (key === "End") focusIntent = "last";
}
if (focusIntent !== undefined) {
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
return;
event.preventDefault();
const items = focusContext.getItems().filter((item) => !item.disabled);
let candidateRefs = items.map((item) => item.ref);
if (focusIntent === "last") {
candidateRefs.reverse();
} else if (focusIntent === "prev" || focusIntent === "next") {
if (focusIntent === "prev") candidateRefs.reverse();
const currentIndex = candidateRefs.findIndex(
(ref) => ref.current === event.currentTarget,
);
candidateRefs = loop
? wrapArray(candidateRefs, currentIndex + 1)
: candidateRefs.slice(currentIndex + 1);
}
queueMicrotask(() => focusFirst(candidateRefs));
}
},
[onKeyDownProp, focusContext, dir, orientation, loop],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
onMouseDownProp?.(event);
if (event.defaultPrevented) return;
isMouseClickRef.current = true;
if (disabled) {
event.preventDefault();
} else {
focusContext.onItemFocus(itemId);
}
},
[onMouseDownProp, focusContext, itemId, disabled],
);
return (
<Button
type="button"
data-slot="action-bar-item"
variant="secondary"
size="sm"
disabled={disabled}
tabIndex={isTabStop ? 0 : -1}
{...itemProps}
className={cn(orientation === "vertical" && "w-full", className)}
ref={composedRef}
onClick={onClick}
onFocus={onFocus}
onKeyDown={onKeyDown}
onMouseDown={onMouseDown}
/>
);
}
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}
/>
);
}
interface ActionBarSeparatorProps extends DivProps {
orientation?: Orientation;
}
function ActionBarSeparator(props: ActionBarSeparatorProps) {
const {
orientation: orientationProp,
asChild,
className,
...separatorProps
} = props;
const context = useActionBarContext(SEPARATOR_NAME);
const orientation = orientationProp ?? context.orientation;
const SeparatorPrimitive = asChild ? Slot : "div";
return (
<SeparatorPrimitive
role="separator"
aria-orientation={orientation}
aria-hidden="true"
data-slot="action-bar-separator"
{...separatorProps}
className={cn(
"in-data-[slot=action-bar-selection]:ml-0.5 in-data-[slot=action-bar-selection]:h-4 in-data-[slot=action-bar-selection]:w-px bg-border",
orientation === "horizontal" ? "h-6 w-px" : "h-px w-full",
className,
)}
/>
);
}
export {
ActionBar,
ActionBarSelection,
ActionBarGroup,
ActionBarItem,
ActionBarClose,
ActionBarSeparator,
type ActionBarProps,
};Update the import paths to match your project setup.
Layout
import {
ActionBar,
ActionBarSelection,
ActionBarSeparator,
ActionBarGroup,
ActionBarItem,
ActionBarClose,
} from "@/components/ui/action-bar";
return (
<ActionBar>
<ActionBarSelection />
<ActionBarSeparator />
<ActionBarGroup>
<ActionBarItem />
<ActionBarItem />
</ActionBarGroup>
<ActionBarClose />
</ActionBar>
);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,
ActionBarGroup,
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>
<ActionBarSeparator />
<ActionBarGroup>
<ActionBarItem>
<Star />
Favorite
</ActionBarItem>
<ActionBarItem>
<Archive />
Archive
</ActionBarItem>
</ActionBarGroup>
</ActionBar>
</div>
);
}API Reference
ActionBar
The root component that controls the visibility and position of the action bar. Has role="toolbar" for accessibility.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-side] | "top" | "bottom" |
[data-align] | "start" | "center" | "end" |
[data-orientation] | "horizontal" | "vertical" |
ActionBarSelection
Displays selection information, typically used to show how many items are selected.
Prop
Type
ActionBarGroup
A container for action items that implements roving focus management. Items within a group can be navigated using arrow keys, forming a single tab stop. See Keyboard Interactions for full details.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-orientation] | "horizontal" | "vertical" |
ActionBarItem
An interactive button item within the action bar. When used inside a Group, participates in roving focus navigation.
Prop
Type
ActionBarClose
A button that closes the action bar by calling the onOpenChange callback with false. The close button has its own tab stop, separate from the group's roving focus.
Prop
Type
ActionBarSeparator
A visual separator between action bar items.
Prop
Type
Accessibility
Keyboard Interactions
The action bar follows the WAI-ARIA Toolbar pattern for keyboard navigation.
| Key | Description |
|---|---|
| Tab | Moves focus to the next focusable element (Action Group or Close button). |
| ShiftTab | Moves focus to the previous focusable element. |
| Escape | Closes the action bar and calls onOpenChange(false). |
| ArrowLeft | Moves focus to the previous item in the group (horizontal orientation). |
| ArrowUp | Moves focus to the previous item in the group (vertical orientation). |
| ArrowRight | Moves focus to the next item in the group (horizontal orientation). |
| ArrowDown | Moves focus to the next item in the group (vertical orientation). |
| Home | Moves focus to the first item in the group. |
| End | Moves focus to the last item in the group. |