Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8d83177
Refine the admin menu view transition animation.
westonruter May 20, 2026
867f8e3
Render-block admin pages so View Transitions capture a complete page.
westonruter May 21, 2026
b5abf8d
Address PHPStan error: Function wp_get_view_transitions_admin_css() s…
westonruter May 21, 2026
18a1fee
Merge remote-tracking branch 'origin/trunk' into trac-65032-view-tran…
westonruter Jun 2, 2026
59f6402
Tests: Add unit tests for `wp_print_view_transitions_render_blocking_…
westonruter Jun 2, 2026
93763e7
Merge branch 'trunk' into trac-65032-view-transition-improvements
westonruter Sep 4, 2026
df1468d
Merge branch 'trunk' into trac-65032-view-transition-improvements
westonruter Sep 8, 2026
5739a3d
Bump since tag to 7.2.0
westonruter Sep 8, 2026
9d77999
Merge branch 'trunk' into trac-65032-view-transition-improvements
westonruter Oct 23, 2026
326d29e
Point the render-blocking expect link at `#wpcontent`.
westonruter Oct 23, 2026
0457120
Only print the expect link when the View Transitions style is enqueued.
westonruter Oct 23, 2026
7f3b82e
Skip the expect link for iframe requests.
westonruter Oct 23, 2026
2ba00cf
Enqueue admin View Transitions only from admin-header.php.
westonruter Oct 23, 2026
a8d146e
Only print the expect link once the View Transitions style is printed.
westonruter Oct 23, 2026
b8ee1e3
Reference `admin_enqueue_scripts` with `{@see}` in the docblock.
westonruter Oct 23, 2026
426336b
Clarify that the expect link's media query does not detect a transition.
westonruter Oct 23, 2026
3e992c6
Tests: Cover that iframe_header() prints no View Transitions style or…
westonruter Oct 23, 2026
ad67e1e
Fade out an open admin menu flyout instead of clipping it.
westonruter Oct 23, 2026
cf51179
Fade out flyouts in the folded admin menu as well.
westonruter Oct 23, 2026
6eeb4ed
Move an open admin menu flyout with its menu item as it fades out.
westonruter Oct 23, 2026
6c9e063
Tests: Remove View Transitions tests for a hook that no longer exists.
westonruter Oct 23, 2026
eae1cf4
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/wp-admin/admin-header.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@
wp_enqueue_script( 'utils' );
wp_enqueue_script( 'svg-painter' );

/*
* View transitions animate the admin menu, so they are only enqueued on screens that print it.
* Iframe screens such as Thickbox modals do not load this header and so do not opt in.
*/
wp_enqueue_view_transitions_admin_css();

$admin_body_class = preg_replace( '/[^a-z0-9_-]+/i', '-', $hook_suffix );

// Print the global admin inline scripts through the script tag API so the
Expand Down
67 changes: 67 additions & 0 deletions src/wp-admin/css/view-transitions.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,74 @@
navigation: auto;
}

/*
* Give each top-level menu item its own view transition name — its id,
* such as "menu-posts" — so each one is captured as an independent group
* and animates from its old position and size to its new ones across a
* navigation. This produces the accordion effect: the previously open
* section collapses as the newly current one expands, instead of the
* whole menu simply cross-fading. attr() falls back to "none" for any
* item whose id is missing or is not a valid custom identifier. The
* shared wp-menu-top class lets the image-pair rule below address every
* menu-top group at once.
*/
#adminmenu > .menu-top {
view-transition-name: attr(id type(<custom-ident>), none);
view-transition-class: wp-menu-top;
}

/*
* Name the collapse button too, so it slides into place with the menu
* items instead of cross-fading as part of the page root.
*/
#collapse-menu {
view-transition-name: collapse-menu;
}

