Expandable Toolbar

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

toolbarwidth-animation
"use client";

import {
  type ComponentPropsWithoutRef,
  type CSSProperties,
  type KeyboardEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { 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 useIsomorphicLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

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;
const TOOLBAR_PADDING = 2;
const TRIGGER_RADIUS_OPEN = 8;
const TRIGGER_RADIUS_CLOSED = TRIGGER_RADIUS_OPEN + TOOLBAR_PADDING;

type ExpandableToolbarSide = "start" | "end";
type ExpandableToolbarAnchor = "toolbar" | "trigger";
type ExpandableToolbarTriggerProps = ComponentPropsWithoutRef<"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<
  ComponentPropsWithoutRef<"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. */
  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 [contentRef, contentWidth] = useMeasuredWidth<HTMLDivElement>();
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const shouldReduceMotion = useReducedMotion();
  const controlled = open !== undefined;
  const isOpen = controlled ? open : internalOpen;
  const panelWidth = isOpen ? contentWidth : 0;
  const currentLabel = isOpen ? collapseLabel : expandLabel;

  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 ||
        !closeOnEscape ||
        !isOpen ||
        event.key !== "Escape"
      ) {
        return;
      }

      event.stopPropagation();
      setOpen(false);
      focusTrigger();
    },
    [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": 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 panel = (
    <motion.div
      initial={false}
      id={panelId}
      aria-hidden={!isOpen}
      inert={!isOpen ? true : undefined}
      data-slot="expandable-toolbar-panel"
      data-state={isOpen ? "open" : "closed"}
      animate={{ width: panelWidth, opacity: isOpen ? 1 : 0 }}
      transition={transition}
      className={cn(
        "flex min-w-0 overflow-hidden whitespace-nowrap",
        side === "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,
        )}
      >
        {side === "end" ? (
          <span
            aria-hidden="true"
            data-slot="expandable-toolbar-trigger-separator"
            className={cn(
              "mx-1 h-5 w-px shrink-0 bg-border",
              classNames?.triggerSeparator,
            )}
          />
        ) : null}
        {children}
        {side === "start" ? (
          <span
            aria-hidden="true"
            data-slot="expandable-toolbar-trigger-separator"
            className={cn(
              "mx-1 h-5 w-px shrink-0 bg-border",
              classNames?.triggerSeparator,
            )}
          />
        ) : 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}
    >
      {side === "start" ? (
        <>
          {panel}
          {trigger}
        </>
      ) : (
        <>
          {trigger}
          {panel}
        </>
      )}
    </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" : "left-0",
          )}
        >
          {toolbar}
        </motion.div>
      </div>
    );
  }

  return toolbar;
}

function DefaultExpandableToolbarTrigger({
  open,
  expandIcon,
  collapseIcon,
  triggerProps,
}: {
  open: boolean;
  expandIcon?: ReactNode;
  collapseIcon?: ReactNode;
  triggerProps: ExpandableToolbarTriggerProps;
}) {
  const { className, ...buttonProps } = triggerProps;
  const icon = open ? (collapseIcon ?? expandIcon) : expandIcon;

  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}
    >
      <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;
}

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

  useIsomorphicLayoutEffect(() => {
    const element = ref.current;

    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);

    return () => resizeObserver.disconnect();
  }, []);

  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

pnpm dlx shadcn@latest add @ericts/expandable-toolbar