Why I Built a Package WordPress Core Won't
3/28/2026
3 minutes readThe Tab Trap
If you have ever tried to add custom controls to the Gutenberg inspector sidebar, you have probably discovered a fun little surprise: your controls are prisoners of the tab system.
Add an InspectorControls component? Congrats, it lives in the Settings tab. Switch to Styles? Gone. Your carefully crafted panel just vanishes because Gutenberg decided it belongs to one tab and one tab only.
There is no group="all". No tabIndependent={true}. No escape hatch.
I ran into this while building a WordPress plugin that needed to show contextual information in the sidebar for specific blocks. Not settings. Not styles. Just information that should always be there when the block is selected. The moment I switched from the Settings tab to Styles and watched my panel disappear, I knew I was in trouble.
Down the Rabbit Hole
My first instinct was to check if someone had already solved this. Surely this is a common need. Turns out, it is. There are issues on the Gutenberg repo going back years:
- #59115 — "Block Inspector Tabs Extension" (2024)
- #67814 — "Standardize InspectorControls group slots" (2024)
There is even a draft PR for extensible tools panels. It has been a draft for a while. I am sure it will land any day now.
So I did what any reasonable developer does when the framework does not cooperate: I read the source code. Gutenberg determines which tabs a block exposes through an internal hook called useInspectorControlsTabs. Internal as in not exported. Not documented. Not for you. The group prop on InspectorControls supports 14 different values — settings, styles, color, typography, dimensions, border, effects, and more — but none of them mean "show this everywhere." And there is no public API to query which groups a block registers, so you cannot even dynamically target all of them.
At this point I had a clear picture of what was not going to work. That did not stop me from trying all of it anyway.
Everything That Did Not Work
I thought I could outsmart the tab system by rendering the same InspectorControls in every known group. The problem is you cannot know at runtime which groups a block will expose. Some blocks have two tabs. Some have three. Some have none. And if you guess wrong, you get duplicates in tabs that share groups. Not great.
Then I thought: forget the React way, I will just manipulate the DOM. Find the Advanced panel in the sidebar, insert my content before it. Clean, surgical, done. Except the inspector sidebar is a React-managed tree, and React has strong opinions about unexpected children. The moment I called insertBefore on a node inside its virtual DOM, the entire block editor crashed with a reconciliation error. React was not subtle about it either — three consecutive error dumps in the console, each one longer than the last.
Fine. CSS then. If the sidebar is a flex container, I can use order: -1 to visually reposition my portal content. I inspected the computed styles. display: block. Not flex. The sidebar is not a flex container. Of course it is not.
Each attempt felt clever for about five minutes before reality showed up.
The Approach That Actually Works
The solution came from understanding where the tab system's boundary actually lives in the DOM. The inspector tab panels are children of .editor-sidebar__panel. By hooking into editor.BlockEdit via addFilter and using React's createPortal to render content directly into that container, the controls land outside the tab panels entirely. Switch tabs, your controls stay put. Select a different block, the filter callback decides whether to show or hide. Deselect all blocks, everything disappears cleanly.
Getting there required understanding Gutenberg's internal rendering pipeline, React's DOM ownership boundaries, and the exact point in the sidebar DOM tree where the tab system ends and the panel container begins. Every failed attempt narrowed the problem until the right approach became clear.
I packaged this into gutenberg-inspector-portal. The API is one function:
import { registerGutenbergInspectorPortal } from 'gutenberg-inspector-portal';
registerGutenbergInspectorPortal(
'my-plugin/panel',
name => name === 'core/paragraph',
() => (
<PanelBody title="My Panel">
<TextControl label="Field" />
</PanelBody>
)
);
A namespace for isolation, a filter callback to control which blocks see it, and a render function for your content. Multiple plugins can register independently without stepping on each other.
Platform Gaps and When to Fill Them
There is a version of this story where I wait for Gutenberg to ship the extensibility API. The issues are open. The draft PR exists. People are working on it. Maybe next release, maybe the one after. In the meantime, every plugin developer who needs tab-independent controls is either duplicating the same workaround or giving up and accepting the limitation.
I have been building WordPress plugins long enough to know that "wait for core" is sometimes the right call and sometimes a polite way of saying "live with the problem." When the gap is well-defined, the workaround is stable, and the need is real, filling it yourself is not impatience — it is pragmatism.
When Gutenberg ships a proper API for this, I will happily archive the package. Until then, it exists because sometimes the best API is the one you write yourself.
