Skip to content

Refine the admin menu view transition animation - #11912

Open
westonruter wants to merge 22 commits into
WordPress:trunkfrom
westonruter:trac-65032-view-transition-improvements
Open

westonruter wants to merge 22 commits into
WordPress:trunkfrom
westonruter:trac-65032-view-transition-improvements

Conversation

@westonruter

@westonruter westonruter commented May 20, 2026 •

Copy link
Copy Markdown
Member

When view transitions were enabled throughout wp-admin in 7.0, each top-level admin menu item was given a stable view-transition-name, so navigating between sections animates them into place (the "accordion" effect). Several rough edges remained:

  • The accordion failed intermittently. A cross-document view transition snapshots the incoming page at its first render, which can occur before the <body> has finished parsing. The snapshot then captures a half-built admin menu, so the menu items have no height delta and the accordion silently degrades to a plain cross-fade. It reproduces most easily with DevTools closed; pagereveal instrumentation confirmed the snapshot being taken at readyState: "loading" with an incomplete menu.
  • A newly current section's submenu was captured at its full intrinsic height, so it painted at full height from the first frame and overlapped the menu items below while they were still sliding down into place.
  • The collapse button (#collapse-menu) was not named, so it cross-faded as part of the page root instead of moving with the rest of the menu.
  • The View Transitions style was enqueued at admin_enqueue_scripts, which iframe_header() also fires, so iframe screens such as Thickbox modals loaded it even though they have no admin menu.

A side-by-side video of trunk and this PR is in #11912 (comment), with narration and details on how it was made in #11912 (comment).

Changes

Render blocking until the admin menu is parsed

  • A new wp_print_view_transitions_render_blocking_link() function, hooked to admin_head, prints <link rel="expect" href="#wpcontent" blocking="render" media="(prefers-reduced-motion: no-preference)">. The first render, and therefore the transition's snapshot, waits until the admin menu has been parsed, so the accordion runs reliably.
  • The target is #wpcontent, the element right after the admin menu, because rendering is unblocked as soon as the target's start tag is parsed. Targeting an element near the end of the page (such as #wpfooter) would also work, but it would delay the first render by the time it takes to parse all of the page content.
  • The media query limits the delay to when view transitions can run, matching the @view-transition rule.
  • The link is printed only when the wp-view-transitions-admin style has already been printed (wp_style_is( …, 'done' )). An expect link whose target never appears blocks the first render until the whole document is parsed, so it must not be printed on screens without #wpcontent.

Opt-in enqueue from the admin header

  • wp_enqueue_view_transitions_admin_css() is now called from wp-admin/admin-header.php instead of being hooked to admin_enqueue_scripts. Only screens that print the admin menu opt in, and iframe screens (which use iframe_header()) no longer get the style or the expect link.
  • This changes how a plugin turns off admin View Transitions; see Backward compatibility below.

Menu animation (view-transitions.css)

  • A shared view-transition-class (wp-menu-top) is added to every top-level menu item, and overflow: clip is set on ::view-transition-image-pair(.wp-menu-top). Each menu item's snapshot is clipped to its animating group box, so an expanding submenu wipes open from the link row to full height, and wipes closed in reverse, in step with the items below it instead of overlapping them.
  • #collapse-menu gets its own view-transition-name, so it slides into place with the menu items instead of cross-fading with the page root.
  • An open hover or focus flyout extends outside its menu item's box, so the clip above would cut it off. Open flyouts are captured as their own groups with view-transition-name: match-element, so they fade out intact, and view-transition-group: nearest nests each one inside its menu item's group so it moves with the item while fading. This covers the expanded menu, the folded menu, and the auto-folded menu at 783–960px. Below 783px the menu is responsive and its submenus are in the normal flow, so none are named there. Browsers without nested view transition groups fade the flyout out in place.

Other

  • wp_get_view_transitions_admin_css() casts the result of file_get_contents() to a string, to match its return type.
  • Unit tests cover wp_print_view_transitions_render_blocking_link(): its hook, the printed link, and that nothing is printed when the style is not enqueued or not yet printed.

Backward compatibility

In 7.0, a plugin could turn off admin View Transitions by unhooking the enqueue function:

remove_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' );

With this PR, that function is no longer hooked to admin_enqueue_scripts. It is called directly from wp-admin/admin-header.php, so the remove_action() call does nothing and View Transitions stay on.

Instead, a plugin should dequeue the style:

add_action(
	'admin_enqueue_scripts',
	static function () {
		wp_dequeue_style( 'wp-view-transitions-admin' );
	}
);

This works in both 7.0 and 7.2:

  • 7.0: core's enqueue is hooked to admin_enqueue_scripts at the default priority before any plugin loads, so the plugin's callback at the same priority runs after it and dequeues the style.
  • 7.2: admin-header.php enqueues the style before it fires admin_enqueue_scripts, so the dequeue again comes after the enqueue.

Nothing else is needed to turn off the expect link. It is only printed when the wp-view-transitions-admin style has been printed, so dequeuing the style also removes the link. A plugin does not need to unhook wp_print_view_transitions_render_blocking_link.

This recommendation is documented in the docblock for wp_enqueue_view_transitions_admin_css(), and the change should also be mentioned in the 7.2 dev note.

Testing instructions

  1. In Chrome, with the OS "reduced motion" setting off, log in to wp-admin.
  2. Navigate repeatedly between sections whose submenus differ, such as Tools ↔ Settings, including with DevTools closed. The accordion (the previous submenu wiping closed, the new one wiping open) should play on every navigation, never degrading to a plain cross-fade.
  3. Confirm the expanding submenu wipes open in step with the surrounding menu items, rather than briefly overlapping the items beneath it.
  4. Confirm the "Collapse menu" button slides with the menu rather than fading.
  5. Hover over a menu item to open its flyout, such as Appearance, and click one of its links. The flyout should fade out while moving with its menu item, not be cut off or stay behind.
  6. Repeat step 5 with the menu folded, and in RTL.
  7. Open an iframe screen, such as Plugins → Add New → "More Details" on a plugin. Confirm its source has no wp-view-transitions-admin style and no rel="expect" link.
  8. Enable "reduced motion" in the OS settings and confirm no transitions run.

Trac ticket: https://core.trac.wordpress.org/ticket/65032

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.7, Claude Opus 5.5
Used for: Authoring the initial code and PR description (Opus 4.7). Code review, the follow-up fixes (the #wpcontent target, the opt-in enqueue, the done guard, and flyout handling), unit tests, testing with Chrome DevTools (LTR, RTL, keyboard, folded and auto-folded menus, mobile, reduced motion, and a simulated slow server), the side-by-side video, and updating this description (Opus 5.5). All changes were reviewed and directed by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

🤖 Generated with Claude Code

Clip each top-level menu item's view transition snapshot to its
animating group box. By default a captured snapshot keeps its full
intrinsic height, so a newly expanded submenu painted at full height
from the first frame and overlapped the menu items below while they
were still sliding into place. Setting `overflow: clip` on the image
pair makes the submenu wipe open, and closed in reverse, in step with
those items.

Also name the collapse button so it slides into place with the menu
items rather than cross-fading via the page root, and add comments
explaining each rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented May 20, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, mukesh27, b1ink0.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.


Props Bot commands

You can trigger Props Bot actions by commenting on this PR:

  • @propsbot add <username> to give props to a GitHub user. Requires write access.
  • @propsbot remove <username> to leave a user out of the props. Requires write access.
  • @propsbot refresh to refresh this list. Adding the props-bot label works too.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@westonruter

Copy link
Copy Markdown
Member Author

In the following videos, I have a plugin active which slows down the animations to make them more observable:

add_action(
	'admin_print_styles',
	function () {
		?>
		<style>
			/* TEMP DEBUG: slow every view transition animation down to 2s. Remove before shipping. */
			::view-transition-group(*),
			::view-transition-old(*),
			::view-transition-new(*) {
				animation-duration: 1s;
			}
		</style>
		<?php
	}
);

Before:

Screen.Recording.2026-05-20.at.16.26.37.mov

After:

Screen.Recording.2026-05-20.at.16.28.04.mov

A cross-document view transition snapshots the incoming page at its
first render, which can occur before the body has finished parsing.
When that happens the admin menu is only partially built in the
snapshot, so the menu-top elements have no height delta and the
section accordion silently degrades to a plain cross-fade.

Print a render-blocking `<link rel="expect">` pointed at the end of
the body, so the first render — and therefore the transition's
snapshot — waits until the page is fully parsed. The media query
scopes the small first-render delay to when view transitions run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 From Claude Code — paired with @westonruter on this branch. Posting this to document how the root cause was found, since the render-blocking fix isn't obvious without the backstory.

Diagnosing the intermittent accordion failure

Symptom. Navigating between admin sections, the menu "accordion" (one section's submenu collapsing as the next expands) usually played, but intermittently degraded to a plain cross-fade with no movement. It was hard to reproduce — and, crucially, it happened more often with DevTools closed.

Ruled out along the way:

  • Scroll position. The admin menu scrolls with the page when it is taller than the viewport, so a scroll delta between two loads can translate the whole menu. This was confirmed as a real mechanism via a controlled same-document transition — but the bug also occurs with a fixed, top-aligned menu, so it was not the cause here.
  • Overlapping navigations. Clicking again before the previous (~0.25s) transition finished could interrupt it. Instrumentation ruled this out: the failing navigations were clean push navigations with priorTransitionStillRunning: false.

Instrumentation. A temporary pagereveal / pageswap logger recorded, for every navigation, each menu item's captured old/new view-transition snapshot heights plus context (readyState, navigation type, whether a prior transition was still running). Because the bug reproduced best with DevTools closed, each failing navigation was persisted to localStorage so it could be captured without DevTools open and inspected afterward.

Root cause. Every captured failure showed the same two things: readyState: "loading", and an incomplete admin menu — e.g. only 7 of the 11 .menu-top items existed, or none at all.

A cross-document view transition snapshots the incoming page at its first render, and the browser paints incrementally — so first paint (and therefore the snapshot) can happen before the <body> has finished parsing. When it does, the admin menu is only half-built in the snapshot; the items that should change height are not there yet, so there is no geometry delta to animate and the transition falls back to a plain cross-fade. "More often with DevTools closed" fits exactly: a faster load lets first paint beat the body parse.

Fix. A render-blocking expectation in the admin <head>:

<link rel="expect" href="#wpfooter" blocking="render" media="(prefers-reduced-motion: no-preference)">

This holds the first render — and so the view transition's snapshot — until #wpfooter (the end of the body) has parsed, guaranteeing the snapshot captures a fully-built page. The media query confines the small first-render delay to when view transitions actually run.

The two CSS refinements in the first commit — clipping each menu item's snapshot so a submenu wipes open/closed instead of overlapping the items below, and naming #collapse-menu so it moves with the menu — were found and fixed during the same investigation.

…hould return string but returns string|false.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refines wp-admin view-transition behavior so the admin menu accordion animation is captured after the page is fully parsed, clips submenu snapshots during expansion/collapse, and includes the collapse button in the menu transition.

Changes:

  • Adds a render-blocking rel="expect" link in admin head for complete-page transition snapshots.
  • Adds CSS transition grouping/clipping for top-level admin menu items.
  • Names the collapse menu item for independent view-transition animation.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/wp-includes/view-transitions.php Adds the render-blocking link printer and hardens CSS file reading to return a string.
src/wp-includes/default-filters.php Hooks the new render-blocking link output into admin_head.
src/wp-admin/css/view-transitions.css Adds menu item transition classes, collapse button naming, and snapshot clipping.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-includes/view-transitions.php Outdated
@westonruter

Copy link
Copy Markdown
Member Author

@mukeshpanchal27 How's this look to you?

westonruter and others added 2 commits June 2, 2026 13:56
…sition-improvements

* origin/trunk: (53 commits)
  REST API: Allow-list ability schema response keywords
  Plugins: Fix plugin card width calculation on the Add Plugins screen.
  Build/Test Tools: Remove unused reusable workflow.
  Editor: add responsive global styles for blocks.
  Build/Test Tools: Configure Composer caching with input where possible.
  Twenty Eleven: Add missing documentation for some global variables.
  Abilities API: Add coverage for top-level `required` in input validation
  Abilities API: Add coverage for ignored schema `validate_callback`/`sanitize_callback`
  HTML API: Fixes for issues discovered while fuzzing.
  Twenty Ten: Add missing documentation for some global variables.
  Privacy: Correct type of `WP_User_Request::$user_id` from `int` to `string`/`numeric-string`.
  Charset: Add missing `return` statement to `_mb_ord()`.
  Docs: Add missing @SInCE tags to WP_REST_Icons_Controller.
  Docs: Add missing @SInCE tags to `WP_Icons_Registry`.
  KSES: Decode style attribute before sending to safecss_filter_attr().
  Twenty Sixteen: Correct display of italics in citations.
  Docs: Correct some typos in JS inline comments and DocBlocks.
  Docs: Remove duplicate documentation for the `rest_block_hooks_post_types` hook.
  Docs: Fix typo in `WP_Query` post-loading comment.
  Media: Re-introduce client-side media processing feature.
  ...
…link()`.

Covers that the function is hooked at `admin_head` and that it prints a
single render-blocking `<link rel="expect">` tag with the expected
attributes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@mukeshpanchal27 mukeshpanchal27 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @westonruter, for working on this. Overall, it looks solid to me.

I did notice one issue: when the menu is collapsed and a menu item is clicked, the menu seems to lose its original collapsed state. Can you check whether you can reproduce that?

Screen recording:

Screen.Recording.2026-06-04.at.5.40.03.PM.mov

@westonruter

Copy link
Copy Markdown
Member Author

@mukeshpanchal27 Hummm. I was able to reproduce this once, but then I couldn't reproduce it anymore. Is this reliably reproducible?

@mukeshpanchal27

Copy link
Copy Markdown
Member

@mukeshpanchal27 Hummm. I was able to reproduce this once, but then I couldn't reproduce it anymore. Is this reliably reproducible?

Yes. In Test this pull request with WordPress Playground instance.

Comment thread src/wp-includes/view-transitions.php Outdated
@westonruter

Copy link
Copy Markdown
Member Author

@b1ink0 Given your experience with View Transitions, could I get your help to review this and identify/fix anything that is amiss? I'm somewhat stuck.

@b1ink0

b1ink0 commented Sep 8, 2026

Copy link
Copy Markdown

Thanks, @westonruter, for working on this. Overall, it looks solid to me.

I did notice one issue: when the menu is collapsed and a menu item is clicked, the menu seems to lose its original collapsed state. Can you check whether you can reproduce that?

I was able to reproduce this. It looks like a Playground-specific cookie sync issue rather than an issue with this change.

WordPress stores the collapsed state in the wp-settings-{user_id} cookie (mfold=f). In Playground the browser has the updated value, but PHP receives a stale value on the next request, so the collapsed state is lost. There’s a related Playground cookie-store issue WordPress/wordpress-playground#3061.

Also, admin View Transitions are currently intentionally disabled in Playground on Chromium due to a browser crash workaround WordPress/wordpress-playground#3886.

cc: @westonruter

@westonruter

Copy link
Copy Markdown
Member Author

@b1ink0 Thanks so much for this investigation. So other than this known defect in Playground, this gets your ✅ of approval? I'll move to commit if so.

Comment thread src/wp-includes/view-transitions.php Outdated
* @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect
*/
function wp_print_view_transitions_render_blocking_link(): void {
echo '<link rel="expect" href="#wpfooter" blocking="render" media="(prefers-reduced-motion: no-preference)">' . "\n";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use #adminmenu. I tested it and didn’t see any issues, but could you please check whether you find any problems or think this is too early in parsing to use?

Else everything looks good to me.

Suggested change
echo '<link rel="expect" href="#wpfooter" blocking="render" media="(prefers-reduced-motion: no-preference)">' . "\n";
echo '<link rel="expect" href="#adminmenu" blocking="render" media="(prefers-reduced-motion: no-preference)">' . "\n";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay. You're right that #wpfooter is bad because it would block rendering the whole page. But #adminmenu is too early in the page. In 326d29e a middle-ground was picked: #wpcontent. Since this opens right after the admin menu and before the footer:

image

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

A code review of this branch was run, and its findings about the render-blocking expect link were then measured in Chrome DevTools. The findings overlap with @b1ink0's suggestion to use #adminmenu. The measurements back moving the target off #wpfooter, but they point to #wpcontent rather than #adminmenu.

Cost of the #wpfooter target

Setup: edit.php with 109 posts on one page, 4× CPU throttling, and the median of 7 loads per variant. A temporary mu-plugin switched the expect target with a query parameter.

expect target FCP Added delay
none 252 ms —
#adminmenu 252 ms 0
#wpcontent 252 ms 0
#wpbody 252 ms 0
#wpfooter (current) 336 ms +84 ms

The local server sends the whole response at once, so these numbers measure parse time only. When a server streams a slow page, #wpfooter would also hold first paint while the server generates everything above the footer, so the real cost may be higher.

Chrome unblocks at the target's start tag

A test page was set up with a target <ul> that contains a parser-blocking script taking 1.5 s:

expect target FCP
The <ul> itself 44 ms (the script inside it finished at 1524 ms)
An element after the <ul> 1540 ms
A missing id 1540 ms (blocked until parsing ended)

The block is lifted once the target's start tag is parsed, not once its children are. So <ul id="adminmenu"> doesn't guarantee that the menu items or #collapse-menu are parsed at first render. It works in practice only because the menu markup is small enough to be parsed in one pass. <div id="wpcontent"> opens right after the menu's closing </ul></div></div> in admin-header.php, so its start tag means the whole menu is parsed, at the same cost as #adminmenu.

Other confirmed findings

  • The link is also printed on screens that never output the target. admin_head fires from iframe_header() and wp_iframe() too. On those screens the target never appears, so first paint is blocked until parsing ends, footer scripts included:

    • media-upload.php: 216 → 500 ms (+284 ms)
    • plugin-information modal: 516 → 776 ms (+260 ms)

    These screens have no #adminmenu or #wpcontent either, so changing the target doesn't fix this. An early return when IFRAME_REQUEST is defined would cover most of them (media-upload.php, update.php, plugin-information), but not every iframe_header() caller. The single-site theme-information screen, for example, doesn't appear to define it. The font-library and options-connectors screens do output #wpfooter and aren't affected.

  • The link doesn't depend on the view-transitions style being enqueued. If a plugin dequeues wp-view-transitions-admin or removes wp_enqueue_view_transitions_admin_css, every admin screen still delays first paint, with no transition to show for it. admin_enqueue_scripts runs before admin_head, so a wp_style_is( 'wp-view-transitions-admin' ) check would keep the two in sync.

Proposed changes

  • Change the target to href="#wpcontent", and update the docblock to say the target is the element right after the admin menu.
  • Return early when the style isn't enqueued or IFRAME_REQUEST is defined.
  • Add tests for both early returns.

Not yet verified

These review findings haven't been checked yet:

  • The (prefers-reduced-motion: no-preference) media query is duplicated in PHP and CSS.
  • overflow: clip on ::view-transition-image-pair(.wp-menu-top) may cut off the current-item arrow or focus rings during the transition.
  • The collapse-menu transition name could clash with a plugin menu-top whose id is also collapse-menu. A prefixed name like wp-collapse-menu would avoid that.
  • The (string) file_get_contents() cast turns a missing CSS file into an empty style, while the render-blocking link is still printed.

Waiting for `#wpfooter` held the first render until all of the page
content had been parsed, which on an `edit.php` list table with 109
posts delayed FCP by about 84 ms at 4x CPU throttling, and more when
the server streams a slow page.

Chrome unblocks rendering once the target's start tag is parsed, not
its children, so `#adminmenu` would not guarantee that the menu items
are parsed. `#wpcontent` opens right after the menu closes, so its
start tag means the whole menu has been parsed, and it adds no
measurable delay compared with having no expect link at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
westonruter and others added 7 commits October 22, 2026 20:02
The link was printed on every admin screen regardless of whether the
`wp-view-transitions-admin` style was enqueued. When a plugin dequeues
the style or unhooks `wp_enqueue_view_transitions_admin_css()`, the
first render was still delayed with no transition to show for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`admin_head` also fires from `iframe_header()` and `wp_iframe()`, whose
screens have no admin menu. An expect link whose target never appears
blocks the first render until the whole document has been parsed,
footer scripts included, which delayed FCP by about 280 ms on
`media-upload.php` and 260 ms on the plugin-information modal at 4x CPU
throttling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hooking `wp_enqueue_view_transitions_admin_css()` to
`admin_enqueue_scripts` also enqueued the style on iframe screens, since
`iframe_header()` and `wp_iframe()` fire that action too, even though
those screens have no admin menu to animate. Enqueuing it from
admin-header.php, next to `colors`, makes it opt-in for the screens that
print the menu, including iframe screens that do not define
`IFRAME_REQUEST`, such as the theme-information modal.

To disable admin View Transitions, a plugin should now dequeue the
`wp-view-transitions-admin` style rather than unhook the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checking that the `wp-view-transitions-admin` style is `done` rather
than merely enqueued ties the link to the CSS actually having been
output in the head. If the style is enqueued too late, it is printed in
the footer, and there is no transition at first render to wait for.

Since the style is now only enqueued from admin-header.php, this check
also leaves out iframe screens, so the `IFRAME_REQUEST` check is
removed. It missed iframe screens that do not define that constant,
such as the theme-information modal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docblock said the media query limits the first-render delay to when
view transitions actually run, but `prefers-reduced-motion` only reflects
a motion preference. A reload or a direct visit is also held until
`#wpcontent` is parsed, which is negligible since that element starts
right after the admin menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… expect link.

iframe_header() fires the same enqueue and head actions as admin-header.php,
so this guards against the enqueue being hooked to one of those actions
again, which would print an expect link whose target never appears on
iframe screens. The test fails when the `admin_enqueue_scripts` hook is
restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter

westonruter commented Oct 23, 2026 •

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Follow-up to the earlier comment. Its proposals have been pushed, except that the IFRAME_REQUEST check was replaced by an opt-in approach.

What changed

  • 326d29e changes the expect target from #wpfooter to #wpcontent. The block is lifted once the target's start tag is parsed, and <div id="wpcontent"> opens right after the admin menu closes. So the whole menu is parsed at first render, with no measurable delay over having no link at all.
  • 2ba00cf calls wp_enqueue_view_transitions_admin_css() from admin-header.php, next to colors, instead of hooking it to admin_enqueue_scripts. iframe_header() and wp_iframe() also fire admin_enqueue_scripts, so the hook enqueued the style on Thickbox and other iframe screens, which have no admin menu. Enqueuing it from admin-header.php means only screens that print the menu get it. That includes iframe screens that don't define IFRAME_REQUEST, such as the single-site theme-information modal, which the IFRAME_REQUEST check in 7f3b82e missed.
  • a8d146e makes wp_print_view_transitions_render_blocking_link() return early unless wp_style_is( 'wp-view-transitions-admin', 'done' ), and removes the IFRAME_REQUEST check. The link now depends on the CSS actually having been printed in the head. Because the style is no longer enqueued on iframe screens, this one check also leaves them out.
  • 426336b clarifies in the docblock that the prefers-reduced-motion media query doesn't detect whether a transition is in progress. A reload or a direct visit is also held until #wpcontent is parsed, which is negligible.
  • 3e992c6 adds a test that renders iframe_header() and asserts that neither the style nor an expect link is printed. The test fails if the admin_enqueue_scripts hook is restored.

Verified in the browser: edit.php and font-library.php get the style and href="#wpcontent", while the media-upload, plugin-information and theme-information screens get neither.

Compatibility: how to opt out now

remove_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) no longer disables admin View Transitions. A search of the plugin directory found most plugins that disable them already dequeue or deregister the wp-view-transitions-admin handle, which keeps working, and the expect link follows along. Only two plugins unhook the function. One of them, The Off Switch (100 installs), relies on that alone. A dequeue works both in 7.0 and after this change:

add_action(
	'admin_enqueue_scripts',
	static function () {
		wp_dequeue_style( 'wp-view-transitions-admin' );
	},
	11
);

That was verified locally to remove both the style and the expect link. The function's docblock now recommends dequeuing, and the dev note should mention it too.

Still open

  • overflow: clip on ::view-transition-image-pair(.wp-menu-top) may cut off a flyout submenu that is open on hover when a navigation starts. This hasn't been verified yet.
  • wp_get_view_transitions_admin_css() reads the CSS file whenever wp_default_styles runs, front end included. This has been the case since 7.0 and could be fixed in a follow-up by adding the inline style when the style is enqueued.

westonruter and others added 2 commits October 22, 2026 21:49
The `overflow: clip` on each menu-top's image pair also clipped a flyout
submenu that was open on hover or focus when navigating, since the
flyout extends outside its menu-top box. Clicking a flyout link, a
common way to navigate the admin, made the flyout vanish on the first
frame of the transition.

Give an open flyout of a non-current item its own view transition name,
so it is captured outside the clipped menu-top and fades out. The
current item's submenu is in the normal flow and stays with its
menu-top. `match-element` generates a unique name for each flyout, so
several open at once cannot collide and abort the transition. Browsers
without `match-element` ignore the declaration and keep the clipped
behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the menu is folded, either explicitly or automatically between 783
and 960 pixels wide, every submenu is a flyout, including the current
item's, so its flyout was still cut off by the menu-top clip. Name those
flyouts too.

Limit all of the flyout rules to 783 pixels and wider. Below that the
menu is responsive and its submenus are in the normal flow, so naming
one would pull it out of its clipped menu-top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

The flyout issue listed as still open in the previous comment was confirmed and is now fixed.

The problem

To test it, a transition was frozen at 40 ms of its 250 ms by pausing the view transition animations from a pagereveal listener. The test hovered a menu item that isn't the current one, then clicked a link in its flyout. Because the flyout extends outside its li, overflow: clip on ::view-transition-image-pair(.wp-menu-top) cut it off: it was gone on the first frame instead of fading out. Removing the clip isn't an option, because the overlap comes back: the collapsing and expanding submenus are drawn over the items around them.

The fix

  • ad67e1e gives an open flyout its own view-transition-name, so it is captured outside the clipped menu item and fades out. The name is match-element, so each flyout gets a unique name and two flyouts open at once can't collide and abort the transition. Browsers without match-element ignore the declaration and keep the clipped behavior. In the expanded menu, the current item's submenu is in normal flow, so it isn't named and still animates inside its clipped menu item.
  • cf51179 covers the folded menu, both when folded explicitly and when it folds automatically at 783–960 px. There, every submenu is a flyout, including the current item's, which was otherwise still clipped. All flyout rules are limited to 783 px and wider, since below that the menu is responsive and its submenus are in normal flow.

Testing

Tested in Chrome 156, in both LTR and forced RTL, by freezing transitions and checking the old page's state in pageswap:

  • Expanded menu: clicking a link in a hover flyout, pressing Enter with keyboard focus on a top-level link, and pressing Enter with focus on a flyout link all fade the flyout out, and the transition finishes.
  • Expanded menu, current item: clicking a link in the current item's submenu adds .opensub to it, but the submenu isn't named and stays inside its clipped menu item.
  • Three flyouts open at once: each gets its own name, and the transition runs.
  • Folded menu: the flyouts of both non-current items and the current item fade out.
  • Auto-folded at 900 px: the current item's flyout fades out.
  • Mobile layout at 600 px: nothing is named, even with keyboard focus, and the transition finishes.
  • Reduced motion: there is no transition, and the expect link's media query doesn't match.
  • First paint on edit.php at 4× CPU throttling: FCP is 232 ms both with the #wpcontent expect link and without it.

The view-transitions PHPUnit group passes on single site and multisite.

The only item still open is reading the CSS file on every request, which is being left for a follow-up.

Giving an open flyout its own view transition name kept it from being
clipped, but as an independent group that exists only on the outgoing
page it faded out in place, staying behind while its menu item moved to
its new position. Before the flyout was named, it was part of its menu
item's snapshot and moved along with it.

Nest the flyout's group inside its menu item's group with
`view-transition-group: nearest`, so it moves with the menu item again
while fading out. The nested group is a sibling of the menu item's
clipped image pair, so it is still not cut off. Browsers without nested
view transition groups ignore the declaration and fade it out in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

Here's a side-by-side video showing before and after (props Claude), with the animations slowed down so you can more easily compare. Even so, downloading the video and scrubbing the it back-and-forth at the animation points (e.g. in Quicktime) may be helpful to see what is going on:

side-by-side-expanded.mp4

Take note especially the animations of the elements below the active menu item when navigating. Before there were strange overlaps going on. After, it just feels more “right”.

@westonruter

Copy link
Copy Markdown
Member Author

🤖 Comment by Claude Opus 5.5

Follow-up to the flyout comment and the side-by-side video.

New since the last update: flyouts move with their menu item

6eeb4ed adds view-transition-group: nearest to the open-flyout rules.

With ad67e1e, an open flyout got its own view-transition-name, so the menu item's overflow: clip would no longer cut it off. But that made it an independent group that exists only on the outgoing page, so it faded out where it was while its menu item slid to its new position. On trunk, the flyout is part of the menu item's snapshot, so it moves along with it.

view-transition-group: nearest nests the flyout's group inside its menu item's group. The flyout now follows the item's movement again while fading out. The nested group is a sibling of the menu item's clipped ::view-transition-image-pair, so the clip still doesn't cut it off.

Nested view transition groups shipped in Chrome 140. Browsers without them ignore the declaration, and the flyout fades out in place, as it did before this commit.

To verify it, transitions were frozen at 400 ms of a 1 s animation:

  • Expanded menu, Updates → Appearance, where the item moves up 69 px: the flyout's top edge matched the item partway through its move. All four links were visible, extending below the item's box.
  • Folded menu, Appearance → Fonts: the current item's flyout faded out lined up with its item, with no errors.

Narration for the video

The left half is trunk and the right half is this PR. Animations are slowed to 1 s. Each navigation is clicked from a hover flyout unless marked "top level", and the flyout opens 0.5 s before each click.

Time Navigation What to watch
0:01 Dashboard → Tools › Site Health Dashboard collapses as Tools expands. On trunk, Tools' 9-item submenu is drawn at full height from the first frame, over Settings and Collapse Menu. On the PR, it wipes open as the items below slide down.
0:03.5 → Settings › Reading The two largest submenus, side by side: Tools collapses while Settings expands right below it. On trunk they overlap. On the PR, one wipes closed while the other wipes open.
0:06 → Tools › Export The same pair in reverse.
0:08.5 → Settings › Permalinks The same pair again.
0:11 → Dashboard (top level) A long jump up: every item between Dashboard and Settings moves. On trunk, Dashboard's "Home" lands on "Posts" while ghosted Settings items linger below. On the PR, the menu stays clean and Collapse Menu slides up with the items instead of cross-fading.
0:13.5 → Settings › General A long jump down. On trunk, two "Collapse Menu" labels and ghost Settings items overlap the flyout. On the PR, the flyout fades out while moving with Settings.
0:16 → Appearance › Themes Settings collapses and Appearance expands.
0:18.5 → Tools › Import Appearance collapses and Tools expands.
0:21 → Dashboard › Updates Another long jump up, from Tools to Dashboard.
0:23.5 → Appearance › Fonts Dashboard collapses and Appearance expands.
0:26 → Settings (top level) Appearance collapses and Settings expands.

The flyouts in both halves move with their menu item, so that part looks the same. What differs is the area below the menu item that becomes current: on trunk, collapsing and expanding submenus overlap there.

How the video was made

  • Environment: the local wordpress-develop environment in Chrome 156, with a 965 × 1055 CSS px viewport at 2× density and the menu expanded. The window is just above the 960 px width where the menu folds automatically. The slow-animation snippet set every view transition animation to 1 s.
  • Recording: each run was captured with the Chrome DevTools screencast, first on trunk and then on this branch, without changing anything else.
  • Scripted tour: a temporary mu-plugin, not part of this PR, ran the same 11 navigations on a fixed clock. Click n fired exactly 2 s + n × 2.5 s after the tour started, however long each page took to load. A log recorded when each click actually fired; every one in both runs was within 3 ms of its scheduled time.
  • Flyouts: a flyout was opened by adding the opensub class to its menu item, which is what core's hover handler does, rather than by moving a real cursor. So no cursor appears, and the top-level item doesn't get its :hover highlight.
  • Synchronization: a screencast only captures a frame when the page changes, so its timestamps can't be relied on. A small colored square in the corner switched color the instant each click fired. With ffmpeg, each recording was converted to a constant 30 fps, cut at the frame where each color first appeared, and made exactly 2.5 s per click. The two halves were then joined side by side, and the strip with the marker was cropped off. The markers in the two recordings lined up within one frame (1/30 s) at all 11 clicks.

A transition can still start a fraction of a second earlier on one side than the other. Each new page appears when its server response arrives, which varies by request: about 190–250 ms after the click on both branches, with no consistent difference between trunk and this PR.

Slow server: the expect link measured

A recording with a simulated slow server didn't make a compelling video, but the measurement behind it shows the expect link working.

A temporary mu-plugin sent X-Accel-Buffering: no and, in admin_head, flushed the output and then slept for 1 s. So the browser received the <head>, and the body arrived about 1 s later. Then the browser navigated from Settings to Tools:

  • Trunk: pagereveal fired at 1143 ms, before the response finished at 1145 ms, with only 9 of the 14 top-level menu items in the DOM. The transition started with a partly built menu.
  • This PR: pagereveal fired at 1147 ms, after the response finished at 1137 ms, with all 14 menu items present.

@westonruter

Copy link
Copy Markdown
Member Author

@b1ink0 Do you still approve of the changes? 😅

@b1ink0

b1ink0 commented Oct 23, 2026

Copy link
Copy Markdown

@b1ink0 Do you still approve of the changes? 😅

Yet me check one more time.

@westonruter

Copy link
Copy Markdown
Member Author

Here's another before/after with 20x CPU throttling. This throttling was how I could first best try to reproduce the original defect, and here it shows the clearest improvement in UX:

Before After
vt-before.mov
vt-after.mov

Notice how before there was a flash of white between navigations and how the menus didn't always animate. After, the flash of white is gone and the menu feels much more fluid.

westonruter and others added 2 commits October 23, 2026 09:17
The admin View Transitions style is now enqueued from
wp-admin/admin-header.php rather than from a hook, so asserting that it
is not hooked to `admin_enqueue_scripts` tests something that is never
added. The iframe_header() test guarded against the same thing at the
cost of a separate process, which accounted for most of the group's
runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…into trac-65032-view-transition-improvements
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants