# Wp Admin Postbox Sortable

> Wire up WordPress postboxes on custom plugin admin pages with collapse, drag sorting, Screen Options visibility, and core persistence. Covers `add_meta_box()`, `do_meta_boxes()`, the `postbox` script, `postboxes.add_postbox_toggles( pageId )`, the `.meta-box-sortables` / `.postbox` / `.hndle` DOM contract, and the two nonce fields plugins forget, `closedpostboxesnonce` and `meta-box-order-nonce`. Use when adding collapsible admin boxes, draggable metabox layouts, or Screen Options show/hide behavior to a custom plugin admin screen; use raw `jquery-ui-sortable` instead for non-postbox repeater rows.

- Skill: `lonsdale201/wp-admin-postbox-sortable` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lonsdale201/wp-admin-postbox-sortable`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/wp-admin-postbox-sortable/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/wp-admin-postbox-sortable

---


# WordPress Admin Postboxes & Sortable

Use this skill when you need the familiar collapsible / draggable / Screen-Options-toggleable boxes that WordPress core uses on post edit and Dashboard, but on **your own admin page** or in **your own draggable list**. The most common failure mode is wiring up the HTML correctly, then watching the box collapse fine but never persist — because the nonce field is missing and the AJAX call silently fails.

## When to use this skill

Trigger when ANY of the following is true:

- The user is calling `add_meta_box()` on a custom screen (not just the regular post edit), or registering a custom screen and wants postbox behavior.
- The user wants drag-and-drop reordering for a list of items in `wp-admin` (option rows, repeater fields, custom panels).
- Code references `postboxes.add_postbox_toggles`, `.meta-box-sortables`, `.postbox`, `.hndle`, `closedpostboxesnonce`, `meta-box-order-nonce`, `closedpostboxes_*`, `metaboxhidden_*`, or `meta-box-order_*`.
- The user says "the collapse/expand works but doesn't save" or "drag works but order doesn't persist" or "Screen Options checkboxes don't appear on my page".

## The four pieces that MUST be in place

The postbox UI looks like a single component but it's actually four things wired together. Miss one and the behavior degrades silently. None of this is automatic on a custom admin page.

| Piece | Owner | Failure mode if missing |
|---|---|---|
| 1. `postbox` script enqueued | Your `admin_enqueue_scripts` callback | No collapse, no drag |
| 2. Correct DOM (`.meta-box-sortables` > `.postbox` > `.hndle`) | Your view template | Sortable doesn't init, collapse classes don't bind |
| 3. The two nonce fields in the form | Your view template | AJAX persistence fails; state is not stored |
| 4. `postboxes.add_postbox_toggles( pageId )` called on DOM ready | Your admin JS | No postbox behavior binds; sortable / collapse / Screen Options handlers never start |

### 1. Enqueue the `postbox` script

`postbox` is registered with `jquery-ui-sortable` and `wp-a11y` as deps (`wp-includes/script-loader.php:1439`), and it lives in the footer. Enqueue it on your screen only — never globally.

```php
add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    if ( 'toplevel_page_myplugin' !== $hook_suffix ) {
        return;
    }

    wp_enqueue_script( 'postbox' );

    // Your bootstrap JS that calls postboxes.add_postbox_toggles().
    wp_enqueue_script(
        'myplugin-admin',
        plugins_url( 'assets/admin.js', MYPLUGIN_FILE ),
        array( 'postbox' ),
        MYPLUGIN_VERSION,
        array( 'in_footer' => true, 'strategy' => 'defer' )
    );
} );
```

### 2. The DOM contract

`wp-admin/js/postbox.js` initializes jQuery UI Sortable with `items: '.postbox'`, `handle: '.hndle'`, and `connectWith: '.meta-box-sortables'`. Your markup MUST follow these class names exactly, even if you style them away.

```php
<div class="wrap">
    <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>

    <form method="post" action="">
        <?php
        // Both nonces MUST be present — see piece 3 below.
        wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
        wp_nonce_field( 'meta-box-order', 'meta-box-order-nonce', false );
        ?>

        <div id="poststuff">
            <div id="post-body" class="metabox-holder columns-2">
                <div id="post-body-content"><!-- main column content --></div>

                <div id="postbox-container-1" class="postbox-container">
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'side', $data_object ); ?>
                </div>

                <div id="postbox-container-2" class="postbox-container">
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'normal', $data_object ); ?>
                    <?php do_meta_boxes( 'toplevel_page_myplugin', 'advanced', $data_object ); ?>
                </div>
            </div>
            <br class="clear">
        </div>
    </form>
