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
npm install motion lucide-react react-use-measureThen copy InlineOverflow.tsx. It depends only on @/lib/cn.
Usage
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
| Prop | Type | Default | Description |
|---|---|---|---|
primary | readonly OverflowAction[] | — | Always visible. Two or three; this is the resting width. |
overflow | readonly OverflowAction[] | — | Revealed in place by the toggle. |
label | string | "Actions" | Accessible name for the toolbar. |
moreLabel | string | "More actions" | Accessible name for the toggle. |
defaultOpen | boolean | false | Uncontrolled initial state. |
open | boolean | uncontrolled | Controlled state. Pass with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Fires on every toggle. |
className | string | — | Merged onto the root. |
OverflowAction: { id: string; label: string; icon: ReactNode; onSelect: () => void }.
Keyboard
| Key | Action |
|---|---|
Tab | Enter and leave the bar — it is one tab stop, not five |
← / → | Move between actions and the toggle, wrapping |
Home / End | First action, or the toggle |
Enter / Space | Run the focused action, or toggle the overflow |
Escape | Collapse, 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.
"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
| Framework | React 19 |
| Motion | Motion 12 (motion/react) |
| Measurement | react-use-measure |
| Styling | Tailwind CSS v4 |
| Icons | lucide-react |
| Types | TypeScript 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.