When horizontal space is constrained, you often want to show a couple of primary inline actions and tuck the rest behind a single “more” trigger. A great interaction pattern is to let the inline row expand inline—preserving spatial context—so secondary actions appear exactly where users expect them.
In this post, we’ll build that pattern in three parts:
We’ll use motion/react for layout-driven animation and lucide-react for icons.
InlineItem: a pill that animates its position and size
The InlineItem is deliberately simple: it renders one action in a rounded pill and opts into shared layout animations via a stable layoutId. This lets the item animate its position and size seamlessly across layouts.
const InlineItem = ({
title,
layoutId,
}: {
title: string;
layoutId: string;
}) => {
return (
<motion.div
layoutId={layoutId}
className="px-4 h-10 grid place-content-center bg-white text-black text-sm font-bold rounded-full"
>
<p>{title}</p>
</motion.div>
);
}Key points:
TriggerButton: the compact “more/close” control
The trigger toggles the expanded state and swaps its icon accordingly. It also participates in the shared layout transitions with its own layoutId, which helps the control glide to its new position rather than popping.
const TriggerButton = ({
expanded,
setExpanded,
}: {
expanded?: boolean;
setExpanded?: (expanded: boolean) => void;
}) => {
return (
<motion.button
layoutId="action"
whileTap={{ scale: 0.8 }}
className="p-4 size-10 grid place-content-center bg-white text-neutral-600 hover:text-neutral-500 rounded-full"
onClick={() => setExpanded?.(!Boolean(expanded))}
>
<motion.span
initial={false}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
layoutId="action-icon"
>
{expanded ? <X /> : <MoreHorizontal />}
</motion.span>
</motion.button>
);
}Why this works well:
InlineOverflowInteraction: orchestrating the inline expansion
The parent component is responsible for:
const InlineOverflowInteraction = () => {
const [expanded, setExpanded] = useState(false);
return (
<div className="max-w-[400px] relative mx-auto flex items-center justify-center">
<MotionConfig transition={{ type: "spring", bounce: 0.3 }}>
{!expanded && (
<motion.div
layoutId="wrapper"
className="h-14 py-4 px-2 bg-[#f6f2ec] flex items-center gap-x-2 rounded-full overflow-hidden"
style={{ borderRadius: 999 }}
>
<InlineItem layoutId="save" title="Save" />
<InlineItem layoutId="copy" title="Copy" />
<TriggerButton setExpanded={setExpanded} />
</motion.div>
)}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
layoutId="wrapper"
className="absolute z-10 h-14 py-4 px-2 bg-[#f6f2ec] flex items-center gap-x-2 rounded-full overflow-hidden"
style={{ borderRadius: 999 }}
>
<InlineItem layoutId="save" title="Save" />
<InlineItem layoutId="copy" title="Copy" />
<motion.div
layoutId="collapsed-items"
className="flex items-center gap-x-2 relative -z-1" // -z-1 to hide the blur effect when the element is exiting
initial={{
opacity: 0,
x: -60, // Push the collapsed items to the left so that we give the illusion that the items start at the 0 position
scale: 0.7,
filter: "blur(4px)",
}}
animate={{ opacity: 1, x: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.7, filter: "blur(4px)" }}
>
<InlineItem layoutId="share" title="Share" />
<InlineItem layoutId="delete" title="Delete" />
</motion.div>
<TriggerButton expanded setExpanded={setExpanded} />
</motion.div>
)}
</AnimatePresence>
</MotionConfig>
</div>
);
}What’s happening here:
Implementation notes and tips:
Putting it all together
The final effect is a compact inline action row that gracefully expands to reveal more options without navigating away or opening a separate sheet. Thanks to shared layout animations, it preserves spatial context, keeps the trigger predictable, and feels snappy.
The result is an elegant, discoverable pattern that scales gracefully as your action set grows while keeping the most important actions immediately accessible.