</div>
```

`do_meta_boxes()` outputs the `.meta-box-sortables` containers and reads the
user's saved order from `meta-box-order_toplevel_page_myplugin` automatically.
Register every box with this same actual screen ID. The safest registration
point is `load-{$hook_suffix}`, where `get_current_screen()->id` is available;
pass that value to `add_meta_box()`, `do_meta_boxes()`, and the JavaScript.

### 3. The two nonce fields — the most-forgotten step

Without these, `wp_ajax_closed_postboxes()` (`wp-admin/includes/ajax-actions.php:1803`) calls `check_ajax_referer( 'closedpostboxes', 'closedpostboxesnonce' )` and dies. The request fails (usually visible in Network as `-1` / 403), no user meta is written. The collapse toggles visibly, refresh the page, state is gone, and the developer blames "WordPress weirdness".

```php
wp_nonce_field( 'closedpostboxes',  'closedpostboxesnonce',   false );
wp_nonce_field( 'meta-box-order',   'meta-box-order-nonce',   false );
```

These are emitted in core by `wp-admin/edit-form-advanced.php`, `edit-form-comment.php`, `edit-link-form.php`, `nav-menus.php`, and `wp-admin/includes/dashboard.php`. On YOUR custom page, you emit them — the page that contains the `.postbox` elements.

### 4. Initialize on DOM ready

The screen identifier you pass — `pageId` — is what gets sanitize_key'd server-side and used as the user-meta suffix. Pick something stable; never `document.title` or anything localized.

```js
jQuery( function ( $ ) {
    postboxes.add_postbox_toggles( 'toplevel_page_myplugin', {
        pbshow: function ( id ) { /* optional: called when a box opens */ },
        pbhide: function ( id ) { /* optional: called when a box closes */ },
    } );
} );
```

After this call, the user gets click-to-collapse, drag-to-reorder, the move-up /
move-down accessibility buttons, and Screen Options visibility. The three
postbox states below persist to user meta. One/two-column layout is separate
screen-layout state and requires the corresponding Screen Options setup.

In WordPress 7.1, core-generated postboxes also receive a named `role="region"`,
an ID on the `.hndle` heading, and `wp_get_tooltip()` wrappers for move/show-hide
controls. The `postbox` handle depends on `wp-tooltip`. Prefer `do_meta_boxes()`
so these semantics update with core. If you hand-copy postbox markup, you own
the region name, heading/control association, tooltip assets, and future drift.

## Per-user persistence — the three storage keys

Replace `$page` with the sanitize_key'd page id you passed to `add_postbox_toggles()`.

| User meta key | Contents | Set by |
|---|---|---|
| `closedpostboxes_$page` | Array of postbox IDs that are currently collapsed | AJAX `closed-postboxes` action |
| `metaboxhidden_$page` | Array of postbox IDs hidden via Screen Options | Same AJAX action; ALWAYS exempts `submitdiv`, `linksubmitdiv`, `manage-menu`, `create-menu` |
| `meta-box-order_$page` | Map of `context => csv of postbox ids in order` | AJAX `meta-box-order` action |

`do_meta_boxes()` reads `meta-box-order_$page` and re-injects boxes in the user's saved order — you do nothing extra to make ordering "take effect" on next page load.

To reset a user's preferences (e.g. an "Reset layout" button on your page), `delete_user_meta()` on these three keys.

## Screen Options registration (the hide/show checkboxes)

The Screen Options pane can list meta boxes registered against the **actual
current screen ID**. `add_menu_page()`/`add_submenu_page()` creates the screen,
but it does not repair a mismatched ID: a box registered as `myplugin_page`
will not belong to a screen whose ID is `toplevel_page_myplugin`. Register on
`load-{$hook_suffix}` and use `get_current_screen()->id` consistently.

If you want a custom "Layout" or "Show on screen" panel in Screen Options, use `add_screen_option()` from the `load-{$hook_suffix}` hook (covered separately by the WP-List-Table skill since it overlaps).

## jQuery events you can hook

`postbox.js` triggers three events on `document`. Useful for syncing your own UI to the postbox state without polling.

```js
jQuery( document )
    .on( 'postbox-toggled', function ( e, $postbox ) {
        // Fires after open/close. $postbox is a jQuery object.
    } )
    .on( 'postbox-moved', function ( e, $postbox ) {
        // Fires after a drag moves a box between sortable areas.
    } )
    .on( 'postboxes-columnchange', function () {
        // Fires when the user switches 1-column / 2-column layout via Screen Options.
    } );
```

## Pattern: drag-reorderable list without postbox chrome

When the user wants drag-to-reorder for a list of plugin-specific rows (rules, repeater entries) WITHOUT the postbox look, skip `postboxes.*` and use jQuery UI Sortable directly. `jquery-ui-sortable` is registered in core; no enqueue magic needed beyond declaring it as a dep.

```php
wp_enqueue_script(
    'myplugin-rules',
    plugins_url( 'assets/rules.js', MYPLUGIN_FILE ),
    array( 'jquery-ui-sortable', 'wp-a11y' ),
    MYPLUGIN_VERSION,
    array( 'in_footer' => true )
);