/*
* Clip each menu-top's snapshot to its animating group box. By default a
* captured snapshot keeps its full intrinsic height, so the new (expanded)
* submenu paints at full height from the first frame and overlaps the
* items below while they are still sliding down. Clipping the image pair
* to the group — whose height animates from the link row to the full
* expanded height — makes the submenu wipe open, and closed in reverse,
* in step with those items.
*/
::view-transition-image-pair(.wp-menu-top) {
overflow: clip;
}

/*
* Capture an open flyout submenu — one shown on hover or focus — as its own
* group, so that it fades out instead of being cut off by the clip above,
* since it extends outside its menu-top box. match-element gives each
* flyout a unique name, so two open at once cannot collide and abort the
* transition. view-transition-group nests the flyout's group inside its
* menu-top's group, so it moves along with its menu item while fading out
* instead of staying behind at its old position. Since the nested group is
* a sibling of the menu-top's image pair, it is not cut off by the clip.
*
* In the expanded menu, the current item's submenu is in the normal flow,
* so it is left to the clipped menu-top. When the menu is folded, either
* explicitly or automatically on narrower screens, every submenu is a
* flyout, including the current one. Below 783px the menu is responsive and
* its submenus are in the normal flow, so none are named there.
*/
@media screen and (min-width: 783px) {
#adminmenu > .wp-not-current-submenu.opensub > .wp-submenu,
#adminmenu > .wp-not-current-submenu > a.menu-top:focus + .wp-submenu,
.folded #adminmenu > .opensub > .wp-submenu,
.folded #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu {
view-transition-name: match-element;
view-transition-group: nearest;
}
}

