Inline Overflow

A toolbar with more actions than fit has to hide some, and a dropdown moves them to a different surface where they stop feeling like siblings of the primary actions.

DisclosureProductivityEditorFilesintermediate
  • Keyboard
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/inline-overflow.json

The problem

A toolbar with more actions than fit has to hide some. The usual answer is a dropdown, which moves them to a different surface with different hit targets, a different dismiss gesture and a different place on screen — so the secondary actions stop being siblings of the primary ones and become a different class of thing. Widening the bar in place keeps them all in one row, one surface, one set of rules.

Use cases

  • A document or item toolbar with two everyday actions and a few rarer ones
  • A selection toolbar in an editor, where the bar is already floating
  • Any row of three to six actions that has to survive a narrow container

Not for: more than about six actions — past that the expanded bar is wider than the container and the reveal has nowhere to go, so use a real menu. Not for actions that need submenus, descriptions or icons-plus-detail — use create-menu. Not for a single destructive action needing confirmation — use inline-confirm.

This is the third of the library's inline family, with inline-confirm and inline-search. They share one idea: resolve the interaction in the surface that started it rather than opening another one.

Installation

bash
npm install motion lucide-react react-use-measure

Then copy InlineOverflow.tsx. It depends only on @/lib/cn.

Usage

tsx
import InlineOverflow from "@/patterns/inline-overflow/InlineOverflow";
import { Copy, Save, Share, Trash2 } from "lucide-react";

<InlineOverflow
  label="Document actions"
  primary={[
    { id: "save", label: "Save", icon: <Save />, onSelect: () => save() },
    { id: "copy", label: "Copy", icon: <Copy />, onSelect: () => copy() },
  ]}
  overflow={[
    { id: "share", label: "Share", icon: <Share />, onSelect: () => share() },
    {
      id: "delete",
      label: "Delete",
      icon: <Trash2 />,
      onSelect: () => remove(),
    },
  ]}
/>;

API

PropTypeDefaultDescription
primaryreadonly OverflowAction[]—Always visible. Two or three; this is the resting width.
overflowreadonly OverflowAction[]—Revealed in place by the toggle.
labelstring"Actions"Accessible name for the toolbar.
moreLabelstring"More actions"Accessible name for the toggle.
defaultOpenbooleanfalseUncontrolled initial state.
openbooleanuncontrolledControlled state. Pass with onOpenChange.
onOpenChange(open: boolean) => void—Fires on every toggle.
classNamestring—Merged onto the root.

OverflowAction: { id: string; label: string; icon: ReactNode; onSelect: () => void }.

Keyboard

KeyAction
TabEnter and leave the bar — it is one tab stop, not five
← / →Move between actions and the toggle, wrapping
Home / EndFirst action, or the toggle
Enter / SpaceRun the focused action, or toggle the overflow
EscapeCollapse, returning focus to the toggle

The APG toolbar contract. A five-control bar that costs five tab presses to walk past is the more common implementation and the wrong one.

Accessibility

None of the actions did anything. All five chips were cursor-pointer divs with no click handler and no keyboard path — the bar expanded, looked complete, and could not be used by anyone. They are real buttons with an onSelect callback now, and the toggle is a button rather than a div, so it answers Enter and Space.

The toggle carries aria-expanded and aria-controls pointing at the revealed group, so a screen reader user is told the bar has more in it before they go looking. The bar itself is a labelled role="toolbar".

Focus stays on the toggle when it opens, which is correct for a disclosure — the revealed chips are one ← away and nothing has been taken from you. It is Escape that needs care: it can be pressed while focus is on a chip that is about to unmount, so the toggle is focused through its own ref rather than by index, because the index it will occupy after the collapse is not the one it occupies during the event.

Under prefers-reduced-motion the width snaps, the blur is dropped, and the chips lose the hover lift and tilt. The reveal still cross-fades, so the change is not silent.

Narrow containers. The chips tighten from px-3 to px-2 below 420px of container, which brings four labelled chips plus the toggle from ~390px down to ~360px and keeps the whole bar inside a typical preview column. Below that the expanded row scrolls horizontally inside the pill rather than clipping, and focus drags the scroll with it, so keyboard users reach every chip and the toggle without touching the scroll at all.

The tightening matters more than it looks. When the row overflows, the thing that ends up half-cut at the edge is the toggle — the one control that closes the bar — and a control sliced down the middle reads as broken rather than as scrollable.

