Tab Navigation in WordPress — Organizing Options Pages

As your theme or plugin grows, the number of settings inevitably increases. Three sections on one page — tolerable. Ten sections — already unreadable. Users are forced to scroll endlessly looking for the right field, losing context and time. The solution familiar to every desktop application user is tab navigation.

WordPress provides ready-made CSS infrastructure for creating tabs in the admin panel. The nav-tab-wrapper and nav-tab classes style navigation exactly like on the Themes or Plugins pages. You only need to add tab switching logic and Settings API option group bindings. In this article, we will implement a full multi-tab settings panel with two tabs, active tab state preservation, and success message display via settings_errors().

Tab navigation is not a WordPress API function but a pattern built on HTML/CSS and query parameters. WordPress only provides CSS classes for styling. All tab switching logic is written manually by you.

Implementation Plan

Our goal is to create an options page with two tabs: Display Options and Social Networks. The first tab is active on page load. After saving settings, the active tab does not reset — the user stays where they were before clicking the Save button. On successful save, a standard WordPress notification is displayed.

Technical plan:

  • Create HTML tab structure with nav-tab-wrapper and nav-tab classes
  • Add a tab query parameter to identify the active tab
  • Check the tab parameter in the options page callback and apply nav-tab-active to the active tab
  • Render the corresponding option group content based on the active tab
  • Add a hidden field to the form so WordPress remembers the active tab on save
  • Call settings_errors() to display success/error messages

HTML Tab Structure

WordPress uses two CSS classes for tab styling: nav-tab-wrapper — a container grouping tabs into a row, and nav-tab — styling for an individual tab. The active tab additionally receives the nav-tab-active class. The HTML structure looks like this:

Sandbox Theme Options

[/codeblock]

Note: tabs are <a> links, not buttons or list items. WordPress styles exactly this structure, and deviating from it will cause tabs to lose the native admin panel appearance.

Determining the Active Tab via Query Parameter

Each tab links to the same options page but with a different tab parameter value: admin.php?page=sandbox-theme-options&tab=display. The page callback checks this parameter and determines which tab to highlight and which option group content to show:

