- Q3 roadmapEdited 2 days ago
- Brand guidelinesEdited 1 week ago
- Launch checklistEdited 3 weeks ago
The shape of the problem
Picture a list of rows, each with a ⋯ button that opens a small actions menu: rename, duplicate, delete. The obvious way to build it is to put a menu inside every row. Ten rows, ten menus, each with its own open state, its own positioning logic, its own portal. It works, it's wasteful, and it gets worse the longer the list grows.
What you actually want is one menu. The actions are identical for every row — the only thing that changes is which row you opened it from. So why mount the thing ten times?
How Base UI does it
This is what Base UI calls a detached trigger, and the wiring is small. You create a handle, give it to a single Menu.Root, and give the same handle to as many Menu.Triggers as you like:
The trigger and the menu don't have to sit anywhere near each other in the markup — that's the detached part. They're connected by the handle, not by being nested.
The payload is the clever bit
A shared menu would be useless if it couldn't tell which row opened it. That's what payload is for. Each trigger passes its own row's data, and Menu.Root hands it back through a render function — {({ payload }) => …} — so the menu can label itself and wire its actions to the right item. In the demo, the action you pick fires a toast naming the row it acted on, which is the whole proof: one menu, but it always knows where it was opened from.
It positions itself correctly with no work from me, too. The menu anchors to whichever trigger opened it, so even though there's a single Menu.Positioner, it appears under the right ⋯ every time.
What it's stripped of
Not much, honestly — this is close to how I'd ship it. A real version would wire the actions to actual mutations and almost certainly add a confirmation step before the destructive one. The structure underneath, though, is exactly this: one Menu.Root, one handle, a trigger per row.
Why it's more than a tidiness win
Mounting one menu instead of ten isn't only lighter. It's also why the behaviour stays consistent. There's a single place where the actions, the keyboard handling, and the styling live, so every row's menu is identical by construction rather than by discipline. When one component owns every instance of an interaction, that interaction can't quietly drift apart across the list. It's work you do once and then stop having to think about.