Back to components

Expandable Toolbar

A controlled or uncontrolled toolbar primitive that measures its action slot and expands from either side.

layoutwidth-animation

side="start" — grows left

side="center" — grows both ways

side="end" — grows right

"use client";

import {
  Children,
  type ComponentProps,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
  type Ref,
  useCallback,
  useId,
  useRef,
  useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

const EASE_OUT = [0.22, 1, 0.36, 1] as const;
const DEFAULT_CONTENT_MAX_WIDTH = "min(32rem, calc(100vw - 2rem))";

const TOOLBAR_TRANSITION = {
  width: { duration: 0.2, ease: EASE_OUT },
  opacity: { duration: 0.14, ease: EASE_OUT },
} as const;
const TRIGGER_SURFACE_TRANSITION = { duration: 0.18, ease: EASE_OUT } as const;
// An icon swap is a tiny state change, so keep it snappy: ease-out, well
// under 150ms — the copy button's copy → check treatment, trimmed a touch
// because the swap runs exit-then-enter and the perceived time is doubled.
const TRIGGER_ICON_TRANSITION = {
  duration: 0.1,
  ease: [0.215, 0.61, 0.355, 1],
} as const;
const TRIGGER_ICON_VARIANTS = {
  hidden: { opacity: 0, scale: 0.5 },
  visible: { opacity: 1, scale: 1 },
} as const;
const TOOLBAR_PADDING = 2;
/** Must match the toolbar surface's `border` class. */
const TOOLBAR_BORDER_WIDTH = 1;
const TRIGGER_RADIUS_OPEN = 8;
const TRIGGER_RADIUS_CLOSED = TRIGGER_RADIUS_OPEN + TOOLBAR_PADDING;

type ExpandableToolbarSide = "start" | "end" | "center";
type ExpandableToolbarAnchor = "toolbar" | "trigger";
type ExpandableToolbarTriggerProps = ComponentProps<"button"> & {
  "data-state": "open" | "closed";
};

export type ExpandableToolbarClassNames = {
  triggerWrapper?: string;
  trigger?: string;
  triggerSeparator?: string;
  panel?: string;
  content?: string;
};

export type ExpandableToolbarTriggerRenderProps = {
  open: boolean;
  disabled: boolean;
  label: string;
  controlsId: string;
  triggerProps: ExpandableToolbarTriggerProps;
};

type ExpandableToolbarBaseProps = Omit<
  ComponentProps<"div">,
  "children" | "defaultValue" | "onChange"
> & {
  /** Controlled open state. */
  open?: boolean;
  /** Initial open state for uncontrolled usage. */
  defaultOpen?: boolean;
  /** Called whenever the toolbar requests an open-state change. */
  onOpenChange?: (open: boolean) => void;
  /**
   * Which side of the trigger the content should expand into. `center` splits
   * the children into two panels flanking the trigger, so the toolbar grows
   * symmetrically while the trigger stays put.
   */
  side?: ExpandableToolbarSide;
  /** Whether the full toolbar or only the trigger participates in layout. */
  anchor?: ExpandableToolbarAnchor;
  expandLabel?: string;
  collapseLabel?: string;
  controlsId?: string;
  disabled?: boolean;
  /** Close the toolbar when Escape is pressed inside it. */
  closeOnEscape?: boolean;
  contentMaxWidth?: CSSProperties["maxWidth"];
  classNames?: ExpandableToolbarClassNames;
  children: ReactNode;
};

type ExpandableToolbarDefaultTriggerProps = {
  /** Icon used by the default trigger when the toolbar is closed. */
  expandIcon: ReactNode;
  /** Icon used by the default trigger when the toolbar is open. */
  collapseIcon?: ReactNode;
  renderTrigger?: never;
};

type ExpandableToolbarCustomTriggerProps = {
  expandIcon?: ReactNode;
  collapseIcon?: ReactNode;
  /**
   * Replace the default icon button trigger while keeping the measured panel,
   * ARIA attributes, and open-state plumbing.
   */
  renderTrigger: (props: ExpandableToolbarTriggerRenderProps) => ReactNode;
};

export type ExpandableToolbarProps = ExpandableToolbarBaseProps &
  (ExpandableToolbarDefaultTriggerProps | ExpandableToolbarCustomTriggerProps);

export function ExpandableToolbar({
  open,
  defaultOpen = false,
  onOpenChange,
  side = "start",
  anchor = "toolbar",
  expandIcon,
  collapseIcon,
  expandLabel = "Expand toolbar",
  collapseLabel = "Collapse toolbar",
  controlsId,
  disabled = false,
  closeOnEscape = true,
  contentMaxWidth = DEFAULT_CONTENT_MAX_WIDTH,
  className,
  classNames,
  renderTrigger,
  children,
  role,
  "aria-label": ariaLabel = "Expandable toolbar",
  onKeyDown,
  style,
  ...props
}: ExpandableToolbarProps) {
  const generatedId = useId();
  const panelId = controlsId ?? `${generatedId}-panel`;
  const triggerRef = useRef<HTMLButtonElement>(null);
  const triggerWrapperRef = useRef<HTMLDivElement>(null);
  const [startContentRef, startContentWidth] =
    useMeasuredWidth<HTMLDivElement>();
  const [endContentRef, endContentWidth] = useMeasuredWidth<HTMLDivElement>();
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const shouldReduceMotion = useReducedMotion();
  const controlled = open !== undefined;
  const isOpen = controlled ? open : internalOpen;
  const currentLabel = isOpen ? collapseLabel : expandLabel;

  // `center` flanks the trigger with two panels: the first half of the
  // children expands into the start side, the rest into the end side.
  const childArray = Children.toArray(children);
  const splitIndex = Math.ceil(childArray.length / 2);
  const startChildren =
    side === "start"
      ? childArray
      : side === "center"
        ? childArray.slice(0, splitIndex)
        : [];
  const endChildren =
    side === "end"
      ? childArray
      : side === "center"
        ? childArray.slice(splitIndex)
        : [];
  const startPanelId = side === "center" ? `${panelId}-start` : panelId;
  const endPanelId = side === "center" ? `${panelId}-end` : panelId;
  const startPanelWidth = isOpen ? startContentWidth : 0;

  const focusTrigger = useCallback(() => {
    const trigger =
      triggerRef.current ?? getFirstFocusableElement(triggerWrapperRef.current);

    trigger?.focus();
  }, []);

  const setOpen = useCallback(
    (nextOpen: boolean) => {
      if (disabled) return;

      if (!controlled) {
        setInternalOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [controlled, disabled, onOpenChange],
  );

  const toggleOpen = useCallback(() => {
    setOpen(!isOpen);
  }, [isOpen, setOpen]);

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      onKeyDown?.(event);

      if (event.defaultPrevented) return;

      if (closeOnEscape && isOpen && event.key === "Escape") {
        event.stopPropagation();
        setOpen(false);
        focusTrigger();
        return;
      }

      // `role="toolbar"` promises horizontal arrow-key navigation (ARIA APG),
      // so move focus between the visible controls. Text-entry controls keep
      // the arrows for caret movement.
      if (!TOOLBAR_NAV_KEYS.includes(event.key)) return;

      const activeElement = document.activeElement;

      if (activeElement instanceof HTMLElement && isTextEntry(activeElement)) {
        return;
      }

      // Collapsed panels carry `inert` + `aria-hidden`, so filter on that
      // rather than on layout: `offsetParent` is also null for `position:
      // fixed` elements that are perfectly visible.
      const focusables = Array.from(
        event.currentTarget.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
      ).filter(
        (element) =>
          !element.closest("[inert],[hidden],[aria-hidden='true']"),
      );

      if (focusables.length === 0) return;

      const currentIndex = focusables.indexOf(activeElement as HTMLElement);
      const lastIndex = focusables.length - 1;
      let nextIndex: number;

      if (event.key === "Home") {
        nextIndex = 0;
      } else if (event.key === "End") {
        nextIndex = lastIndex;
      } else if (event.key === "ArrowRight") {
        nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % focusables.length;
      } else {
        nextIndex =
          currentIndex < 0
            ? lastIndex
            : (currentIndex - 1 + focusables.length) % focusables.length;
      }

      event.preventDefault();
      focusables[nextIndex]?.focus();
    },
    [closeOnEscape, focusTrigger, isOpen, onKeyDown, setOpen],
  );

  const triggerRadius = isOpen ? TRIGGER_RADIUS_OPEN : TRIGGER_RADIUS_CLOSED;
  const triggerTransition = shouldReduceMotion
    ? { duration: 0 }
    : TRIGGER_SURFACE_TRANSITION;
  const transition = shouldReduceMotion ? { duration: 0 } : TOOLBAR_TRANSITION;
  const triggerProps = {
    type: "button",
    disabled,
    "aria-label": currentLabel,
    "aria-expanded": isOpen,
    "aria-controls":
      side === "center" ? `${startPanelId} ${endPanelId}` : panelId,
    "data-state": isOpen ? "open" : "closed",
    className: classNames?.trigger,
    onClick: toggleOpen,
  } satisfies ExpandableToolbarTriggerProps;

  const trigger = (
    <motion.div
      ref={triggerWrapperRef}
      data-slot="expandable-toolbar-trigger-wrapper"
      data-state={isOpen ? "open" : "closed"}
      className={cn(
        "group/expandable-toolbar-trigger relative isolate flex size-8 shrink-0 items-center justify-center",
        classNames?.triggerWrapper,
      )}
    >
      {!renderTrigger ? (
        <motion.span
          aria-hidden="true"
          data-slot="expandable-toolbar-trigger-surface"
          initial={false}
          animate={{
            inset: isOpen ? 0 : -TOOLBAR_PADDING,
            borderRadius: triggerRadius,
          }}
          transition={triggerTransition}
          className={cn(
            "pointer-events-none absolute z-0 bg-muted opacity-0 transition-opacity",
            "group-hover/expandable-toolbar-trigger:opacity-100 group-data-[state=open]/expandable-toolbar-trigger:opacity-100",
            "dark:bg-muted/50",
            disabled && "hidden",
          )}
        />
      ) : null}
      {renderTrigger ? (
        renderTrigger({
          open: isOpen,
          disabled,
          label: currentLabel,
          controlsId: panelId,
          triggerProps,
        })
      ) : (
        <DefaultExpandableToolbarTrigger
          open={isOpen}
          expandIcon={expandIcon}
          collapseIcon={collapseIcon}
          triggerProps={triggerProps}
        />
      )}
    </motion.div>
  );

  const separator = (
    <span
      aria-hidden="true"
      data-slot="expandable-toolbar-trigger-separator"
      className={cn(
        "mx-1 h-5 w-px shrink-0 bg-border",
        classNames?.triggerSeparator,
      )}
    />
  );

  const renderPanel = (
    position: "start" | "end",
    id: string,
    content: ReactNode[],
    contentRef: Ref<HTMLDivElement>,
    contentWidth: number,
  ) => (
    <motion.div
      initial={false}
      id={id}
      aria-hidden={!isOpen}
      inert={!isOpen ? true : undefined}
      data-slot="expandable-toolbar-panel"
      data-state={isOpen ? "open" : "closed"}
      animate={{ width: isOpen ? contentWidth : 0, opacity: isOpen ? 1 : 0 }}
      transition={transition}
      className={cn(
        "flex min-w-0 overflow-hidden whitespace-nowrap",
        position === "start" ? "justify-end" : "justify-start",
        !isOpen && "pointer-events-none",
        classNames?.panel,
      )}
      style={{ maxWidth: contentMaxWidth }}
    >
      <div
        ref={contentRef}
        data-slot="expandable-toolbar-content"
        className={cn(
          "flex w-max shrink-0 flex-nowrap items-center gap-1",
          classNames?.content,
        )}
      >
        {position === "end" ? separator : null}
        {content}
        {position === "start" ? separator : null}
      </div>
    </motion.div>
  );

  const toolbar = (
    <div
      role={role ?? "toolbar"}
      aria-label={ariaLabel}
      data-slot="expandable-toolbar"
      data-state={isOpen ? "open" : "closed"}
      data-side={side}
      className={cn(
        "inline-flex max-w-full items-center overflow-hidden rounded-lg border bg-background text-foreground shadow-sm",
        className,
      )}
      onKeyDown={handleKeyDown}
      style={{ ...style, padding: TOOLBAR_PADDING }}
      {...props}
    >
      {startChildren.length > 0
        ? renderPanel(
            "start",
            startPanelId,
            startChildren,
            startContentRef,
            startContentWidth,
          )
        : null}
      {trigger}
      {endChildren.length > 0
        ? renderPanel(
            "end",
            endPanelId,
            endChildren,
            endContentRef,
            endContentWidth,
          )
        : null}
    </div>
  );

  if (anchor === "trigger") {
    return (
      <div
        data-slot="expandable-toolbar-anchor"
        data-state={isOpen ? "open" : "closed"}
        data-side={side}
        className="relative inline-flex size-8 shrink-0"
      >
        <motion.div
          className={cn(
            "absolute top-0",
            side === "start" && "right-0",
            side === "end" && "left-0",
          )}
          // For `center`, the box's left edge starts one border + padding to
          // the left of the anchor slot and shifts by the start panel's width
          // as it opens, so the trigger itself never moves — both panels
          // appear to grow out of it symmetrically.
          style={
            side === "center"
              ? { left: -(TOOLBAR_PADDING + TOOLBAR_BORDER_WIDTH) }
              : undefined
          }
          animate={
            side === "center" ? { x: -startPanelWidth } : undefined
          }
          transition={
            shouldReduceMotion ? { duration: 0 } : TOOLBAR_TRANSITION.width
          }
        >
          {toolbar}
        </motion.div>
      </div>
    );
  }

  return toolbar;
}

function DefaultExpandableToolbarTrigger({
  open,
  expandIcon,
  collapseIcon,
  triggerProps,
}: {
  open: boolean;
  expandIcon?: ReactNode;
  collapseIcon?: ReactNode;
  triggerProps: ExpandableToolbarTriggerProps;
}) {
  const shouldReduceMotion = useReducedMotion();
  const { className, ...buttonProps } = triggerProps;
  const icon = open ? (collapseIcon ?? expandIcon) : expandIcon;
  const swapsIcon = collapseIcon != null;
  const iconTransition = shouldReduceMotion
    ? { duration: 0 }
    : TRIGGER_ICON_TRANSITION;

  return (
    <Button
      variant="ghost"
      size="icon-sm"
      className={cn(
        "relative z-10 size-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground aria-expanded:bg-transparent aria-expanded:text-foreground active:scale-100 active:translate-y-0 active:not-aria-[haspopup]:translate-y-0 dark:hover:bg-transparent",
        className,
      )}
      {...buttonProps}
    >
      {swapsIcon ? (
        <AnimatePresence mode="wait" initial={false}>
          <motion.span
            key={open ? "collapse" : "expand"}
            aria-hidden="true"
            variants={TRIGGER_ICON_VARIANTS}
            initial="hidden"
            animate="visible"
            exit="hidden"
            transition={iconTransition}
            className="flex items-center justify-center"
          >
            {icon}
          </motion.span>
        </AnimatePresence>
      ) : (
        <span aria-hidden="true" className="flex items-center justify-center">
          {icon}
        </span>
      )}
    </Button>
  );
}

const FOCUSABLE_SELECTOR =
  'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

function getFirstFocusableElement(element: HTMLElement | null) {
  return element?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
}

const TOOLBAR_NAV_KEYS = ["ArrowLeft", "ArrowRight", "Home", "End"];

/** Controls where the arrow keys belong to the caret, not to toolbar nav. */
function isTextEntry(element: HTMLElement) {
  return (
    element instanceof HTMLTextAreaElement ||
    element.isContentEditable ||
    (element instanceof HTMLInputElement &&
      !["button", "checkbox", "radio", "range", "submit", "reset"].includes(
        element.type,
      ))
  );
}

function useMeasuredWidth<T extends HTMLElement>() {
  const [width, setWidth] = useState(0);
  const observerRef = useRef<ResizeObserver | null>(null);

  // A callback ref (rather than a mount-only effect) so a panel that mounts
  // later — `side` flipping to "center", children growing past one — is still
  // measured and observed instead of animating open to width 0.
  const ref = useCallback((element: T | null) => {
    observerRef.current?.disconnect();
    observerRef.current = null;

    if (!element) return;

    const updateWidth = (nextWidth: number) => {
      setWidth((currentWidth) =>
        currentWidth === nextWidth ? currentWidth : nextWidth,
      );
    };

    updateWidth(readElementWidth(element));

    if (typeof ResizeObserver === "undefined") return;

    const resizeObserver = new ResizeObserver((entries) => {
      updateWidth(readElementWidth(element, entries[0]));
    });

    resizeObserver.observe(element);
    observerRef.current = resizeObserver;
  }, []);

  return [ref, width] as const;
}

function readElementWidth(
  element: HTMLElement,
  entry?: ResizeObserverEntry,
) {
  const borderBoxSize = Array.isArray(entry?.borderBoxSize)
    ? entry?.borderBoxSize[0]
    : entry?.borderBoxSize;

  if (borderBoxSize) {
    return Math.ceil(borderBoxSize.inlineSize);
  }

  return Math.ceil(element.getBoundingClientRect().width || element.scrollWidth);
}

Installation