Skip to content

Folds the least important controls into More as it narrows, by priority.

Menus & tooltips@base-ui/reactmotion@number-flow/react

01Preview

Checkout · Active issues

14

CHK-212Saved cards fail on Safari 17

CHK-205Address form loses focus on paste

CHK-198Tax line rounds twice

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react motion @number-flow/react

03Usage

import { OverflowMenu } from "@/components/ui/overflow-menu";

<OverflowMenu
  label="View options"
  items={[
    { id: "filter", label: "Filter", icon: <FilterIcon />, priority: 10, onSelect: openFilter },
    { id: "sort", label: "Sort", icon: <SortIcon />, priority: 8, onSelect: openSort },
    { id: "done", label: "Hide done", icon: <CheckIcon />, pressed: hideDone, onSelect: toggleDone },
    { id: "export", label: "Export CSV", icon: <DownloadIcon />, priority: 1, onSelect: exportCsv },
  ]}
  end={<Button>New issue</Button>}
/>

04Source

"use client";
import { Menu } from "@base-ui/react/menu";
import { Toolbar } from "@base-ui/react/toolbar";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Check, MoreH } from "@/lib/icons";
import { ease, spring } from "@/lib/motion";
import { Tooltip, TooltipProvider } from "@/components/ui/tooltip";

export type OverflowItem = {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect?: () => void;
  /** Higher stays in the bar longer. Ties fall back to order: later items fold first. */
  priority?: number;
  /** Makes it a toggle: aria-pressed in the bar, a checkbox item in the menu. */
  pressed?: boolean;
  /** Show only the icon in the bar (the label becomes its name and tooltip). */
  iconOnly?: boolean;
  disabled?: boolean;
  tone?: "default" | "danger";
};

/**
 * Which items fit in `width`, by priority. The ones that don't fold into the menu, and when
 * anything folds the menu button's width is reserved first.
 */
export function fitItems(items: OverflowItem[], widths: Record<string, number>, width: number, moreWidth: number, gap: number) {
  const total = items.reduce((sum, it, i) => sum + (widths[it.id] ?? 0) + (i ? gap : 0), 0);
  if (total <= width) return new Set(items.map((i) => i.id));
  const budget = width - moreWidth - gap;
  const ranked = items.map((it, i) => ({ it, i })).sort((a, b) => (b.it.priority ?? 0) - (a.it.priority ?? 0) || a.i - b.i);
  const fit = new Set<string>();
  let used = 0;
  for (const { it } of ranked) {
    const w = (widths[it.id] ?? 0) + (fit.size ? gap : 0);
    // Strict priority: once something doesn't fit, nothing ranked below it squeezes in ahead of it.
    if (used + w > budget) break;
    used += w;
    fit.add(it.id);
  }
  return fit;
}

const GAP = 2;

export type OverflowMenuProps = Omit<React.ComponentProps<"div">, "children"> & {
  items: OverflowItem[];
  /** Accessible name of the toolbar. */
  label: string;
  /** Label of the overflow button. */
  moreLabel?: string;
  /** Content after the collapsible items that never folds, e.g. a primary action. */
  end?: React.ReactNode;
  /** Called with the ids that are currently folded into the menu. */
  onOverflowChange?: (hidden: string[]) => void;
};

/**
 * A toolbar that folds its lowest-priority items into a "more" menu as it narrows, and lets them
 * back out as it widens. Items leave the bar with a fade while their neighbors spring into place.
 */
