Action Bar
A floating toolbar for bulk actions on selected items.
Preview
import * as React from "react";
import {
ArchiveIcon,
Cross2Icon,
TrashIcon,
} from "@radix-ui/react-icons";
import { Button } from "@/components/ui/Button";
import { Checkbox } from "@/components/ui/Checkbox";
import {
ActionBar,
ActionBarClose,
ActionBarGroup,
ActionBarItem,
ActionBarSelection,
ActionBarSeparator,
} from "@/components/ui/ActionBar";
export function ActionBarDemo() {
const [selected, setSelected] = React.useState<string[]>(["1", "3"]);
const toggle = (id: string) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
return (
<div className="relative min-h-40 w-full max-w-md">
<ul className="flex flex-col gap-0.5 border border-gray-6 rounded-md bg-gray-2 p-1">
{rows.map((row) => {
const checked = selected.includes(row.id);
return (
<li key={row.id}>
<div className="flex items-center gap-1 rounded-sm px-1 py-0.5 text-sm text-gray-12 hover:bg-gray-3">
<Checkbox
id={`invoice-${row.id}`}
checked={checked}
onCheckedChange={() => toggle(row.id)}
aria-label={`Select ${row.name}`}
/>
<label htmlFor={`invoice-${row.id}`} className="cursor-pointer">
{row.name}
</label>
</div>
</li>
);
})}
</ul>
<ActionBar
open={selected.length > 0}
onOpenChange={(open) => {
if (!open) setSelected([]);
}}
side="bottom"
align="center"
>
<ActionBarSelection>
{selected.length} selected
</ActionBarSelection>
<ActionBarSeparator />
<ActionBarGroup>
<ActionBarItem
onSelect={() => setSelected([])}
aria-label="Archive"
>
<ArchiveIcon className="icon" aria-hidden />
Archive
</ActionBarItem>
<ActionBarItem
onSelect={() => setSelected([])}
aria-label="Delete"
>
<TrashIcon className="icon" aria-hidden />
Delete
</ActionBarItem>
</ActionBarGroup>
<ActionBarClose aria-label="Clear selection">
<Cross2Icon className="icon" aria-hidden />
</ActionBarClose>
</ActionBar>
</div>
);
}Below the sm breakpoint (640px) the bar insets itself by one spacing unit (13px) on the left, right, and its anchored side, and its width stays within the viewport. Selection text stays fully visible. Action labels are visually hidden (the button aria-label remains), with 13px of horizontal padding on each action, and the close button stays pinned to the end.
Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/action-barManual
Install the following dependencies:
pnpm install @radix-ui/react-direction @radix-ui/react-slotCopy and paste the button component into your components/ui/Button.tsx file.
/**
* @description Displays a button or a component that looks like a button.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";
const buttonVariants = cva("button", {
variants: {
variant: {
default: "button-fill",
fill: "button-fill",
pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3",
link: "button-link",
menu: "button-menu",
},
size: {
default: "h-4",
xs: "h-2.5",
sm: "h-3",
md: "h-4",
lg: "h-5",
xl: "h-6",
icon: "w-3 h-3 p-0",
"icon-md": "w-4 h-4 p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isDisabled?: boolean;
isLoading?: boolean;
isLoadingText?: string | React.ReactNode;
mode?: VariantProps<typeof buttonVariants>["variant"];
label?: string | React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props: ButtonProps, ref) => {
const {
className,
variant,
mode,
size,
asChild = false,
isDisabled,
isLoading,
isLoadingText,
children,
label,
...rest
} = props;
const Comp = asChild ? Slot : "button";
const disabled = isDisabled || isLoading || props.disabled;
const effectiveVariant = variant ?? mode;
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({ variant: effectiveVariant, size, className }),
disabled && "opacity-50 cursor-not-allowed"
)}
ref={ref}
disabled={disabled}
{...rest}
>
{asChild ? (
children
) : (
<>
{isLoading && isLoadingText ? isLoadingText : <>{label}{children}</>}
</>
)}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
export type { ButtonProps };
export default Button;
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 compose refs utility into your utils/compose-refs.tsx 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 use as ref hook into your hooks/use-as-ref.ts file.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/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 use isomorphic layout effect hook into your hooks/use-isomorphic-layout-effect.ts file.
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };
Copy and paste the ActionBar component into your components/ui/ActionBar.tsx file.
"use client";
/**
* @description A floating toolbar for bulk actions on selected items.
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import { useDirection } from "@radix-ui/react-direction";
import { Slot as SlotPrimitive } from "@radix-ui/react-slot";
import { useComposedRefs } from "@/utils/compose-refs";
import { cn } from "@/utils/class-names";
import { useAsRef } from "@/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
import { Button } from "@/components/ui/Button";
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 = 13,
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 ? SlotPrimitive.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-md border border-gray-6 bg-gray-1 outline-none animate-action-bar-show",
"data-[side=bottom]:origin-bottom data-[side=top]:origin-top",
orientation === "horizontal"
? "flex flex-row items-center gap-1 px-1 py-0.5"
: "flex flex-col items-start gap-1 px-0.5 py-1",
className,
)}
style={
{
"--action-bar-side-offset": `${sideOffset}px`,
"--action-bar-align-offset": `${alignOffset}px`,
...style,
} as React.CSSProperties
}
/>,
portalContainer,
)}
</ActionBarContext.Provider>
);
}
function ActionBarSelection(props: DivProps) {
const { className, asChild, ...selectionProps } = props;
const SelectionPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<SelectionPrimitive
data-slot="action-bar-selection"
{...selectionProps}
className={cn(
"flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-sm border border-gray-6 bg-gray-2 px-1 py-0.5 text-sm font-medium tabular-nums text-gray-12",
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 ? SlotPrimitive.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-0.5 outline-none",
orientation === "horizontal"
? "items-center"
: "w-full flex-col items-start",
className,
)}
onBlur={onBlur}
onFocus={onFocus}
onMouseDown={onMouseDown}
/>
</FocusContext.Provider>
);
}
function renderActionBarItemChildren(children: React.ReactNode) {
return React.Children.map(children, (child, index) => {
if (typeof child === "string" || typeof child === "number") {
if (typeof child === "string" && child.trim() === "") return child;
return (
<span key={index} data-slot="action-bar-item-label">
{child}
</span>
);
}
return child;
});
}
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,
children,
...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="pill"
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}
>
{renderActionBarItemChildren(children)}
</Button>
);
}
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 ? SlotPrimitive.Slot : "button";
return (
<ClosePrimitive
type="button"
data-slot="action-bar-close"
{...closeProps}
className={cn(
"inline-flex size-3 items-center justify-center rounded-sm text-gray-11 opacity-70 outline-none transition-opacity hover:bg-gray-3 hover:opacity-100 focus-visible:ring-2 focus-visible:ring-accent-8 disabled:pointer-events-none [&_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 ? SlotPrimitive.Slot : "div";
return (
<SeparatorPrimitive
role="separator"
aria-orientation={orientation}
aria-hidden="true"
data-slot="action-bar-separator"
{...separatorProps}
className={cn(
"bg-gray-6 in-data-[slot=action-bar-selection]:ml-0.5 in-data-[slot=action-bar-selection]:h-3 in-data-[slot=action-bar-selection]:w-px",
orientation === "horizontal" ? "h-3 w-px" : "h-px w-full",
className,
)}
/>
);
}
export {
ActionBar,
ActionBarClose,
ActionBarGroup,
ActionBarItem,
type ActionBarProps,
ActionBarSelection,
ActionBarSeparator,
};Copy styles/action-bar.css and import it once in your global stylesheet. The CLI does this from the registry css entry. Placement and the narrow-viewport layout live in that file.
@import "../styles/action-bar.css";Usage
TopAligned
export const TopAligned = () => {
const [open, setOpen] = React.useState(true);
return (
<div className="relative min-h-32 w-full">
<Button variant="pill" type="button" onClick={() => setOpen(true)}>
Show action bar
</Button>
<ActionBar
open={open}
onOpenChange={setOpen}
side="top"
align="center"
sideOffset={13}
>
<ActionBarSelection>3 selected</ActionBarSelection>
<ActionBarSeparator />
<ActionBarGroup>
<ActionBarItem onSelect={() => setOpen(false)}>
<ArchiveIcon className="icon" aria-hidden />
Archive
</ActionBarItem>
<ActionBarItem onSelect={() => setOpen(false)}>
<TrashIcon className="icon" aria-hidden />
Delete
</ActionBarItem>
</ActionBarGroup>
<ActionBarClose aria-label="Close">
<Cross2Icon className="icon" aria-hidden />
</ActionBarClose>
</ActionBar>
</div>
);
};Vertical
export const Vertical = () => {
const [open, setOpen] = React.useState(true);
return (
<div className="relative min-h-48 w-full">
<Button variant="pill" type="button" onClick={() => setOpen(true)}>
Show vertical bar
</Button>
<ActionBar
open={open}
onOpenChange={setOpen}
orientation="vertical"
side="bottom"
align="end"
sideOffset={13}
alignOffset={13}
>
<ActionBarSelection>2 selected</ActionBarSelection>
<ActionBarSeparator />
<ActionBarGroup>
<ActionBarItem onSelect={() => setOpen(false)}>Archive</ActionBarItem>
<ActionBarItem onSelect={() => setOpen(false)}>Delete</ActionBarItem>
</ActionBarGroup>
<ActionBarClose aria-label="Close">
<Cross2Icon className="icon" aria-hidden />
</ActionBarClose>
</ActionBar>
</div>
);
};