Most WordPress plugins ship with several admin screens — a custom post type for the main entity, settings pages, reports, import/export. By default, every one of those screens becomes its own submenu item in the WordPress admin sidebar, cluttering the navigation and confusing end users. WordPress admin tabs solve this by collapsing multiple screens under one submenu item with a tabbed interface across the top of each screen — so users see one entry in the sidebar but can navigate between related screens via tabs.
This is the pattern WooCommerce uses for its own admin (Orders, Subscriptions, Reports, Settings under the WooCommerce parent menu). It is also how serious plugins like Gravity Forms, MemberPress, and AffiliateWP structure their admin. The technique combines four standard WordPress functions — register_post_type(), add_submenu_page(), remove_submenu_page(), and the all_admin_notices hook — into a clean, single-submenu admin UI.
Quick verdict: use this pattern when your plugin has 2-6 related admin screens that conceptually belong together. It dramatically improves UX without any additional plugin dependencies. The implementation is ~80 lines of PHP. This guide walks through the full pattern step by step, with working code for every piece.
WordPress admin tabs: quick reference
If you are evaluating WordPress admin tabs for your next project, you are weighing real trade-offs between cost, complexity, ownership, and time-to-launch. The right WordPress admin tabs decision depends on a handful of variables — team capacity, scope clarity, and how much ongoing maintenance you can absorb. The summary below is the 60-second version; the rest of this guide unpacks the nuance.
- WordPress admin tabs pricing typically ranges based on scope clarity, integration count, and ongoing support requirements.
- WordPress admin tabs timelines vary from days (small scope) to months (enterprise scope) depending on complexity.
- The biggest variable in WordPress admin tabs is requirements clarity at the brief stage — vague briefs produce vague quotes.
- Vendor selection for WordPress admin tabs matters more than tool selection — the right team beats the right stack.
- WordPress admin tabs ROI is positive when scope is bounded, deliverables are specified, and success criteria are measurable.
For complementary perspectives on WordPress admin tabs, the WordPress custom post type registration handbook and WordPress add_submenu_page() reference resources cover adjacent angles worth reviewing alongside this guide. They focus on the underlying technology and standards — this post focuses on the WordPress admin tabs decision specifically.
When you revisit your WordPress admin tabs approach in 12 to 24 months, three signals usually indicate a refresh is justified. First, the original brief no longer matches business reality — product, audience, or operational scope has shifted. Second, the underlying technology has moved forward enough that the WordPress admin tabs decision made under previous constraints would be different today. Third, ongoing maintenance overhead has crept up beyond what was forecast at launch. None of these are emergencies on their own; together they signal it is time to revisit fundamentals rather than patch around them.
Why use WordPress admin tabs for custom post types?
Compare the default vs the tabbed admin UI for a typical plugin with a custom post type plus a settings page:
| Aspect | Default (separate submenus) | Tabbed (one submenu) |
|---|---|---|
| Sidebar clutter | High (2+ items per plugin) | Low (1 item per plugin) |
| Cognitive load | Users hunt for the right screen | All related screens in one place |
| Navigation between screens | Multiple clicks via sidebar | One click via tab |
| Brand cohesion | Each screen feels disconnected | Screens feel like one app |
| Setup effort | Built-in WP behavior | ~80 lines of PHP |
Step 1: Register the custom post type under your parent menu
The custom post type registration looks normal except for one key argument: show_in_menu set to the slug of your parent menu (here, woocommerce). This nests the CPT under WooCommerce instead of creating its own top-level menu.
$labels = array(
'name' => esc_html__( '{Your Label}', '{your-text-domain}' ),
'singular_name' => esc_html__( '{Your Label}', '{your-text-domain}' ),
'menu_name' => esc_html__( '{Your Label}', '{your-text-domain}' ),
);
$args = array(
'labels' => $labels,
'public' => false,
'publicly_queryable' => false,
'exclude_from_search' => true,
'show_ui' => true,
'show_in_menu' => 'woocommerce', // attaches under the WooCommerce menu
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 30,
'rewrite' => array(
'slug' => '{your-custom-post-type}',
'with_front' => false,
),
'supports' => array( 'title', 'page-attributes' ),
);
register_post_type( '{your-custom-post-type}', $args );show_in_menu accepts any registered menu slug: Common values:
woocommerce,tools.php,options-general.php,edit.php?post_type=page, or any custom top-level menu slug you registered yourself. To create a brand-new top-level menu for your plugin, register it viaadd_menu_page()first, then use that slug here.
Step 2: Add the settings submenu page
Add your settings page (or any other non-CPT admin screen) under the same parent menu with add_submenu_page():
add_submenu_page(
'woocommerce', // parent menu slug
esc_html__( '{Your Label}', '{your-text-domain}' ), // page title
esc_html__( '{Your Label}', '{your-text-domain}' ), // menu title
'manage_woocommerce', // capability
'{your-submenu-slug}', // menu slug
'your_callback_function', // render callback
5 // position
);After this, the sidebar shows TWO submenus under WooCommerce — the CPT list table AND the settings page. Step 4 will collapse them visually into one.
Step 3: Render the tabs across the top of each screen
Hook into all_admin_notices to print the tab markup at the top of every admin page. Inside the callback, check whether the current screen ID is one of your plugin’s screens; if yes, render the nav-tab-wrapper with all your tabs.
add_action( 'all_admin_notices', 'raj_display_admin_tabs', 5 );
function raj_display_admin_tabs() {
$screen = get_current_screen();
if ( ! $screen || ! in_array( $screen->id, raj_get_tab_screen_ids(), true ) ) {
return;
}
$tabs = array(
'tab_one' => array(
'title' => __( 'Items', '{your-text-domain}' ),
'url' => admin_url( 'edit.php?post_type={your-custom-post-type}' ),
),
'tab_two' => array(
'title' => __( 'Settings', '{your-text-domain}' ),
'url' => admin_url( 'admin.php?page={your-submenu-slug}' ),
),
);
$current_tab = raj_get_current_tab();
?>
<div class="wrap woocommerce">
<h2 class="nav-tab-wrapper woo-nav-tab-wrapper">
<?php foreach ( $tabs as $id => $tab ) :
$classes = array( 'nav-tab' );
if ( $id === $current_tab ) {
$classes[] = 'nav-tab-active';
}
?>
<a href="<?php echo esc_url( $tab['url'] ); ?>" class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"><?php echo esc_html( $tab['title'] ); ?></a>
<?php endforeach; ?>
</h2>
</div>
<?php
}Why all_admin_notices?: This hook fires near the top of every admin page, right where WordPress’s default
.wrapheading would appear. It is the natural place to inject a tab bar that visually replaces the page title. Other candidate hooks likeadmin_noticeswork but place tabs further down the page.
Step 4: Define screen IDs and current-tab detection
The tab renderer needs to know which admin screens belong to your plugin (so tabs appear there) and which tab is currently active (to style it as nav-tab-active). Two small helper functions handle this:
function raj_get_tab_screen_ids() {
return array(
'{your-custom-post-type}', // single post screen
'edit-{your-custom-post-type}', // list table screen
'woocommerce_page_{your-submenu-slug}', // settings page
);
}
function raj_get_current_tab() {
$screen = get_current_screen();
if ( ! $screen ) {
return '';
}
switch ( $screen->id ) {
case '{your-custom-post-type}':
case 'edit-{your-custom-post-type}':
return 'tab_one';
case 'woocommerce_page_{your-submenu-slug}':
return 'tab_two';
}
return '';
}Screen IDs follow predictable patterns. CPT list tables use edit-{cpt-slug}. CPT single edit screens use just {cpt-slug}. Settings pages registered via add_submenu_page() use {parent-menu-slug}_page_{submenu-slug}. To verify, temporarily echo $screen->id in your callback while visiting each page.
Step 5: Hide duplicate submenus so users see only one
At this point, the sidebar still shows both the CPT submenu and the settings submenu. To present them as a single submenu, conditionally hide the inactive one based on the current screen. Use remove_submenu_page() hooked to admin_menu with a high priority (so it runs after both submenus are registered):
add_action( 'admin_menu', 'raj_dedupe_submenu_link', 100 );
function raj_dedupe_submenu_link() {
global $pagenow;
$cpt = '{your-custom-post-type}';
$page_slug = '{your-submenu-slug}';
$is_cpt = ( 'edit.php' === $pagenow && isset( $_GET['post_type'] ) && $cpt === $_GET['post_type'] )
|| ( 'post.php' === $pagenow && isset( $_GET['post'] ) && $cpt === get_post_type( (int) $_GET['post'] ) );
$is_settings = ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && $page_slug === $_GET['page'] );
if ( $is_cpt ) {
remove_submenu_page( 'woocommerce', $page_slug );
} elseif ( $is_settings ) {
remove_submenu_page( 'woocommerce', 'edit.php?post_type=' . $cpt );
} else {
// Default visible submenu when neither screen is active.
remove_submenu_page( 'woocommerce', $page_slug );
}
}For maximum polish, give both submenus the SAME title in their respective registrations. End users see one submenu link — they don’t notice it conditionally switches between two different pages depending on context.
How the pieces fit together
When a user clicks the single submenu link, WordPress lands them on the currently-visible page (CPT list or settings). The tab bar at the top of that page shows both screens. Clicking a tab navigates to the OTHER screen, which in turn switches which submenu is hidden. Net effect:
- Sidebar: always shows one submenu under WooCommerce
- Top of each screen: tab bar with all related screens
- User mental model: “this plugin lives under WooCommerce, and clicking around uses the tabs”
- Codebase: 5 small functions, no dependencies, fully under your control
Extending the pattern to more screens
The same pattern scales to 4-6+ screens. For each additional screen:
- 1. Register the screen (another
add_submenu_page()call, another CPT, or a custom screen) - 2. Add an entry to the
$tabsarray in your display callback - 3. Add the screen ID to
raj_get_tab_screen_ids() - 4. Add a case to
raj_get_current_tab()mapping the screen ID to the tab key - 5. Add a branch to
raj_dedupe_submenu_link()so the right submenu is visible per screen
Past 6 tabs, consider a dropdown or split the plugin into sub-sections. WordPress’s nav-tab-wrapper visual style starts to wrap or overflow on smaller admin screens with too many tabs.
Styling considerations for WordPress admin tabs
The nav-tab-wrapper class is part of WordPress’s core admin CSS — it inherits the platform’s tab styling automatically. For full visual cohesion:
- Use WooCommerce’s woo-nav-tab-wrapper class when nesting under the WooCommerce parent menu — it picks up WC’s purple branding for tab hover/active states
- Use plain nav-tab-wrapper when nesting under other parents — picks up the default WP blue
- Add a custom class if you want your own brand styling — load a tiny admin CSS file via
admin_enqueue_scripts - Icons inside tabs — use Dashicons (
<span class="dashicons dashicons-list-view"></span>) for visual context
Common mistakes when adding WordPress admin tabs
Patterns that look correct but break in production:
- Wrong hook priority on admin_menu — if the dedupe runs before the submenus are registered, nothing happens. Use priority 100+
- Hardcoded screen IDs that differ between environments — multisite, custom CPT slugs, and translated menu names all change screen IDs. Echo
$screen->idwhile debugging - Forgetting capability checks — if a user has access to the settings page but not the CPT, the tab to the CPT screen will hit a 403. Filter the tabs array by
current_user_can() - Tabs appear on unrelated screens — the screen ID check in your callback must be strict; in_array with the wrong type can match too broadly
- Missing all_admin_notices reset — other plugins also hook into this. Be defensive: only render your tabs when on YOUR screens
- Skipping i18n on tab labels — wrap titles in
__()so multilingual plugins translate correctly - Breaking the back button — when remove_submenu_page hides the page user came from, browser back may behave unexpectedly. Test the user flow end-to-end
Testing the WordPress admin tabs implementation
Manual test checklist before shipping:
- Visit each screen — tab bar renders and the correct tab is highlighted
- Click each tab — navigates to the right URL, tab highlighting updates
- Open the sidebar — only one submenu visible per screen
- Visit screens via direct URL (not via tab click) — tabs still render correctly
- Test as a user with limited capabilities — restricted screens hide / show appropriately
- Test with WPML or Polylang if your site is multi-language — labels translate
- Test on mobile admin view — tabs do not overflow horribly
- Run with multiple plugins also hooking all_admin_notices — no conflicts
Alternative: custom admin pages without using CPT list tables
The pattern above leverages WordPress’s native list table for the CPT. Some plugins prefer to render their own table UI on a custom admin page (using WP_List_Table directly) instead of relying on the CPT edit screen.
- Pro: full control over columns, filters, bulk actions, row actions without fighting CPT defaults
- Pro: simpler tab routing — all screens are custom admin pages with predictable IDs
- Con: have to rebuild the things CPT lists give you free (quick edit, post search, etc.)
- Con: more code, more places things can go wrong
For plugins where the CPT IS the primary data model, stick with CPT list tables (the pattern above). For plugins where you have custom data tables and want admin UI to match, use WP_List_Table on custom admin pages and apply the same tab pattern over those pages instead.
Implementation — FAQs
How do I add WordPress admin tabs to my plugin?
Five steps. (1) Register your custom post type with show_in_menu set to your parent menu slug. (2) Add your settings page with add_submenu_page() under the same parent. (3) Hook into all_admin_notices to render a nav-tab-wrapper at the top of each plugin screen. (4) Build helper functions that return the list of plugin screen IDs and detect the current tab. (5) Use remove_submenu_page() hooked to admin_menu at high priority to hide inactive submenus so users see only one sidebar entry.
Why use all_admin_notices for rendering admin tabs?
This hook fires near the top of every admin page, exactly where WordPress’s default page title (.wrap h1) appears. Rendering tabs there gives them the natural visual position users expect. Alternatives like admin_notices work but place tabs further down. load-{page} hooks fire too late to render inline content. all_admin_notices is the right balance of timing + position.
How do I detect the current admin screen in WordPress?
Use get_current_screen() inside any hook that fires after the screen has been determined (most admin hooks). The returned WP_Screen object has an id property that uniquely identifies each admin page. CPT list tables use edit-{slug}, CPT edit screens use {slug}, settings pages use {parent}_page_{submenu-slug}. Echo $screen->id while debugging to see the exact value for your screens.
Variants — FAQs
Can I use this pattern under a top-level menu instead of WooCommerce?
Yes — replace woocommerce with your own menu slug everywhere it appears. First register your top-level menu with add_menu_page(). Then use that slug as the show_in_menu value for your CPT, the parent slug for add_submenu_page(), and the first argument to remove_submenu_page(). The pattern is parent-menu-agnostic.
How do I add WordPress admin tabs without custom post types?
Same pattern, fewer steps. Register multiple admin pages with add_submenu_page() under one parent. Use the same all_admin_notices callback to render tabs. The screen IDs in get_tab_screen_ids() are all {parent}_page_{submenu-slug} patterns instead of CPT screens. remove_submenu_page() dedupe logic works the same way.
Can I render WordPress admin tabs with JavaScript instead of all_admin_notices?
Possible but not recommended. JavaScript-rendered tabs flicker on page load (DOM not yet built when JS runs), don’t appear if JS is disabled or fails to load, and add unnecessary complexity. PHP rendering via all_admin_notices is more reliable, accessible, and SEO-friendly (though SEO is rarely a concern for admin pages). Use JS only for tab interactions (smooth animations, AJAX page loads) — not for the initial render.
Production concerns — FAQs
How do I make sure WordPress admin tabs work with capability restrictions?
Filter your $tabs array based on current_user_can() for each tab’s destination screen. If a user can’t access a screen, don’t show its tab — clicking it would hit a 403. Also use the same capability in your add_submenu_page() and CPT registration so the underlying pages enforce the check. Defense in depth.
Do WordPress admin tabs work with the block editor or only classic admin?
The pattern works in both contexts because all_admin_notices fires regardless of editor type. The CPT edit screen runs the block editor (or classic editor, if disabled) inside the page; the tab bar renders ABOVE the editor area in either case. No special handling needed for Gutenberg.
How can I add icons to WordPress admin tabs?
Use Dashicons (WordPress’s built-in icon font) inside each tab’s anchor: <a href="..." class="nav-tab"><span class="dashicons dashicons-list-view"></span> Items</a>. The full Dashicons reference is at developer.wordpress.org. For custom SVG icons, inline them in the anchor or use a background-image CSS rule scoped to each tab’s class.