Touch is declared false, and that is not an oversight. The chips are 36px tall and the toggle is a 28px circle six pixels from its neighbour. The chips could be grown to the 44px this library asks for, but the toggle could not — not without either changing the bar's proportions or letting its hit area overlap the chip beside it, and overlapping targets cause the mis-taps that the 44px rule exists to prevent. A dense action bar is a pointer-and-keyboard control. Saying so is more useful than claiming a guarantee the layout cannot keep; speed-dial is the thumb-reachable pattern.

Performance

The bar's width is measured from its own content with react-use-measure rather than hardcoded. The original animated to a fixed 210, which is a number that is only correct for the two labels it shipped with — translate "Save" into German and the bar clips itself. Because the overflow group is unmounted by AnimatePresence in popLayout mode, it leaves the flow the instant it starts exiting, so one measurement drives both states.

Two details make that measurement safe rather than circular. The measured row is w-max, so it reports its intrinsic width no matter what the bar around it is doing; and the narrow-screen cap is max-w-full in CSS rather than a Math.min against a second measurement. Capping the animated number against a measured container would make the container's size an input to the width that determines the container's size — which settles on a plausible-looking wrong answer instead of failing loudly.

Width is a layout-animated property and there is no way around that here — the bar's width is the animation. It is one element on its own line, so the reflow is cheap and contained; the chips ride it with layout="position", which translates them rather than re-laying them out.

The chips carried a transition-all class alongside Motion's whileHover and whileTap. Two systems were driving one transform: CSS interpolating toward each frame Motion wrote. Removing the class is what makes the press read as immediate.

Two dead properties went with it — an animationDelay in a animate object, which is not a Motion prop, and a z: -10 on a non-3D element.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

InlineOverflow.tsx
"use client";

import { useCallback, useId, useRef, useState } from "react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useReducedMotion,
} from "motion/react";
import { Ellipsis, X } from "lucide-react";
import useMeasure from "react-use-measure";

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

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface OverflowAction {
  id: string;
  label: string;
  icon: React.ReactNode;
  onSelect: () => void;
}