@media screen and (min-width: 783px) and (max-width: 960px) {
.auto-fold #adminmenu > .opensub > .wp-submenu,
.auto-fold #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu {
view-transition-name: match-element;
view-transition-group: nearest;
}
}
}
2 changes: 1 addition & 1 deletion src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@
add_action( 'admin_enqueue_scripts', 'wp_localize_jquery_ui_datepicker', 1000 );
add_action( 'admin_enqueue_scripts', 'wp_common_block_scripts_and_styles' );
add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' );
add_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' );
add_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' );
add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' );
add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' );
add_action( 'enqueue_block_assets', 'enqueue_block_styles_assets', 30 );
Expand Down
42 changes: 41 additions & 1 deletion src/wp-includes/view-transitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
/**
* Enqueues View Transitions CSS for the admin.
*
* To disable admin View Transitions, dequeue the `wp-view-transitions-admin` style at the
* {@see 'admin_enqueue_scripts'} action, rather than unhooking this function.
*
* @since 7.0.0
* @since 7.2.0 Called from wp-admin/admin-header.php instead of hooked to {@see 'admin_enqueue_scripts'}.
*/
function wp_enqueue_view_transitions_admin_css(): void {
wp_enqueue_style( 'wp-view-transitions-admin' );
Expand All @@ -26,5 +30,41 @@ function wp_enqueue_view_transitions_admin_css(): void {
function wp_get_view_transitions_admin_css(): string {
$affix = SCRIPT_DEBUG ? '' : '.min';
$path = ABSPATH . "wp-admin/css/view-transitions{$affix}.css";
return file_get_contents( $path );
return (string) file_get_contents( $path );
}

/**
* Prints a render-blocking expectation so View Transitions capture a fully parsed admin menu.
*
* A cross-document view transition snapshots the incoming page at its first render, which can
* happen before the `<body>` has finished parsing. The snapshot would then capture a partially
* built page — for example an incomplete admin menu — and the transition animates to or from
* the wrong state.
*
* The `expect` link blocks the first render until `#wpcontent` is parsed. Rendering is unblocked
* as soon as the target's start tag is parsed, not its children, so the target is the element
* right after the admin menu rather than the menu itself: once `#wpcontent` starts, the whole
* menu has been parsed. Targeting an element near the end of the body 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 first-render delay to when view transitions can run, matching the
* `@view-transition` rule in view-transitions.css. It does not detect whether a transition is
* actually in progress, so a reload or a direct visit is also held until `#wpcontent` is parsed,
* but since that element starts right after the admin menu, the delay is negligible.
*
* Nothing is printed unless the admin View Transitions style has already been printed, since
* there is otherwise no transition to wait for. That style is only enqueued from
* wp-admin/admin-header.php, so this also leaves out iframe screens such as Thickbox modals,
* which have no `#wpcontent`: an `expect` link whose target never appears blocks the first
* render until the whole document has been parsed.
*
* @since 7.2.0
*
* @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect
*/
function wp_print_view_transitions_render_blocking_link(): void {
if ( ! wp_style_is( 'wp-view-transitions-admin', 'done' ) ) {
return;
}

echo '<link rel="expect" href="#wpcontent" blocking="render" media="(prefers-reduced-motion: no-preference)">' . "\n";
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,6 @@ public function tear_down() {
parent::tear_down();
}

/**
* Tests that the hook for enqueuing admin view transitions CSS is set up.
*
* @ticket 64470
*/
public function test_hook() {
$this->assertSame( 10, has_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) );
}

/**
* Tests that the admin view transitions style handle includes the inline CSS.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
/**
* Tests for the wp_print_view_transitions_render_blocking_link() function.
*
* @package WordPress
* @subpackage View Transitions
*/

/**
* @group view-transitions
* @covers ::wp_print_view_transitions_render_blocking_link
*/
class Tests_View_Transitions_wpPrintViewTransitionsRenderBlockingLink extends WP_UnitTestCase {

private ?WP_Styles $original_wp_styles = null;

/**
* Saves the global styles instance and resets it so each test starts with nothing enqueued.
*
* @global WP_Styles|null $wp_styles
*/
public function set_up() {
global $wp_styles;

parent::set_up();
$this->original_wp_styles = $wp_styles;
$wp_styles = null;
}

public function tear_down() {
global $wp_styles;

$wp_styles = $this->original_wp_styles;
parent::tear_down();
}

/**
* Tests that the hook for printing the render-blocking link is set up.
*
* @ticket 65032
*/
public function test_hook(): void {
$this->assertSame( 10, has_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ) );
}

/**
* Tests that a render-blocking expect link is printed.
*
* @ticket 65032
*/
public function test_prints_render_blocking_link(): void {
wp_enqueue_view_transitions_admin_css();
get_echo( 'wp_print_styles', array( 'wp-view-transitions-admin' ) );
$this->assertTrue( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to have been printed.' );

$output = get_echo( 'wp_print_view_transitions_render_blocking_link' );

$processor = new WP_HTML_Tag_Processor( $output );
$this->assertTrue( $processor->next_tag( 'LINK' ), 'Expected a LINK tag to be printed.' );
$this->assertSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected the LINK to have a rel of "expect".' );
$this->assertSame( '#wpcontent', $processor->get_attribute( 'href' ), 'Expected the LINK to point at the element after the admin menu.' );
$this->assertSame( 'render', $processor->get_attribute( 'blocking' ), 'Expected the LINK to block rendering.' );
$this->assertSame( '(prefers-reduced-motion: no-preference)', $processor->get_attribute( 'media' ), 'Expected the LINK to be scoped to the view transitions media query.' );
$this->assertFalse( $processor->next_tag(), 'Expected only a single tag to be printed.' );
}

/**
* Tests that nothing is printed when the admin View Transitions style is not enqueued.
*
* @ticket 65032
*/
public function test_prints_nothing_when_style_not_enqueued(): void {
$this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' );
$this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) );
}

/**
* Tests that nothing is printed when the admin View Transitions style is enqueued but not yet printed.
*
* This happens when the style is enqueued too late for the head, so it is printed in the footer instead.
*
* @ticket 65032
*/
public function test_prints_nothing_when_style_not_printed(): void {
wp_enqueue_view_transitions_admin_css();
$this->assertTrue( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to be enqueued.' );
$this->assertFalse( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to not be printed yet.' );

$this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) );
}
}
Loading