function sandbox_theme_options_page_html() { if ( ! current_user_can( 'manage_options' ) ) { return; } $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'display'; ?>
The sanitize_key() function is critically important for security. The $_GET['tab'] parameter comes from the user and may contain XSS vectors. sanitize_key() keeps only letters, digits, hyphens, and underscores — a safe character subset.

Registering Sections for Different Pages

The attentive reader will notice that do_settings_sections() is called with different page slugs depending on the active tab. This means sections must be registered for different pages. Here is the updated registration function:

function sandbox_register_settings() { register_setting( 'sandbox_options_group', 'sandbox_theme_options', array( 'type' => 'array', 'sanitize_callback' => 'sandbox_sanitize_options', 'default' => array( 'show_header' => 1, 'show_sidebar' => 1, 'show_footer' => 1, 'facebook_url' => '', 'twitter_url' => '', ), ) ); add_settings_section( 'sandbox_display_section', 'Display Options', 'sandbox_display_section_callback', 'sandbox_display_options_page' ); add_settings_section( 'sandbox_social_section', 'Social Network Links', 'sandbox_social_section_callback', 'sandbox_social_options_page' ); } add_action( 'admin_init', 'sandbox_register_settings' );[/codeblock]

The key point: all fields still belong to a single option group (sandbox_options_group) and are stored in a single array (sandbox_theme_options). Separation happens only at the interface level — different sections are registered for different page slugs. When saving, WordPress updates the entire options array regardless of which tab the Save button was clicked on.

Always check that $_GET['tab'] is set before using it. PHP emits an undefined index notice otherwise. The pattern isset($_GET['tab']) ? sanitize_key($_GET['tab']) : 'display' is the safe default.

Preserving Tab State After Form Submission

When the Save button is clicked, WordPress redirects to a URL without the tab query parameter. As a result, the active tab resets to the default — the user always returns to the first tab after saving. This is inconvenient. To fix it, add a hidden field to the form and handle it on redirect:

echo '';[/codeblock]

Now add code to preserve the tab during redirects. WordPress fires the wp_redirect hook when redirecting after settings save. We intercept it and add the tab parameter:

function sandbox_preserve_active_tab( $location ) { if ( isset( $_POST['sandbox_active_tab'] ) ) { $location = add_query_arg( 'tab', sanitize_key( $_POST['sandbox_active_tab'] ), $location ); } return $location; } add_filter( 'wp_redirect', 'sandbox_preserve_active_tab' );[/codeblock]
The wp_redirect filter fires on every admin redirect, not just from your options page. Always check for $_POST['sandbox_active_tab'] before modifying the URL, or you will break redirects for other plugins and standard WordPress pages.

settings_errors() — Success and Error Messages

The settings_errors() function displays standard WordPress notifications about successful saves, validation errors, or warnings. The call must be placed before the tab navigation so the message is always visible:

settings_errors( 'sandbox_theme_options' );[/codeblock]

WordPress automatically adds a "Settings saved." message on successful save via the Settings API. Custom messages are added via add_settings_error() in the sanitization function:

function sandbox_sanitize_options( $input ) { $sanitized = array(); if ( isset( $input['facebook_url'] ) && ! empty( $input['facebook_url'] ) ) { $sanitized['facebook_url'] = esc_url_raw( $input['facebook_url'] ); $parsed = wp_parse_url( $sanitized['facebook_url'] ); if ( ! isset( $parsed['host'] ) || strpos( $parsed['host'], 'facebook.com' ) === false ) { add_settings_error( 'sandbox_theme_options', 'invalid_facebook_url', 'Facebook URL must point to facebook.com.', 'error' ); $sanitized['facebook_url'] = ''; } } return $sanitized; }[/codeblock]
settings_errors() accepts an optional $setting parameter — the option slug for which to display messages. If omitted, all registered messages are displayed. Specify the slug explicitly to prevent messages from other plugins from appearing on your page.

Handling Missing Tab Parameter

On the first visit to the options page, the tab parameter is absent from the URL. Our code must handle this gracefully and show the default tab:

$active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'display';[/codeblock]

If no query parameter is passed, $active_tab gets the value 'display', and the page shows the first tab. This is the standard behavior users expect.

Complete Tab Navigation Code

Here is the complete functions.php with tab navigation, two tabs, active tab state preservation, and settings_errors() messages:

'array', 'sanitize_callback' => 'sandbox_sanitize_options', 'default' => array( 'show_header' => 1, 'show_sidebar' => 1, 'show_footer' => 1, 'facebook_url' => '', 'twitter_url' => '', ), ) ); add_settings_section( 'sandbox_display_section', 'Display Options', 'sandbox_display_section_callback', 'sandbox_display_options_page' ); $display = array( 'show_header' => 'Show Header', 'show_sidebar' => 'Show Sidebar', 'show_footer' => 'Show Footer' ); foreach ( $display as $k => $l ) { add_settings_field( 'sandbox_' . $k, $l, 'sandbox_checkbox_field_callback', 'sandbox_display_options_page', 'sandbox_display_section', array( 'label_for' => 'sandbox_' . $k, 'option' => 'sandbox_theme_options', 'key' => $k ) ); } add_settings_section( 'sandbox_social_section', 'Social Network Links', 'sandbox_social_section_callback', 'sandbox_social_options_page' ); $social = array( 'facebook_url' => 'Facebook URL', 'twitter_url' => 'Twitter URL' ); foreach ( $social as $k => $l ) { add_settings_field( 'sandbox_' . $k, $l, 'sandbox_url_field_callback', 'sandbox_social_options_page', 'sandbox_social_section', array( 'label_for' => 'sandbox_' . $k, 'option' => 'sandbox_theme_options', 'key' => $k ) ); } } add_action( 'admin_init', 'sandbox_register_settings' ); function sandbox_display_section_callback() { echo '

Toggle visibility of page containers.

'; } function sandbox_social_section_callback() { echo '

Enter your social media profile URLs.

'; } function sandbox_checkbox_field_callback( $a ) { $o = get_option( $a['option'], array() ); $c = isset( $o[ $a['key'] ] ) ? (bool) $o[ $a['key'] ] : false; echo ''; } function sandbox_url_field_callback( $a ) { $o = get_option( $a['option'], array() ); $v = isset( $o[ $a['key'] ] ) ? $o[ $a['key'] ] : ''; echo ''; } function sandbox_sanitize_options( $input ) { $out = array(); foreach ( array( 'show_header', 'show_sidebar', 'show_footer' ) as $f ) { $out[ $f ] = isset( $input[ $f ] ) && $input[ $f ] ? 1 : 0; } foreach ( array( 'facebook_url', 'twitter_url' ) as $f ) { $out[ $f ] = isset( $input[ $f ] ) && ! empty( $input[ $f ] ) ? esc_url_raw( $input[ $f ] ) : ''; } return $out; } function sandbox_theme_options_page_html() { if ( ! current_user_can( 'manage_options' ) ) { return; } $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'display'; ?>

'; submit_button( 'Save Settings' ); ?>
Testing Tab Navigation

After adding the code to functions.php, verify the behavior:

  1. Visit Appearance → Theme Options. The Display Options tab should be active
  2. Switch to Social Networks — the URL should include tab=social
  3. Enter a Facebook URL and click Save — the tab should not reset to Display
  4. Enter an invalid URL and save — an error message should appear above the tabs

If the tab resets after saving, check that the wp_redirect filter is added and the sandbox_active_tab hidden field is present in the form. If messages do not appear, check the settings_errors() call and the slug matching the first parameter of add_settings_error().

WordPress Notification Functions Comparison

WordPress provides several functions for displaying admin notices, and understanding the differences is important:

FunctionPurposeContextOutput Location
settings_errors()Settings API messages: save success, validation errorsOptions pageAt the call location on the page
add_settings_error()Registers a message for settings_errors()sanitize_callbackWhere settings_errors() is called
admin_noticesHook for displaying any admin noticesAny admin pageTop of the page
wp_die()Critical error, stops executionAny contextSeparate error page

For options pages, the correct combination is add_settings_error() in the sanitizer and settings_errors() in the page callback. This gives native-looking notifications and correctly handles all states: success, warning, error, and informational messages.

Expanding the Tab System

The current implementation supports two tabs. Adding a third requires:

  • A new element in nav-tab-wrapper: a link with nav-tab class and nav-tab-active condition
  • New sections and fields registered for a new page (e.g., 'sandbox_general_options_page')
  • A new elseif condition in the page callback
  • A new valid value in the allowed tabs whitelist

For more than three tabs, replace the if-elseif chain with an allowed tabs array and in_array() check to prevent errors when users manually change the tab parameter to a non-existent value:

$allowed_tabs = array( 'display', 'social', 'general', 'advanced' ); $active_tab = isset( $_GET['tab'] ) && in_array( $_GET['tab'], $allowed_tabs, true ) ? sanitize_key( $_GET['tab'] ) : 'display';[/codeblock]
Never use the $_GET['tab'] value directly in a function name or include path. This opens the door to Local File Inclusion (LFI) attacks. Always validate via in_array() against a whitelist of allowed values.

Tabs vs Separate Pages: When to Use Which

When should you use tabs versus separate pages with submenus? The choice depends on the number of settings and their logical cohesion:

CriteriaTabsSeparate Submenus
Number of settings10-30 fields across 2-4 tabs30+ fields, each page standalone
Data cohesionAll settings belong to one theme/pluginDifferent modules with independent settings
Usage frequencyUser frequently switches between tabsEach page configured rarely and independently
LearnabilityHigher — all settings on one pageLower — need to find the right submenu
Menu depth1 menu item, no submenus1 item + N submenus (clutters sidebar)

For most themes and medium plugins, tabs are optimal: one entry point via Appearance or a custom item, all settings on one page, instant switching between groups. Separate submenus are justified for large plugins like WooCommerce, where each section (Products, Orders, Shipping) is essentially a separate application.

\u{201c}

Tab navigation is not a technical necessity — it is a matter of respecting the user. Two tabs with five fields each are more convenient than one page with ten fields requiring scrolling. Spend 20 minutes implementing tabs — users will thank you with a silent absence of complaints.

Frequently Asked Questions About Tab Navigation

How do I create tabs in the WordPress admin panel?

Use the HTML structure with CSS classes nav-tab-wrapper (container) and nav-tab (individual tab). The active tab gets the additional nav-tab-active class. Switching is implemented via a tab query parameter checked in the options page callback.

Why does the tab reset after saving settings?

WordPress redirects to a URL without the tab parameter by default. To preserve the tab, add a hidden field named sandbox_active_tab and a wp_redirect filter that appends the tab parameter to the redirect URL.

Is sanitize_key() required for the tab parameter?

Yes. The $_GET['tab'] parameter comes from the user and may contain XSS vectors. sanitize_key() keeps only safe characters: letters, digits, hyphens, and underscores. Without it, you risk an XSS vulnerability.

How does settings_errors() work with tabs?

settings_errors() displays WordPress notifications about settings saves. The call must be before the tab structure so the message is visible on any tab. It accepts an optional $setting parameter to filter messages by option slug.

Can different tabs have different option groups?

No, a single form supports only one option group (one settings_fields() call). However, you can register different sections for different page slugs and call do_settings_sections() with the appropriate slug based on the active tab.

How do I add a third tab?

Add a third <a> element to nav-tab-wrapper with a new tab parameter value, register sections and fields for a new page, add an elseif condition in the callback, and include the new tab value in the allowed values whitelist.

Why use tabs instead of separate pages with submenus?

Tabs do not clutter the sidebar menu, switch instantly, and are intuitive for users. They are ideal for 2-4 groups of related settings within a single theme or plugin.

How do I style tabs to match WordPress design?

Use the native CSS classes nav-tab-wrapper, nav-tab, and nav-tab-active. WordPress automatically applies standard admin panel styles to these classes. No additional styling is required.

Is h2 required for the tab container?

No, WordPress uses h2 on the Themes and Plugins pages, but it is not a requirement. You can use div — nav-tab-wrapper styles work on any block element. However, h2 is semantically more correct for a navigation heading.

How do I prevent LFI attacks through the tab parameter?

Always validate the tab value via in_array() against a whitelist of allowed values before using it in a function name, file path, or page name. Never pass $_GET['tab'] directly to include, require, or file_get_contents.