export interface InlineOverflowProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onSelect"
> {
  /** Always visible. Keep to two or three — this is the row's resting width. */
  primary: readonly OverflowAction[];
  /** Revealed in place by the toggle. */
  overflow: readonly OverflowAction[];
  /** Names the toolbar for assistive technology, e.g. "Document actions". */
  label?: string;
  /** Accessible name for the toggle. @default "More actions" */
  moreLabel?: string;
  defaultOpen?: boolean;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

/*
 * 200/14 is damping ratio 0.49 — pronounced overshoot, and deliberately so.
 * The bar overshooting its new width is what sells the row as one elastic
 * object rather than as chips appearing next to each other. It is well outside
 * the house `spring.snappy`, and mapping it onto a token would flatten it.
 */
const widthSpring = { type: "spring" as const, stiffness: 200, damping: 14 };

/** 200/18 is ratio 0.64. Drives the `layout="position"` slide of the chips. */
const positionSpring = { type: "spring" as const, stiffness: 200, damping: 18 };

/* -------------------------------------------------------------------------- */
/* Chip                                                                        */
/* -------------------------------------------------------------------------- */

function ActionChip({
  action,
  reduce,
  tabIndex,
  onFocus,
  onKeyDown,
  buttonRef,
}: {
  action: OverflowAction;
  reduce: boolean | null;
  tabIndex: number;
  onFocus: () => void;
  onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
  buttonRef: (node: HTMLButtonElement | null) => void;
}) {
  return (
    <motion.button
      ref={buttonRef}
      type="button"
      tabIndex={tabIndex}
      onClick={action.onSelect}
      onFocus={onFocus}
      onKeyDown={onKeyDown}
      whileTap={reduce ? undefined : { scale: 0.8 }}
      whileHover={
        reduce
          ? undefined
          : { y: -2, boxShadow: "0 8px 15px rgba(0, 0, 0, 0.1)", rotateZ: -3 }
      }
      // No `transition-all` here. The original carried one, which meant CSS was
      // interpolating toward each frame Motion wrote for `transform` and
      // `box-shadow` — two systems driving one property, which reads as lag on
      // the way in and rubber-banding on the way out.
      className={cn(
        "focus-ring flex cursor-pointer items-center justify-center gap-1.5",
        "rounded-4xl bg-surface py-2 text-content shadow-sm",
        // Tighter until the bar has room for the roomier padding. Four
        // labelled chips plus the toggle at `px-3` want ~390px; below that the
        // row scrolls and the toggle ends up cut in half at the edge, which
        // reads as broken rather than as scrollable.
        "px-2 @min-[420px]:px-3",
      )}
    >
      <span aria-hidden="true" className="[&>svg]:size-3.5">
        {action.icon}
      </span>
      {action.label}
    </motion.button>
  );
}

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * An action bar that grows to reveal its secondary actions in place.
 *
 * A toolbar with more actions than fit has to hide some, and a dropdown moves
 * them to a different surface with different hit targets and a different way
 * of being dismissed — so the secondary actions become a different class of
 * thing than the primary ones. Widening the bar keeps every action a sibling.
 */
export function InlineOverflow({
  primary,
  overflow,
  label = "Actions",
  moreLabel = "More actions",
  defaultOpen = false,
  open: controlledOpen,
  onOpenChange,
  className,
  ...rest
}: InlineOverflowProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const overflowId = `${uid}-overflow`;

  const [contentRef, content] = useMeasure({ offsetSize: false });
  const [uncontrolled, setUncontrolled] = useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : uncontrolled;

  const actionRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const toggleRef = useRef<HTMLButtonElement>(null);

  /*
   * APG toolbar: one tab stop, arrows walk the controls. Stops are the visible
   * actions followed by the toggle, which matches both DOM and visual order.
   */
  const visible = open ? [...primary, ...overflow] : primary;
  const toggleStop = visible.length;
  const [activeStop, setActiveStop] = useState(0);
  // Clamped rather than reset in an effect: closing the bar removes stops, and
  // a stale index would leave the toolbar with no tab stop at all.
  const active = Math.min(activeStop, toggleStop);

  const setOpen = useCallback(
    (next: boolean) => {
      if (!isControlled) setUncontrolled(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  const focusStop = useCallback(
    (index: number) => {
      setActiveStop(index);
      if (index === toggleStop) toggleRef.current?.focus();
      else actionRefs.current[index]?.focus();
    },
    [toggleStop],
  );

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>) => {
      const stops = toggleStop + 1;
      switch (event.key) {
        case "ArrowRight":
          event.preventDefault();
          focusStop((active + 1) % stops);
          return;
        case "ArrowLeft":
          event.preventDefault();
          focusStop((active - 1 + stops) % stops);
          return;
        case "Home":
          event.preventDefault();
          focusStop(0);
          return;
        case "End":
          event.preventDefault();
          focusStop(stops - 1);
          return;
        case "Escape":
          if (!open) return;
          event.preventDefault();
          setOpen(false);
          // Focused directly rather than by index: the stop the toggle will
          // occupy after this render is not the one it occupies now.
          setActiveStop(primary.length);
          toggleRef.current?.focus();
          return;
        default:
          return;
      }
    },
    [active, focusStop, open, primary.length, setOpen, toggleStop],
  );

  if (primary.length === 0 && overflow.length === 0) return null;

  /*
   * Measured from the content rather than a hardcoded collapsed width, so
   * relabelling an action or passing a different number of them cannot leave
   * the bar clipping itself.
   */
  const width = content.width || "auto";

  return (
    <MotionConfig transition={positionSpring}>
      {/*
        `w-full min-w-0` is load-bearing. Dropped into a flex parent this
        element defaults to `min-width: auto` and refuses to shrink below its
        own content, so on a narrow screen it would push the bar off the edge
        instead of letting `max-w-full` below do its job.
      */}
      <div
        className={cn(
          // Container query, not viewport: this bar is as likely to sit in a
          // narrow column on a wide screen as on a phone.
          "@container flex w-full min-w-0 justify-center px-4 py-2",
          className,
        )}
        {...rest}
      >
        <motion.div
          initial={false}
          animate={{ width }}
          transition={reduce ? { duration: 0.01 } : widthSpring}
          // The animated width is the content's intrinsic width; `max-w-full`
          // is what keeps it inside a 320px screen. Clamping in CSS rather
          // than in the measurement is deliberate — capping the animated value
          // against a measured container would make the container's size an
          // input to the width that determines the container's size.
          className="max-w-full rounded-full bg-[#219ebc] shadow-lg"
        >
          <div
            className={cn(
              "flex items-center justify-center overflow-x-auto rounded-full",
              // Scrollable rather than clipped, so the toggle stays reachable
              // when the expanded row is wider than the screen. Focusing a chip
              // that is off-edge scrolls it into view on its own.
              "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
            )}
          >
            {/*
              `w-max` keeps this at its intrinsic width whatever the bar around
              it is doing, which is what makes the measurement above a constant
              rather than a feedback loop.
            */}
            <div
              ref={contentRef}
              role="toolbar"
              aria-label={label}
              aria-orientation="horizontal"
              className="flex w-max items-center gap-1.5 p-1.5 text-sm font-semibold"
            >
              <motion.div
                layout="position"
                className="flex items-center justify-center gap-1.5 will-change-transform"
              >
                {primary.map((action, index) => (
                  <ActionChip
                    key={action.id}
                    action={action}
                    reduce={reduce}
                    tabIndex={index === active ? 0 : -1}
                    onFocus={() => setActiveStop(index)}
                    onKeyDown={handleKeyDown}
                    buttonRef={(node) => {
                      actionRefs.current[index] = node;
                    }}
                  />
                ))}
              </motion.div>

              <AnimatePresence mode="popLayout">
                {open && (
                  <motion.div
                    id={overflowId}
                    className="flex items-center gap-1.5"
                    initial={
                      reduce
                        ? { opacity: 0 }
                        : { opacity: 0, filter: "blur(4px)" }
                    }
                    animate={
                      reduce
                        ? { opacity: 1 }
                        : { opacity: 1, filter: "blur(0px)" }
                    }
                    exit={
                      reduce
                        ? { opacity: 0 }
                        : { opacity: 0, filter: "blur(4px)" }
                    }
                    transition={
                      reduce
                        ? { duration: 0.01 }
                        : { duration: 0.15, ease: "easeOut" }
                    }
                  >
                    {overflow.map((action, index) => {
                      const stop = primary.length + index;
                      return (
                        <ActionChip
                          key={action.id}
                          action={action}
                          reduce={reduce}
                          tabIndex={stop === active ? 0 : -1}
                          onFocus={() => setActiveStop(stop)}
                          onKeyDown={handleKeyDown}
                          buttonRef={(node) => {
                            actionRefs.current[stop] = node;
                          }}
                        />
                      );
                    })}
                  </motion.div>
                )}
              </AnimatePresence>

              <motion.button
                ref={toggleRef}
                type="button"
                layout="position"
                aria-label={moreLabel}
                aria-expanded={open}
                aria-controls={open ? overflowId : undefined}
                tabIndex={active === toggleStop ? 0 : -1}
                onClick={() => setOpen(!open)}
                onFocus={() => setActiveStop(toggleStop)}
                onKeyDown={handleKeyDown}
                className={cn(
                  "focus-ring z-10 flex size-7 cursor-pointer items-center",
                  "justify-center rounded-4xl bg-surface shadow-sm",
                )}
              >
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={open ? "close" : "more"}
                    aria-hidden="true"
                    initial={
                      reduce
                        ? { opacity: 0 }
                        : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
                    }
                    animate={
                      reduce
                        ? { opacity: 1 }
                        : { opacity: 1, scale: 1, filter: "blur(0px)" }
                    }
                    exit={
                      reduce
                        ? { opacity: 0 }
                        : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
                    }
                    transition={
                      reduce
                        ? { duration: 0.01 }
                        : { type: "spring", duration: 0.4, bounce: 0 }
                    }
                  >
                    {open ? (
                      <X className="size-4 text-content-subtle" />
                    ) : (
                      <Ellipsis className="size-4 text-content" />
                    )}
                  </motion.span>
                </AnimatePresence>
              </motion.button>
            </div>
          </div>
        </motion.div>
      </div>
    </MotionConfig>
  );
}

export default InlineOverflow;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react)
Measurementreact-use-measure
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by nitishkmrk. Rebuilt with a props API, working actions, an APG toolbar contract and reduced-motion handling. The 200/14 width spring — damping ratio 0.49, well outside the house default — is unchanged, because the overshoot is what makes the row read as one elastic object rather than as chips appearing beside each other.