Skip to content

Add view transitions theme support in abstracted way with sensible defaults #8370

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 35 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2ba8487
Implement view transitions as theme support featured, opt in by defau…
felixarntz Feb 20, 2025
1c6592f
Opt in to using view transitions with default configuration for all c…
felixarntz Feb 20, 2025
0d7b15e
Handle view transitions nicely for header.
felixarntz Feb 20, 2025
fb44bb7
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Feb 27, 2025
6369845
Implement support for slide view transitions based on pagination and …
felixarntz Feb 28, 2025
67076d6
Flip global-transition-names and post-transition-names as that makes …
felixarntz Feb 28, 2025
9ad61c9
Clarify why JS needs to be included in the head.
felixarntz Mar 4, 2025
22bc6f2
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Mar 6, 2025
86b6438
Clarify now obsolete inline documentation.
felixarntz Mar 6, 2025
bb00c24
Merge branch 'add/view-transitions-theme-support' of github.com:felix…
felixarntz Mar 6, 2025
e2e6e5a
Move view transitions JS logic into a function to call for initializa…
felixarntz Mar 6, 2025
8163159
Move CSS and JS assets to more appropriate location and support minif…
felixarntz Mar 6, 2025
d05c521
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Mar 11, 2025
73337dc
Fix missing switch break.
felixarntz Mar 11, 2025
0751966
Fix JSHint violations.
felixarntz Mar 11, 2025
8c101b2
Fix failing PHPUnit test due to missing built JS file.
felixarntz Mar 11, 2025
220d1b8
Add docblocks for view transition JS functions.
felixarntz Mar 11, 2025
9058ed5
Fix JS doc.
felixarntz Mar 12, 2025
864bcea
Simplify JS logic.
felixarntz Mar 12, 2025
9683425
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Mar 12, 2025
dd837c9
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Apr 17, 2025
675e398
Simplify JS code.
felixarntz Apr 17, 2025
55c543d
[WIP] Explore options to make horizontal slide animation look good by…
felixarntz Apr 17, 2025
206551a
Vertically align scroll position for horizontal slide view transitions.
felixarntz Apr 18, 2025
260b377
Allow specifying a default animation and implement a gentle wipe anim…
felixarntz Apr 18, 2025
d628aac
Automation: Updating built files with changes. [dependabot skip]
Apr 18, 2025
6c1a23b
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Apr 21, 2025
6f33080
Use CSS registered custom property for animation angle (props @weston…
felixarntz Apr 21, 2025
eb89cfc
Merge branch 'add/view-transitions-theme-support' of github.com:felix…
felixarntz Apr 21, 2025
e4798d3
Implement classes for view transition animations and allow registerin…
felixarntz Apr 21, 2025
88766c2
Complete decoupling view transition animations from view transition t…
felixarntz Apr 22, 2025
dd04b78
Remove now obsolete view transitions CSS.
felixarntz Apr 22, 2025
f90a336
Fix PHPCS violation.
felixarntz Apr 22, 2025
499445a
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Apr 22, 2025
11a8ef1
Merge branch 'trunk' into add/view-transitions-theme-support
felixarntz Apr 30, 2025
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
2 changes: 2 additions & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ module.exports = function(grunt) {
[ WORKING_DIR + 'wp-includes/js/wp-pointer.js' ]: [ './src/js/_enqueues/lib/pointer.js' ],
[ WORKING_DIR + 'wp-includes/js/wp-sanitize.js' ]: [ './src/js/_enqueues/wp/sanitize.js' ],
[ WORKING_DIR + 'wp-includes/js/wp-util.js' ]: [ './src/js/_enqueues/wp/util.js' ],
[ WORKING_DIR + 'wp-includes/js/wp-view-transitions.js' ]: [ './src/js/_enqueues/wp/view-transitions.js' ],
[ WORKING_DIR + 'wp-includes/js/wpdialog.js' ]: [ './src/js/_enqueues/lib/dialog.js' ],
[ WORKING_DIR + 'wp-includes/js/wplink.js' ]: [ './src/js/_enqueues/lib/link.js' ],
[ WORKING_DIR + 'wp-includes/js/zxcvbn-async.js' ]: [ './src/js/_enqueues/lib/zxcvbn-async.js' ]
Expand Down Expand Up @@ -1005,6 +1006,7 @@ module.exports = function(grunt) {
'src/wp-includes/js/wp-pointer.js': 'src/js/_enqueues/lib/pointer.js',
'src/wp-includes/js/wp-sanitize.js': 'src/js/_enqueues/wp/sanitize.js',
'src/wp-includes/js/wp-util.js': 'src/js/_enqueues/wp/util.js',
'src/wp-includes/js/wp-view-transitions.js': 'src/js/_enqueues/wp/view-transitions.js',
'src/wp-includes/js/wpdialog.js': 'src/js/_enqueues/lib/dialog.js',
'src/wp-includes/js/wplink.js': 'src/js/_enqueues/lib/link.js',
'src/wp-includes/js/zxcvbn-async.js': 'src/js/_enqueues/lib/zxcvbn-async.js',
Expand Down
310 changes: 310 additions & 0 deletions src/js/_enqueues/wp/view-transitions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
/**
* @output wp-includes/js/wp-view-transitions.js
*/

window.wp = window.wp || {};
window.wp.viewTransitions = {};

/**
* Initializes view transitions for the current URL.
*
* @param {object} config The view transitions configuration.
* @param {string} config.postSelector General selector for post elements in the DOM.
* @param {object} config.globalTransitionNames Map of selectors for global elements (queried relative to 'body')
* and their view transition names.
* @param {object} config.postTransitionNames Map of selectors for post elements (queried relative to an element
* identified by config.postSelector) and their view transition names.
* @param {boolean} config.chronologicalSlideInOut Whether slide in/out animation for chronological URL relationship
* (date- or pagination-based) should be enabled.
*/
window.wp.viewTransitions.init = ( config ) => {
if ( ! window.navigation || ! ( 'CSSViewTransitionRule' in window ) ) {
window.console.warn( 'View transitions not loaded as the browser is lacking support.' );
return;
}

/**
* Gets all view transition entries relevant for a view transition.
*
* @param {string} transitionType View transition type (e.g. 'default', 'chronological-forwards', 'chronological-backwards').
* @param {Element} bodyElement The body element.
* @param {Element|null} articleElement The post element relevant for the view transition, if any.
* @return {Array[]} View transition entries with each one containing the element and its view transition name.
*/
const getViewTransitionEntries = ( transitionType, bodyElement, articleElement ) => {
const globalEntries = config.animations[ transitionType ].useGlobalTransitionNames ?
Object.entries( config.globalTransitionNames || {} ).map( ( [ selector, name ] ) => {
const element = bodyElement.querySelector( selector );
return [ element, name ];
} ) : [];

const postEntries = config.animations[ transitionType ].usePostTransitionNames && articleElement ?
Object.entries( config.postTransitionNames || {} ).map( ( [ selector, name ] ) => {
const element = articleElement.querySelector( selector );
return [ element, name ];
} ) : [];

return [
...globalEntries,
...postEntries,
];
};

/**
* Temporarily sets view transition names for the given entries until the view transition has been completed.
*
* @param {Array[]} entries View transition entries as received from `getViewTransitionEntries()`.
* @param {Promise<void>} vtPromise Promise that resolves after the view transition has been completed.
* @return {Promise<void>} Promise that resolves after the view transition names were reset.
*/
const setTemporaryViewTransitionNames = async ( entries, vtPromise ) => {
for ( const [ element, name ] of entries ) {
if ( ! element ) {
continue;
}
element.style.viewTransitionName = name;
}

await vtPromise;

for ( const [ element ] of entries ) {
if ( ! element ) {
continue;
}
element.style.viewTransitionName = '';
}
};

/**
* Appends a selector to another selector.
*
* This supports selectors which technically include multiple selectors (separated by comma).
*
* @param {string} selectors Main selector.
* @param {string} append Selector to append to the main selector.
* @return {string} Combined selector.
*/
const appendSelectors = ( selectors, append ) => {
return selectors.split( ',' ).map( subselector => subselector.trim() + ' ' + append ).join( ',' );
};

/**
* Gets a post element (the first on the page, in case there are multiple).
*
* @return {Element|null} Post element, or null if none is found.
*/
const getArticle = () => {
if ( ! config.postSelector ) {
return null;
}
return document.querySelector( config.postSelector );
};

/**
* Gets the post element for a specific post URL.
*
* @param {string} url Post URL (permalink) to find post element.
* @return {Element|null} Post element, or null if none is found.
*/
const getArticleForUrl = ( url ) => {
if ( ! config.postSelector ) {
return null;
}
const postLinkSelector = appendSelectors( config.postSelector, 'a[href="' + url + '"]' );
const articleLink = document.querySelector( postLinkSelector );
if ( ! articleLink ) {
return null;
}
return articleLink.closest( config.postSelector );
};

/**
* Determines the view transition type to use, given an old and new navigation history entry.
*
* @param {NavigationHistoryEntry} oldEntry Navigation history entry for the URL navigated from.
* @param {NavigationHistoryEntry} newEntry Navigation history entry for the URL navigated to.
* @return {string} View transition type (e.g. 'default', 'chronological-forwards', 'chronological-backwards').
*/
const determineTransitionType = ( oldEntry, newEntry ) => {
if ( ! oldEntry || ! newEntry ) {
return 'default';
}

// Use 'default' transition type if all other transition types are disabled.
if (
! config.animations['chronological-forwards'] &&
! config.animations['chronological-backwards'] &&
! config.animations['pagination-forwards'] &&
! config.animations['pagination-backwards']
) {
return 'default';
}

const oldURL = new URL( oldEntry.url );
const newURL = new URL( newEntry.url );

const oldPathname = oldURL.pathname;
const newPathname = newURL.pathname;

if ( oldPathname === newPathname ) {
return 'default';
}

let oldPageMatches = false;
let newPageMatches = false;
let prefix = '';

// If enabled, check if the URLs are for a chronologically paginated archive.
if ( config.animations['chronological-forwards'] || config.animations['chronological-backwards'] ) {
oldPageMatches = oldPathname.match( /\/page\/(\d+)\/?$/ );
newPageMatches = newPathname.match( /\/page\/(\d+)\/?$/ );
prefix = 'chronological-';
}

// If not, check if the URLs are for a multi-page post.
if ( ! oldPageMatches && ! newPageMatches && ( config.animations['pagination-forwards'] || config.animations['pagination-backwards'] ) ) {
oldPageMatches = oldPathname.match( /\/(\d+)\/?$/ );
newPageMatches = newPathname.match( /\/(\d+)\/?$/ );
prefix = 'pagination-';
}

// If there is a match on at least one of the URLs, compare whether their roots before the page segment match.
if ( oldPageMatches || newPageMatches ) {
const oldPageBase = oldPageMatches ? oldPathname.substring( 0, oldPathname.length - oldPageMatches[ 0 ].length ) : oldPathname.replace( /\/$/, '' );
const newPageBase = newPageMatches ? newPathname.substring( 0, newPathname.length - newPageMatches[ 0 ].length ) : newPathname.replace( /\/$/, '' );
if ( oldPageBase === newPageBase ) { // They belong to the same archive or post.
// Return the appropriate transition type, or 'default' if no particular animation is specified.
if ( oldPageMatches && newPageMatches ) {
if ( Number( oldPageMatches[ 1 ] ) < Number( newPageMatches[ 1 ] ) ) {
return config.animations[ `${ prefix }forwards` ] ? `${ prefix }forwards` : 'default';
}
return config.animations[ `${ prefix }backwards` ] ? `${ prefix }backwards` : 'default';
}
if ( newPageMatches && Number( newPageMatches[ 1 ] ) > 1 ) {
return config.animations[ `${ prefix }forwards` ] ? `${ prefix }forwards` : 'default';
}
if ( oldPageMatches && Number( oldPageMatches[ 1 ] ) > 1 ) {
return config.animations[ `${ prefix }backwards` ] ? `${ prefix }backwards` : 'default';
}
}
}

// If enabled, check if the URLs are for content labelled by date (e.g. navigation to previous/next post).
if ( config.animations['chronological-forwards'] || config.animations['chronological-backwards'] ) {
const oldDateMatches = oldPathname.match( /\/(\d{4})\/(\d{2})\/(\d{2})\/[^\/]+\/?$/ );
const newDateMatches = newPathname.match( /\/(\d{4})\/(\d{2})\/(\d{2})\/[^\/]+\/?$/ );
if ( oldDateMatches && newDateMatches ) {
const oldPageBase = oldPathname.substring( 0, oldPathname.length - oldDateMatches[ 0 ].length );
const newPageBase = newPathname.substring( 0, newPathname.length - newDateMatches[ 0 ].length );
if ( oldPageBase === newPageBase ) { // They belong to the same hierarchy.
const oldDate = new Date( parseInt( oldDateMatches[ 1 ] ), parseInt( oldDateMatches[ 2 ] ) - 1, parseInt( oldDateMatches[ 3 ] ) );
const newDate = new Date( parseInt( newDateMatches[ 1 ] ), parseInt( newDateMatches[ 2 ] ) - 1, parseInt( newDateMatches[ 3 ] ) );
if ( oldDate < newDate ) {
return config.animations['chronological-forwards'] ? 'chronological-forwards' : 'default';
}
if ( oldDate > newDate ) {
return config.animations['chronological-backwards'] ? 'chronological-backwards' : 'default';
}
}
}
}

return 'default';
};

/**
* Gets the view transition name for the element that receives a slide animation based on the transition type determined, if any.
*
* @param {string} transitionType View transition type as received from `determineTransitionType()`.
* @return {string|null} View transition name, or null if none is relevant for the transition type.
*/
const getViewTransitionNameForSlideAnimation = ( transitionType ) => {
if ( config.animations[ transitionType ] && config.animations[ transitionType ].targetName !== '*' ) {
return config.animations[ transitionType ].targetName;
}
return null;
};

/**
* Customizes view transition behavior on the URL that is being navigated from.
*
* @param {PageSwapEvent} event Event fired as the previous URL is about to unload.
*/
window.addEventListener( 'pageswap', ( event ) => {
if ( event.viewTransition ) {
const transitionType = determineTransitionType( event.activation.from, event.activation.entry );
event.viewTransition.types.add( transitionType );

let viewTransitionEntries;
if ( document.body.classList.contains( 'single' ) ) {
viewTransitionEntries = getViewTransitionEntries(
transitionType,
document.body,
getArticle()
);
} else if ( document.body.classList.contains( 'home' ) || document.body.classList.contains( 'archive' ) ) {
viewTransitionEntries = getViewTransitionEntries(
transitionType,
document.body,
getArticleForUrl( event.activation.entry.url )
);
}
if ( viewTransitionEntries ) {
setTemporaryViewTransitionNames( viewTransitionEntries, event.viewTransition.finished );

const slideViewTransitionName = getViewTransitionNameForSlideAnimation( transitionType );
if ( slideViewTransitionName ) {
// Consider a scroll offset if defined (e.g. due to fixed navigation bars being in the way).
const scrollYOffset = document.documentElement.style.getPropertyValue( '--wp-scroll-y-offset' );
const currentScrollY = window.scrollY - ( scrollYOffset ? parseInt( scrollYOffset, 10 ) : 0 );
sessionStorage.setItem( 'wpViewTransitionsOldScrollY', currentScrollY );
}
}
}
} );

/**
* Customizes view transition behavior on the URL that is being navigated to.
*
* @param {PageRevealEvent} event Event fired as the new URL being navigated to is loaded.
*/
window.addEventListener( 'pagereveal', ( event ) => {
if ( event.viewTransition ) {
const transitionType = determineTransitionType( window.navigation.activation.from, window.navigation.activation.entry );
event.viewTransition.types.add( transitionType );

let viewTransitionEntries;
if ( document.body.classList.contains( 'single' ) ) {
viewTransitionEntries = getViewTransitionEntries(
transitionType,
document.body,
getArticle()
);
} else if ( document.body.classList.contains( 'home' ) || document.body.classList.contains( 'archive' ) ) {
viewTransitionEntries = getViewTransitionEntries(
transitionType,
document.body,
window.navigation.activation.from ? getArticleForUrl( window.navigation.activation.from.url ) : null
);
}
if ( viewTransitionEntries ) {
setTemporaryViewTransitionNames( viewTransitionEntries, event.viewTransition.ready );

const slideViewTransitionName = getViewTransitionNameForSlideAnimation( transitionType );
if ( slideViewTransitionName ) {
const oldScrollY = sessionStorage.getItem( 'wpViewTransitionsOldScrollY' );
if ( oldScrollY !== null ) {
// Align vertical scroll position.
if ( oldScrollY ) {
window.scrollTo( 0, parseInt( oldScrollY, 10 ) );
}
sessionStorage.removeItem( 'wpViewTransitionsOldScrollY' );
} else {
// Skip view transition to avoid an odd diagonal slide.
event.viewTransition.skipTransition();
}
}
}
}
} );
};
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentyeleven/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ function twentyeleven_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom color scheme.
add_theme_support(
'editor-color-palette',
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentyfifteen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ function twentyfifteen_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom color scheme.
add_theme_support(
'editor-color-palette',
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentyfourteen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ function twentyfourteen_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom color scheme.
add_theme_support(
'editor-color-palette',
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentynineteen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ function twentynineteen_setup() {
// Add support for responsive embedded content.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom line height.
add_theme_support( 'custom-line-height' );
}
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentyseventeen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ function twentyseventeen_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Define and register starter content to showcase the theme on new sites.
$starter_content = array(
'widgets' => array(
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentysixteen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ function twentysixteen_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom color scheme.
add_theme_support(
'editor-color-palette',
Expand Down
3 changes: 3 additions & 0 deletions src/wp-content/themes/twentythirteen/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ function twentythirteen_setup() {
// Add support for responsive embeds.
add_theme_support( 'responsive-embeds' );

// Add support for view transitions.
add_theme_support( 'view-transitions' );

// Add support for custom color scheme.
add_theme_support(
'editor-color-palette',
Expand Down
Loading
Loading