export function OverflowMenu({ items, label, moreLabel = "More", end, onOverflowChange, className, ...rest }: OverflowMenuProps) {
  const reduce = useReducedMotion();
  const regionRef = useRef<HTMLDivElement>(null);
  const ghostRef = useRef<HTMLDivElement>(null);
  const moreGhostRef = useRef<HTMLSpanElement>(null);
  const moreRef = useRef<HTMLButtonElement>(null);
  // Everything shows until the first measurement, which lands before the first paint.
  const [visible, setVisible] = useState<Set<string> | null>(null);
  const [settled, setSettled] = useState(false);
  const visibleRef = useRef<Set<string> | null>(null);

  // One observer measures the region and every item's natural width from a hidden copy of the row.
  const idsKey = items.map((i) => `${i.id}:${i.label}:${i.iconOnly ? 1 : 0}`).join("|");
  const itemsRef = useRef(items);
  useLayoutEffect(() => {
    itemsRef.current = items;
  });
  useLayoutEffect(() => {
    const region = regionRef.current;
    const ghost = ghostRef.current;
    if (!region || !ghost) return;
    const measure = () => {
      const widths: Record<string, number> = {};
      ghost.querySelectorAll<HTMLElement>("[data-ghost-id]").forEach((el) => {
        widths[el.dataset.ghostId!] = el.getBoundingClientRect().width;
      });
      const moreWidth = moreGhostRef.current?.getBoundingClientRect().width ?? 32;
      const next = fitItems(itemsRef.current, widths, region.clientWidth, moreWidth, GAP);
      const prev = visibleRef.current;
      if (prev && prev.size === next.size && [...next].every((id) => prev.has(id))) return;
      // Anything folding away while focused hands focus to the menu button instead of the page.
      const focused = document.activeElement?.closest<HTMLElement>("[data-item-id]")?.dataset.itemId;
      if (focused && !next.has(focused)) requestAnimationFrame(() => moreRef.current?.focus());
      visibleRef.current = next;
      setVisible(next);
      // The first fit happens before paint and shouldn't animate; later ones should.
      if (!prev) requestAnimationFrame(() => setSettled(true));
    };
    const ro = new ResizeObserver(measure);
    ro.observe(region);
    ro.observe(ghost);
    return () => ro.disconnect();
  }, [idsKey]);

  const shown = visible ? items.filter((i) => visible.has(i.id)) : items;
  const hidden = visible ? items.filter((i) => !visible.has(i.id)) : [];
  const hiddenKey = hidden.map((i) => i.id).join(",");
  useEffect(() => {
    onOverflowChange?.(hiddenKey ? hiddenKey.split(",") : []);
  }, [hiddenKey, onOverflowChange]);

  const layout = reduce || !settled ? false : ("position" as const);
  const still = { opacity: 0, transition: { duration: 0 } };

  return (
    <TooltipProvider>
      <Toolbar.Root aria-label={label} className={cn("relative flex min-w-0 items-center gap-2", className)} {...rest}>
        <div ref={regionRef} className="relative flex min-w-0 flex-1 items-center overflow-hidden" style={{ gap: GAP }}>
          <AnimatePresence initial={false} mode="popLayout">
            {shown.map((it) => (
              <motion.div
                key={it.id}
                layout={layout}
                data-item-id={it.id}
                className="shrink-0"
                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: "blur(2px)" }}
                animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                exit={!settled ? still : reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.9, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.in } }}
                transition={reduce ? { duration: 0.12 } : { ...spring.snappy, opacity: { duration: 0.16, ease: ease.out } }}
              >
                <BarItem item={it} />
              </motion.div>
            ))}
            {hidden.length > 0 && (
              <motion.div
                key="__more"
                layout={layout}
                className="shrink-0"
                initial={!settled ? false : reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85, transition: { duration: 0.12 } }}
                transition={reduce ? { duration: 0.12 } : spring.pop}
              >
                <Menu.Root>
                  <Toolbar.Button
                    ref={moreRef}
                    render={<Menu.Trigger />}
                    aria-label={`${moreLabel}, ${hidden.length} ${hidden.length === 1 ? "item" : "items"}`}
                    className={cn(buttonClass, "gap-1 pl-2 pr-2 data-popup-open:bg-fg/[0.08] data-popup-open:text-fg")}
                  >
                    <MoreH />
                    {/* The count ticks as items fold in, so it's clear where they went. */}
                    <span aria-hidden className="tabular text-[11.5px] text-fg-3">
                      <NumberFlow value={hidden.length} animated={!reduce} />
                    </span>
                  </Toolbar.Button>
                  <Menu.Portal>
                    <Menu.Positioner side="bottom" align="end" sideOffset={6} collisionPadding={8} className="z-(--z-dropdown)">
                      <Menu.Popup className={popupClass}>
                        {hidden.map((it) => (
                          <MenuItem key={it.id} item={it} />
                        ))}
                      </Menu.Popup>
                    </Menu.Positioner>
                  </Menu.Portal>
                </Menu.Root>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        {end}

        {/* A hidden copy of every item at its natural width: what the fitting is measured against. */}
        <div ref={ghostRef} aria-hidden inert className="pointer-events-none invisible absolute left-0 top-0 flex w-max" style={{ gap: GAP }}>
          {items.map((it) => (
            <span key={it.id} data-ghost-id={it.id} className="shrink-0">
              <span className={cn(buttonClass, it.iconOnly ? "w-8 px-0" : "px-2.5")}>
                {it.icon}
                {!it.iconOnly && it.label}
              </span>
            </span>
          ))}
          <span ref={moreGhostRef} className={cn(buttonClass, "gap-1 pl-2 pr-2")}>
            <MoreH />
            <span className="tabular text-[11.5px]">{items.length}</span>
          </span>
        </div>
      </Toolbar.Root>
    </TooltipProvider>
  );
}

const buttonClass = cn(
  "relative inline-flex h-8 shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-[12.5px] font-medium text-fg-2 outline-none",
  "transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.06] hover:text-fg active:scale-[0.96] active:duration-75",
  "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
  "aria-pressed:bg-fg/[0.1] aria-pressed:text-fg data-disabled:pointer-events-none data-disabled:opacity-40",
  "[&_svg]:size-4 [&_svg]:shrink-0",
);

