Expanding Button

A compact icon button that morphs — growing in width and height from its corner — into a floating panel holding arbitrary content. The transformation is the point; it stays content-agnostic.

overlaymorph
Expanding button
Open menu

A button that grows

"use client";

import { ChevronsUpDown } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

const PANEL_TRANSITION = {
  type: "spring",
  duration: 0.24,
  bounce: 0,
} as const;
const CONTENT_TRANSITION = {
  duration: 0.2,
  ease: [0.16, 1, 0.3, 1],
} as const;
const REDUCED_TRANSITION = { duration: 0 } as const;
// Single source of truth for the panel's height cap. Applied to the content
// region so consumers can add a scroll area / pinned footer without knowing it.
const CONTENT_MAX_HEIGHT =
  "var(--expanding-button-max-height, min(15.5rem, calc(100dvh - 8rem)))";

type ExpandingButtonContextValue = {
  open: boolean;
  setOpen: (open: boolean) => void;
};

const ExpandingButtonContext =
  React.createContext<ExpandingButtonContextValue | null>(null);

/**
 * Access the expanding button's open state from within its `children`, e.g. to
 * collapse the panel after an action. Throws when used outside the component.
 */
export function useExpandingButton() {
  const context = React.useContext(ExpandingButtonContext);

  if (!context) {
    throw new Error(
      "useExpandingButton must be used within an <ExpandingButton>.",
    );
  }

  return context;
}

export type ExpandingButtonClassNames = {
  panel?: string;
  content?: string;
  trigger?: string;
};

export type ExpandingButtonProps = Omit<
  React.ComponentPropsWithoutRef<"aside">,
  "children"
> & {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  triggerIcon?: React.ReactNode;
  openLabel?: string;
  closeLabel?: string;
  closeOnEscape?: boolean;
  closeOnOutsideClick?: boolean;
  classNames?: ExpandingButtonClassNames;
  children?: React.ReactNode;
};

/**
 * A compact icon button that morphs — growing in width and height from its
 * top-right corner — into a floating surface holding arbitrary `children`.
 * The transformation is the point; it is intentionally content-agnostic.
 *
 * The panel is bounded by `--expanding-button-width` and
 * `--expanding-button-max-height` and clips overflow, so the morph never runs
 * off-screen. The content region is a flex column capped at that height, so
 * tall content can add a `flex-1` scroll area (and, e.g., a `shrink-0` pinned
 * footer) without re-declaring the cap.
 *
 * The trigger overlays the top-right corner; content that reaches it should
 * clear it with `pr-[var(--expanding-button-trigger-inset)]` (defaults to the
 * 2rem trigger size).
 */
export function ExpandingButton({
  open,
  defaultOpen = false,
  onOpenChange,
  triggerIcon,
  openLabel = "Expand",
  closeLabel = "Collapse",
  closeOnEscape = true,
  closeOnOutsideClick = true,
  className,
  classNames,
  children,
  "aria-label": ariaLabel = "Expanding button",
  onKeyDown,
  ...props
}: ExpandingButtonProps) {
  const generatedId = React.useId();
  const panelId = `${generatedId}-panel`;
  const rootRef = React.useRef<HTMLElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const shouldReduceMotion = useReducedMotion();
  const openControlled = open !== undefined;
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const isOpen = openControlled ? open : uncontrolledOpen;

  const panelTransition = shouldReduceMotion
    ? REDUCED_TRANSITION
    : PANEL_TRANSITION;
  const contentTransition = shouldReduceMotion
    ? REDUCED_TRANSITION
    : CONTENT_TRANSITION;

  const setOpen = React.useCallback(
    (nextOpen: boolean) => {
      if (!openControlled) {
        setUncontrolledOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [onOpenChange, openControlled],
  );

  React.useEffect(() => {
    if (!isOpen || !closeOnOutsideClick) {
      return;
    }

    function handlePointerDown(event: PointerEvent) {
      const root = rootRef.current;

      if (
        !root ||
        !(event.target instanceof Node) ||
        root.contains(event.target)
      ) {
        return;
      }

      setOpen(false);
    }

    document.addEventListener("pointerdown", handlePointerDown, true);

    return () =>
      document.removeEventListener("pointerdown", handlePointerDown, true);
  }, [closeOnOutsideClick, isOpen, setOpen]);

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

      if (
        event.defaultPrevented ||
        !closeOnEscape ||
        !isOpen ||
        event.key !== "Escape"
      ) {
        return;
      }

      event.stopPropagation();
      setOpen(false);
      triggerRef.current?.focus();
    },
    [closeOnEscape, isOpen, onKeyDown, setOpen],
  );

  const contextValue = React.useMemo<ExpandingButtonContextValue>(
    () => ({ open: isOpen, setOpen }),
    [isOpen, setOpen],
  );

  return (
    <ExpandingButtonContext.Provider value={contextValue}>
      <aside
        ref={rootRef}
        aria-label={ariaLabel}
        data-slot="expanding-button"
        data-state={isOpen ? "open" : "closed"}
        onKeyDown={handleKeyDown}
        className={cn(
          "relative inline-flex max-w-[calc(100vw-1.5rem)] justify-end",
          className,
        )}
        {...props}
      >
        <div className="relative flex justify-end">
          <motion.div
            id={panelId}
            initial={false}
            animate={isOpen ? { height: "auto" } : { height: "2rem" }}
            transition={panelTransition}
            style={{ transformOrigin: "top right" }}
            className={cn(
              "relative overflow-hidden rounded-lg border border-border/70 bg-popover/90 text-popover-foreground shadow-md shadow-black/10 backdrop-blur-lg transition-[width] duration-240 ease-[cubic-bezier(0.23,1,0.32,1)] will-change-[width,height] motion-reduce:transition-none dark:shadow-black/20",
              isOpen
                ? "w-[min(var(--expanding-button-width,17rem),calc(100vw-1.5rem))]"
                : "size-8",
              classNames?.panel,
            )}
          >
            <AnimatePresence initial={false}>
              {isOpen ? (
                <motion.div
                  key="expanding-button-content"
                  initial={
                    shouldReduceMotion
                      ? false
                      : { opacity: 0, scale: 0.99, filter: "blur(1.5px)" }
                  }
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={
                    shouldReduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, scale: 0.98 }
                  }
                  transition={contentTransition}
                  style={{
                    transformOrigin: "top right",
                    maxHeight: CONTENT_MAX_HEIGHT,
                  }}
                  className={cn(
                    "flex flex-col [--expanding-button-trigger-inset:2rem] will-change-[filter,transform,opacity]",
                    classNames?.content,
                  )}
                >
                  {children}
                </motion.div>
              ) : null}
            </AnimatePresence>
          </motion.div>

          <button
            ref={triggerRef}
            type="button"
            aria-controls={panelId}
            aria-expanded={isOpen}
            aria-label={isOpen ? closeLabel : openLabel}
            onClick={() => setOpen(!isOpen)}
            className={cn(
              "extend-touch-target absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-md text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none",
              classNames?.trigger,
            )}
          >
            <span
              aria-hidden="true"
              className="flex size-8 shrink-0 items-center justify-center rounded-md bg-transparent text-current"
            >
              {triggerIcon ?? <ChevronsUpDown className="size-3.5" />}
            </span>
          </button>
        </div>
      </aside>
    </ExpandingButtonContext.Provider>
  );
}

Installation

pnpm dlx shadcn@latest add @ericts/expanding-button