wp_localize_script( 'myplugin-rules', 'MyPluginRules', array(
    'restUrl' => esc_url_raw( rest_url( 'myplugin/v1/rules/order' ) ),
    'nonce'   => wp_create_nonce( 'wp_rest' ),
) );
```

```js
jQuery( function ( $ ) {
    $( '#myplugin-rules-list' ).sortable( {
        handle: '.rule-handle',
        placeholder: 'rule-placeholder',
        update: function () {
            const order = $( this ).sortable( 'toArray', { attribute: 'data-rule-id' } );
            wp.apiFetch( {
                url: MyPluginRules.restUrl,
                method: 'POST',
                headers: { 'X-WP-Nonce': MyPluginRules.nonce },
                data: { order },
            } ).then( () => wp.a11y.speak( wp.i18n.__( 'Order saved.', 'myplugin' ) ) );
        },
    } );
} );
```

Two non-obvious bits worth keeping:

- `wp.a11y.speak()` announces drag completion to screen readers. Core does this in `postbox.js` (`save_state` and `save_order`). Match the pattern.
- Use `data-*` attributes on each row plus `toArray({ attribute: 'data-rule-id' })` instead of relying on DOM ids — IDs collide more often than you'd think (especially in repeaters cloned from a template).

## Critical rules

- **Emit both nonces when using core postbox persistence**. If you emit only `closedpostboxesnonce`, drag-and-drop saves fail. If you emit only `meta-box-order-nonce`, collapse state saves fail. They're independent endpoints with independent nonces.
- **Never enqueue `postbox` globally**. It binds click handlers on `.postbox .hndle` and `.handlediv` globally; if another plugin's UI happens to have a `.postbox` element, you'll bind their elements too.
- **The `$page` argument must be sanitize_key-safe**. Core does `sanitize_key( $page )` and `wp_die(0)` if it doesn't match — see `ajax-actions.php:1813`. Use `myplugin_settings`, never `MyPlugin Settings`.
- **Don't write your own AJAX handlers for these**. Core already handles AJAX actions `closed-postboxes` and `meta-box-order` through `wp_ajax_closed_postboxes()` and `wp_ajax_meta_box_order()`. Reusing them is the whole point.
- **Do not reuse reserved core box IDs to force visibility**. Core exempts
  `submitdiv`, `linksubmitdiv`, `manage-menu`, and `create-menu` from its hidden
  list, but colliding with those IDs couples plugin UI to unrelated core
  behavior. Render mandatory controls outside the optional postbox area or
  deliberately own the screen's visibility policy.
- **Postbox JS uses `ajaxurl`**, the global injected by core on admin pages. If you're enqueueing on a screen where `ajaxurl` isn't defined (rare — really only the frontend), localize it yourself.

## Common mistakes

```js
// WRONG — runs before postbox.js loads (when postbox is in_footer, defer, or strategy=defer)
postboxes.add_postbox_toggles( 'toplevel_page_myplugin' );

// RIGHT — wait for DOM ready
jQuery( function () {
    postboxes.add_postbox_toggles( 'toplevel_page_myplugin' );
} );
```

```php
// WRONG — wp_create_nonce returns the nonce string, not a hidden field
echo wp_create_nonce( 'closedpostboxes' );

// RIGHT — wp_nonce_field renders the <input type="hidden" id="closedpostboxesnonce" ...>
//        which is what postbox.js looks for via jQuery('#closedpostboxesnonce').val()
wp_nonce_field( 'closedpostboxes', 'closedpostboxesnonce', false );
```

```php
// WRONG — enqueues on every admin page, binds handlers to other plugins' .postbox elements
add_action( 'admin_enqueue_scripts', static fn() => wp_enqueue_script( 'postbox' ) );

// RIGHT — only on your screen
add_action( 'admin_enqueue_scripts', static function ( string $hook_suffix ): void {
    if ( 'toplevel_page_myplugin' === $hook_suffix ) {
        wp_enqueue_script( 'postbox' );
    }
} );
```

## Cross-references

- See **`wp-plugin-assets-loading`** for the canonical pattern for conditional enqueueing on a specific `$hook_suffix`.
- See **`wp-admin-list-table`** for Screen Options + per-user pagination preferences (overlapping user-meta storage pattern).
- See **`wp-admin-form-controls`** when the metabox body itself contains color pickers, date pickers, or `wp.codeEditor` instances.

## What this skill does NOT cover

- The block-editor metaboxes (`__back_compat_meta_box`) compatibility layer. The whole "Gutenberg shoves metaboxes into a iframe" rabbit hole is its own topic.
- Saving the content of a metabox on post save (`save_post` hook, nonce + capability check). Standard CPT scaffolding territory.
- `wp.media` frame triggered from inside a metabox body — covered by `wp-admin-media-frame`.

## References

- `wp-admin/js/postbox.js` — the canonical client-side implementation; read it before guessing behavior.
- `wp-admin/includes/ajax-actions.php` — `wp_ajax_closed_postboxes()` at line 1803 and `wp_ajax_meta_box_order()` at line 1988.
- `wp-admin/includes/template.php` — `add_meta_box()` at line 1080, `do_meta_boxes()` at line 1304.
- `wp-includes/script-loader.php:1439` — the `postbox` script registration with its dep array.
- Official documentation: <https://developer.wordpress.org/reference/functions/add_meta_box/>
- Official documentation: <https://developer.wordpress.org/reference/functions/do_meta_boxes/>
- Official documentation: <https://developer.wordpress.org/reference/functions/wp_nonce_field/>
- Official documentation: <https://api.jqueryui.com/sortable/>