const popupClass = cn(
  "min-w-48 max-w-(--available-width) rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none",
  "origin-(--transform-origin) transition-[opacity,scale,translate] duration-160 ease-out-expo",
  "data-starting-style:scale-96 data-starting-style:-translate-y-1 data-starting-style:opacity-0",
  "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100",
  "data-instant:transition-none motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
);

function BarItem({ item }: { item: OverflowItem }) {
  const button = (
    <Toolbar.Button
      aria-label={item.iconOnly ? item.label : undefined}
      aria-pressed={item.pressed}
      disabled={item.disabled}
      onClick={item.onSelect}
      className={cn(buttonClass, item.iconOnly ? "w-8 px-0" : "px-2.5", item.tone === "danger" && "hover:bg-danger-soft hover:text-danger")}
    >
      {item.icon}
      {!item.iconOnly && item.label}
    </Toolbar.Button>
  );
  return item.iconOnly ? <Tooltip content={item.label}>{button}</Tooltip> : button;
}

function MenuItem({ item }: { item: OverflowItem }) {
  const cls = cn(
    "flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none pointer-coarse:h-10",
    "transition-colors duration-75 data-highlighted:bg-fg/[0.06] data-disabled:opacity-40",
    "[&_svg]:size-4 [&_svg]:shrink-0",
    item.tone === "danger" ? "text-danger data-highlighted:bg-danger-soft" : "[&>svg:first-child]:text-fg-3",
  );
  if (item.pressed !== undefined) {
    return (
      <Menu.CheckboxItem checked={item.pressed} onCheckedChange={() => item.onSelect?.()} disabled={item.disabled} className={cls}>
        {item.icon}
        <span className="min-w-0 flex-1 truncate">{item.label}</span>
        <Menu.CheckboxItemIndicator keepMounted className="grid size-4 place-items-center text-fg transition-[opacity,scale] duration-150 data-unchecked:scale-75 data-unchecked:opacity-0">
          <Check />
        </Menu.CheckboxItemIndicator>
      </Menu.CheckboxItem>
    );
  }
  return (
    <Menu.Item onClick={item.onSelect} disabled={item.disabled} className={cls}>
      {item.icon}
      <span className="min-w-0 flex-1 truncate">{item.label}</span>
    </Menu.Item>
  );
}

05Props

OverflowMenu

PropTypeDefaultDescription
items*OverflowItem[]Every control, in bar order. Those that don't fit fold into the menu, in the same order.
label*stringAccessible name of the toolbar.
moreLabelstring"More"Name of the overflow button; the folded count is appended: "More, 3 items".
endReactNodeContent after the collapsible region that never folds, such as the primary action.
onOverflowChange(hidden: string[]) => voidCalled with the ids currently folded into the menu.

OverflowItem

PropTypeDefaultDescription
id*stringStable key.
label*stringButton text, or its name and tooltip when iconOnly.
iconReactNode16px icon, shown in the bar and the menu.
onSelect() => voidRuns from the bar button or the menu item.
prioritynumber0Higher stays longer. Folding is strict: nothing lower ranked stays in the bar once a higher one has folded. Ties fold from the end.
pressedbooleanMakes it a toggle: aria-pressed in the bar, a checkbox item with a tick in the menu.
iconOnlybooleanfalseOnly the icon in the bar, with a tooltip.
disabledbooleanfalseShown but not runnable.
tone"default" | "danger""default"Danger colors the item.

fitItems

PropTypeDefaultDescription
fitItems(items, widths, width, moreWidth, gap)Set<string>The fitting on its own: which ids fit in a width, reserving room for the More button once anything folds.

06Notes

Behavior

  • Item widths come from a hidden copy of the row, so fitting is exact and doesn't depend on what is currently showing; one ResizeObserver refits on every size change, including label changes like Sort: Priority to Sort: Updated.
  • The first fit happens before the first paint and doesn't animate. Until then (server render) every item is present and the region clips.
  • If the focused control folds away, focus moves to the More button rather than falling to the page.
  • The More button only exists while something is folded, and its count says how many.

Motion

  • Folding items fade and scale to 0.9 with a 2px blur in 140ms; returning ones reverse it. Neighbors and the More button move on the snappy spring with layout animation, so the bar closes up instead of jumping.
  • The folded count rolls digit by digit as items come and go. The menu grows from the button (scale 0.96, 160ms).
  • Reduced motion swaps it all for short fades and a static count.

Accessibility

  • A toolbar with one tab stop and arrow-key roving over whatever is visible, More included. The menu has arrow keys, typeahead and Escape back to More.
  • The More button's name includes the count. Toggles stay toggles in both places: aria-pressed in the bar, menuitemcheckbox in the menu.