Commit 31798ea5448 for woocommerce

commit 31798ea5448f46377aeca00f1879a7af510446c3
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Tue May 19 00:50:22 2026 +0800

    Add native variation gallery support (#64524)

    Add Additional Variation Images functionality to Core

    Brings native variation gallery support to WooCommerce, replacing the
    retired Additional Variation Images extension. Each variation can have
    its own ordered image set; the storefront swaps to it on selection.

    This PR covers:

    - the data model,
    - migration from the legacy AVI extension,
    - REST API integration,
    - many different interaction cases between various versions of the blocks,
    - backwards compatibility and classic theme support,
    - packages-managed auto-deactivation of the old plugin,
    - and some telemetry

    The Admin UI is still left rough at this stage.

    ---------

    Co-authored-by: Karol Manijak <20098064+kmanijak@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images b/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images
new file mode 100644
index 00000000000..e29c159fb28
--- /dev/null
+++ b/plugins/woocommerce/changelog/64524-feature-merge-additional-variation-images
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add native variation gallery support: each variation can now have its own ordered image set, replacing the retired Additional Variation Images extension. Includes a migrator for legacy data and a runtime fallback so existing stores keep displaying their galleries.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
new file mode 100644
index 00000000000..abb92f0898b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/constants.ts
@@ -0,0 +1,41 @@
+/**
+ * Centralized magic strings used across the Product Gallery frontend.
+ *
+ * Anything hardcoded that the gallery's runtime depends on — CSS class
+ * names, attribute-based selectors, jQuery event namespaces — lives here
+ * so call sites stay name-driven and refactors only touch one place.
+ */
+
+export const SELECTORS = {
+	galleryContainer: '.wp-block-woocommerce-product-gallery',
+	largeImageContainer: '.wc-block-product-gallery-large-image__container',
+	largeImageWrapper: '.wc-block-product-gallery-large-image__wrapper',
+	thumbnail: '.wc-block-product-gallery-thumbnails__thumbnail',
+	thumbnailsScrollable: '.wc-block-product-gallery-thumbnails__scrollable',
+	legacyVariationIdInput: 'input[name="variation_id"]',
+	legacyResetVariations: '.reset_variations',
+	imgByImageId: ( imageId: number | string ): string =>
+		`img[data-image-id="${ imageId }"]`,
+	elementByImageId: ( imageId: number | string ): string =>
+		`[data-image-id="${ imageId }"]`,
+	cartFormForProduct: ( productId: number | string ): string =>
+		`form[data-product_id="${ productId }"]`,
+} as const;
+
+export const CLASSES = {
+	activeThumbnail:
+		'wc-block-product-gallery-thumbnails__thumbnail__image--is-active',
+	dialogOpenBody: 'wc-block-product-gallery-dialog-open',
+} as const;
+
+/**
+ * jQuery event names used by the legacy Add to Cart variation form.
+ * Suffixed with the `.wc-product-gallery` namespace so we can `.off()`
+ * everything we bound without disturbing handlers added by other code.
+ */
+export const LEGACY_FORM_JQUERY_EVENTS = {
+	namespace: '.wc-product-gallery',
+	foundVariation: 'found_variation.wc-product-gallery',
+	hideOrResetVariation:
+		'hide_variation.wc-product-gallery reset_data.wc-product-gallery',
+} as const;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
index a9caf549cf6..a202407c805 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/frontend.ts
@@ -7,6 +7,7 @@ import {
 	getElement,
 	withScope,
 	withSyncEvent,
+	getConfig,
 } from '@wordpress/interactivity';
 import '@woocommerce/stores/woocommerce/products';
 import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
@@ -14,8 +15,14 @@ import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
 /**
  * Internal dependencies
  */
-import type { ProductGalleryContext } from './types';
+import type {
+	ProductGalleryContext,
+	ProductGalleryConfig,
+	ProductImageSet,
+} from './types';
 import { checkOverflow } from './utils';
+import { subscribeLegacyJQueryFormVariations } from './legacy-jquery-form';
+import { SELECTORS, CLASSES } from './constants';

 // Stores are locked to prevent 3PD usage until the API is stable.
 const universalLock =
@@ -29,66 +36,180 @@ const getArrowsState = ( imageIndex: number, totalImages: number ) => ( {
 	isDisabledNext: imageIndex === totalImages - 1,
 } );

+/** Read the `products` map from the WooCommerce iAPI config (or `{}`). */
+const getConfiguredProducts = () =>
+	( getConfig( 'woocommerce' ) as ProductGalleryConfig )?.products || {};
+
+const getProductImageSet = ( productId: string | number ) =>
+	getConfiguredProducts()?.[ String( productId ) ];
+
 /**
- * Scrolls the image into view for the main image.
- *
- * We use getElement to get the current element that triggered the action
- * to find the closest gallery container and scroll the image into view.
- * This is necessary because if you have two galleries on the same page with the same image IDs,
- * then we need to query the image in the correct gallery to avoid scrolling the wrong image into view.
- *
- * @param {string} imageId - The ID of the image to scroll into view.
+ * Find a variation by matching its featured `image_id` against the given
+ * `currentImageId`. Used as a fallback when the form doesn't expose a
+ * `variation_id` directly (early stages of legacy form lifecycle).
+ */
+const getVariationImageSetByCurrentImage = (
+	productImageSet: ProductImageSet,
+	currentImageId: number
+) =>
+	Object.values( productImageSet.variations || {} ).find(
+		( variation ) => variation.image_id === currentImageId
+	);
+
+/**
+ * Pick the image to surface as selected after a visible-set change.
+ * Prefers the caller's request when it's still in the new set; otherwise
+ * falls back to the first image, or `-1` when the set is empty.
+ */
+const pickSelectedImageId = (
+	imageData: number[],
+	requestedId: number | undefined
+): number => {
+	if ( requestedId !== undefined && imageData.includes( requestedId ) ) {
+		return requestedId;
+	}
+	return imageData[ 0 ] ?? -1;
+};
+
+/** Recompute arrow disabled flags for an image set + selected slot. */
+const computeArrowsState = ( imageData: number[], selectedImageId: number ) => {
+	const index = imageData.indexOf( selectedImageId );
+	if ( index < 0 ) {
+		return { isDisabledPrevious: true, isDisabledNext: true };
+	}
+	return getArrowsState( index, imageData.length );
+};
+
+/** Scroll both the large viewer and the thumbnail strip to the given image. */
+const scrollImageEverywhereIntoView = (
+	imageId: number,
+	behavior: ScrollBehavior = 'smooth'
+) => {
+	scrollImageIntoView( imageId, behavior );
+	scrollThumbnailIntoView( imageId );
+};
+
+/**
+ * Mutate the gallery's reactive context to reflect a new visible image
+ * set. Empty input restores the parent product's gallery from the iAPI
+ * config. Also recomputes arrow states and scrolls the active slot into
+ * view.
  */
-const scrollImageIntoView = ( imageId: number ) => {
+const updateVisibleImageSet = (
+	imageIds: number[],
+	selectedImageId?: number
+) => {
+	const context = getContext();
+	const nextImageData = imageIds.length
+		? imageIds
+		: getProductImageSet( context.productId )?.image_ids || [];
+	const nextSelectedImageId = pickSelectedImageId(
+		nextImageData,
+		selectedImageId
+	);
+	const arrowsState = computeArrowsState(
+		nextImageData,
+		nextSelectedImageId
+	);
+
+	context.imageData = nextImageData;
+	context.selectedImageId = nextSelectedImageId;
+	context.hideNextPreviousButtons = nextImageData.length <= 1;
+	context.isDisabledPrevious = arrowsState.isDisabledPrevious;
+	context.isDisabledNext = arrowsState.isDisabledNext;
+
+	if ( nextSelectedImageId === -1 ) {
+		return;
+	}
+
+	scrollImageEverywhereIntoView( nextSelectedImageId, 'instant' );
+};
+
+/**
+ * Toggle the `hidden` attribute on the closest gallery wrapper based on
+ * whether the element's `data-image-id` is in the current `imageData`.
+ * Bound via `data-wp-watch` so it re-runs reactively on context change.
+ */
+const toggleImageVisibility = ( element: HTMLElement ) => {
+	const imageIdValue = element.getAttribute( 'data-image-id' );
+	if ( ! imageIdValue ) {
+		return;
+	}
+
+	const imageId = Number.parseInt( imageIdValue, 10 );
+	const { imageData } = getContext();
+	const visibleIndex = imageData.indexOf( imageId );
+	const isVisible = visibleIndex >= 0;
+	const closestWrapper = element.closest(
+		`${ SELECTORS.largeImageWrapper }, ${ SELECTORS.thumbnail }`
+	) as HTMLElement | null;
+	const visibilityTarget = closestWrapper || element;
+
+	visibilityTarget.hidden = ! isVisible;
+	visibilityTarget.style.order = isVisible ? String( visibleIndex ) : '';
+	element.setAttribute( 'aria-hidden', isVisible ? 'false' : 'true' );
+};
+
+/**
+ * Apply the active-thumbnail class and tabIndex when the element's
+ * image is both visible and the currently selected image; otherwise
+ * remove them.
+ */
+const toggleActiveThumbnailAttributes = ( element: HTMLElement ) => {
+	const imageIdValue = element.getAttribute( 'data-image-id' );
+	if ( ! imageIdValue ) {
+		return;
+	}
+
+	const { imageData, selectedImageId } = getContext();
+	const imageId = Number.parseInt( imageIdValue, 10 );
+	const isVisible = imageData.includes( imageId );
+
+	if ( isVisible && selectedImageId === imageId ) {
+		element.classList.add( CLASSES.activeThumbnail );
+		element.setAttribute( 'tabIndex', '0' );
+		return;
+	}
+
+	element.classList.remove( CLASSES.activeThumbnail );
+	element.setAttribute( 'tabIndex', '-1' );
+};
+
+const scrollImageIntoView = (
+	imageId: number,
+	behavior: ScrollBehavior = 'smooth'
+) => {
 	if ( ! imageId ) {
 		return;
 	}

-	// Get the current element that triggered the action
 	const element = getElement()?.ref as HTMLElement;
-
 	if ( ! element ) {
 		return;
 	}

-	const galleryContainer = element.closest(
-		'.wp-block-woocommerce-product-gallery'
-	);
-
+	const galleryContainer = element.closest( SELECTORS.galleryContainer );
 	if ( ! galleryContainer ) {
 		return;
 	}

-	// Find the scrollable container for the viewer gallery
 	const scrollableContainer = galleryContainer.querySelector(
-		'.wc-block-product-gallery-large-image__container'
-	);
-
+		SELECTORS.largeImageContainer
+	) as HTMLElement | null;
 	if ( ! scrollableContainer ) {
 		return;
 	}

-	const imageElement = scrollableContainer.querySelector(
-		`img[data-image-id="${ imageId }"]`
-	);
-
-	if ( imageElement ) {
-		// Calculate the scroll position to center the image horizontally
-		const containerRect = scrollableContainer.getBoundingClientRect();
-		const imageRect = imageElement.getBoundingClientRect();
-
-		const scrollLeft =
-			scrollableContainer.scrollLeft +
-			( imageRect.left - containerRect.left ) -
-			( containerRect.width - imageRect.width ) / 2;
-
-		// Use scrollTo as scrollIntoView with inline: 'center'
-		// is not supported in iOS (Safari and Chrome).
-		scrollableContainer.scrollTo( {
-			left: scrollLeft,
-			behavior: 'smooth',
-		} );
+	const { imageData } = getContext();
+	const imageIndex = imageData.indexOf( imageId );
+	if ( imageIndex < 0 ) {
+		return;
 	}
+
+	scrollableContainer.scrollTo( {
+		left: imageIndex * scrollableContainer.clientWidth,
+		behavior,
+	} );
 };

 /**
@@ -109,16 +230,14 @@ const scrollThumbnailIntoView = ( imageId: number ) => {
 	}

 	// Find the closest gallery container
-	const galleryContainer = element.closest(
-		'.wp-block-woocommerce-product-gallery'
-	);
+	const galleryContainer = element.closest( SELECTORS.galleryContainer );

 	if ( ! galleryContainer ) {
 		return;
 	}

 	const thumbnailElement = galleryContainer.querySelector(
-		`.wc-block-product-gallery-thumbnails__thumbnail img[data-image-id="${ imageId }"]`
+		`${ SELECTORS.thumbnail } ${ SELECTORS.imgByImageId( imageId ) }`
 	);

 	if ( ! thumbnailElement ) {
@@ -127,16 +246,14 @@ const scrollThumbnailIntoView = ( imageId: number ) => {

 	// Find the thumbnail scrollable container
 	const scrollContainer = thumbnailElement.closest(
-		'.wc-block-product-gallery-thumbnails__scrollable'
+		SELECTORS.thumbnailsScrollable
 	);

 	if ( ! scrollContainer ) {
 		return;
 	}

-	const thumbnail = thumbnailElement.closest(
-		'.wc-block-product-gallery-thumbnails__thumbnail'
-	);
+	const thumbnail = thumbnailElement.closest( SELECTORS.thumbnail );

 	if ( ! thumbnail ) {
 		return;
@@ -170,6 +287,8 @@ const { state: productsState } = store< ProductsStore >(
 	{ lock: universalLock }
 );

+const lastSeenVariationId = new Map< string, number | null | undefined >();
+
 const productGallery = {
 	state: {
 		/**
@@ -186,6 +305,9 @@ const productGallery = {
 		selectImage: ( newImageIndex: number ) => {
 			const context = getContext();
 			const { imageData } = context;
+			if ( newImageIndex < 0 || newImageIndex >= imageData.length ) {
+				return;
+			}

 			const imageId = imageData[ newImageIndex ];
 			const { isDisabledPrevious, isDisabledNext } = getArrowsState(
@@ -202,6 +324,12 @@ const productGallery = {
 				scrollThumbnailIntoView( imageId );
 			}
 		},
+		setImageData: ( imageIds: number[], selectedImageId?: number ) => {
+			updateVisibleImageSet( imageIds, selectedImageId );
+		},
+		resetImageData: () => {
+			updateVisibleImageSet( [] );
+		},
 		selectCurrentImage: ( event?: MouseEvent ) => {
 			if ( event ) {
 				event.stopPropagation();
@@ -214,10 +342,27 @@ const productGallery = {
 			if ( ! imageIdValue ) {
 				return;
 			}
+
 			const imageId = parseInt( imageIdValue, 10 );
-			const { imageData } = getContext();
-			const newImageIndex = imageData.indexOf( imageId );
-			actions.selectImage( newImageIndex );
+			if ( Number.isNaN( imageId ) ) {
+				return;
+			}
+			const context = getContext();
+			const newImageIndex = context.imageData.indexOf( imageId );
+
+			context.selectedImageId = imageId;
+
+			if ( newImageIndex >= 0 ) {
+				const arrowsState = getArrowsState(
+					newImageIndex,
+					context.imageData.length
+				);
+				context.isDisabledPrevious = arrowsState.isDisabledPrevious;
+				context.isDisabledNext = arrowsState.isDisabledNext;
+			}
+
+			scrollImageIntoView( imageId );
+			scrollThumbnailIntoView( imageId );
 		},
 		selectNextImage: ( event?: MouseEvent ) => {
 			if ( event ) {
@@ -312,16 +457,12 @@ const productGallery = {
 			event?.preventDefault();
 			const context = getContext();
 			context.isDialogOpen = true;
-			document.body.classList.add(
-				'wc-block-product-gallery-dialog-open'
-			);
+			document.body.classList.add( CLASSES.dialogOpenBody );
 		} ),
 		closeDialog: () => {
 			const context = getContext();
 			context.isDialogOpen = false;
-			document.body.classList.remove(
-				'wc-block-product-gallery-dialog-open'
-			);
+			document.body.classList.remove( CLASSES.dialogOpenBody );
 		},
 		onTouchStart: ( event: TouchEvent ) => {
 			const context = getContext();
@@ -399,11 +540,11 @@ const productGallery = {

 			if ( element ) {
 				const galleryContainer = element.closest(
-					'.wp-block-woocommerce-product-gallery'
+					SELECTORS.galleryContainer
 				);
 				if ( galleryContainer ) {
 					const selectedImage = galleryContainer.querySelector(
-						`img[data-image-id="${ selectedImageId }"]`
+						SELECTORS.imgByImageId( selectedImageId )
 					) as HTMLElement;
 					if ( selectedImage ) {
 						selectedImage.focus( { preventScroll: true } );
@@ -426,85 +567,158 @@ const productGallery = {
 		},
 	},
 	callbacks: {
+		/**
+		 * Sync the gallery to the blockified Add to Cart + Options block's
+		 * variation state. Bound via `data-wp-watch`, so it re-runs whenever
+		 * `productsState.variationId` changes.
+		 */
 		listenToProductDataChanges: () => {
-			const product = productsState.productInContext;
+			const context = getContext();
+			const variationId = productsState.variationId;
+			const prevVariationId = lastSeenVariationId.get(
+				context.productId
+			);
+
+			if ( prevVariationId === variationId ) {
+				return;
+			}
+
+			if ( prevVariationId === undefined && ! variationId ) {
+				lastSeenVariationId.set( context.productId, variationId );
+				return;
+			}
+
+			lastSeenVariationId.set( context.productId, variationId );

+			const product = productsState.mainProductInContext;
 			if ( ! product ) {
 				return;
 			}

-			const imageId = product.images?.[ 0 ]?.id;
-			if ( ! imageId ) {
+			const productImageSet = getProductImageSet( product.id );
+			if ( ! productImageSet ) {
 				return;
 			}

-			const { imageData } = getContext();
-			const imageIndex = imageData.indexOf( imageId );
+			if ( ! variationId ) {
+				actions.resetImageData();
+				return;
+			}

-			if ( imageIndex >= 0 ) {
-				actions.selectImage( imageIndex );
+			const variationImageSet =
+				productImageSet.variations?.[ variationId ];
+
+			if ( variationImageSet?.image_ids?.length ) {
+				actions.setImageData(
+					variationImageSet.image_ids,
+					variationImageSet.image_id
+				);
+				return;
 			}
+
+			actions.resetImageData();
 		},
+		/**
+		 * Subscribe the gallery to the legacy classic Add to Cart form's
+		 * variation events. Prefers jQuery `found_variation` / `reset_data`
+		 * when jQuery is present; falls back to a MutationObserver on the
+		 * form's `current-image` attribute. Returns a teardown function.
+		 */
 		watchForChangesOnAddToCartForm: () => {
 			const context = getContext();
-			const variableProductCartForm = document.querySelector(
-				`form[data-product_id="${ context.productId }"]`
-			);
+			const $form = document.querySelector(
+				SELECTORS.cartFormForProduct( context.productId )
+			) as HTMLElement | null;

-			if ( ! variableProductCartForm ) {
+			if ( ! $form ) {
 				return;
 			}

-			const selectFirstImage = () =>
-				withScope( () => actions.selectImage( 0 ) );
+			const productImageSet = getProductImageSet( context.productId );
+			const syncFormVariationGallery = withScope( () => {
+				if ( ! productImageSet ) {
+					actions.resetImageData();
+					return;
+				}
+
+				const $variationIdInput = $form.querySelector(
+					SELECTORS.legacyVariationIdInput
+				) as HTMLInputElement | null;
+				const hasVariationIdInput = !! $variationIdInput;
+				const currentVariationId = Number.parseInt(
+					$variationIdInput?.value || '0',
+					10
+				);

+				// When the form exposes a variation_id input but it's empty,
+				// the merchant cleared the variation — restore the parent
+				// gallery instead of guessing from `current-image`.
+				if ( hasVariationIdInput && ! currentVariationId ) {
+					actions.resetImageData();
+					return;
+				}
+
+				const currentImageId = Number.parseInt(
+					$form.getAttribute( 'current-image' ) || '0',
+					10
+				);
+				const variationImageSet = hasVariationIdInput
+					? productImageSet.variations?.[ currentVariationId ]
+					: getVariationImageSetByCurrentImage(
+							productImageSet,
+							currentImageId
+					  );
+
+				if ( variationImageSet?.image_ids?.length ) {
+					actions.setImageData(
+						variationImageSet.image_ids,
+						currentImageId || variationImageSet.image_id
+					);
+					return;
+				}
+
+				actions.resetImageData();
+			} );
+
+			const teardownJQuery = subscribeLegacyJQueryFormVariations( $form, {
+				onVariationFound: () => syncFormVariationGallery(),
+				onVariationReset: () => actions.resetImageData(),
+			} );
+
+			if ( teardownJQuery ) {
+				syncFormVariationGallery();
+				return teardownJQuery;
+			}
+
+			// MutationObserver fallback for environments without jQuery.
 			const observer = new MutationObserver(
-				withScope( function ( mutations ) {
-					for ( const mutation of mutations ) {
-						const { imageData } = getContext();
-
-						const mutationTarget = mutation.target as HTMLElement;
-						const currentImageAttribute =
-							mutationTarget.getAttribute( 'current-image' );
-						const currentImageId = currentImageAttribute
-							? parseInt( currentImageAttribute, 10 )
-							: null;
-						if (
-							mutation.type === 'attributes' &&
-							currentImageId &&
-							imageData.includes( currentImageId )
-						) {
-							const nextImageIndex =
-								imageData.indexOf( currentImageId );
-
-							actions.selectImage( nextImageIndex );
-						} else {
-							actions.selectImage( 0 );
-						}
-					}
-				} )
+				withScope( () => syncFormVariationGallery() )
 			);
+			const $clearVariationsLink = $form.querySelector(
+				SELECTORS.legacyResetVariations
+			);
+			const syncOnChange = withScope( () => syncFormVariationGallery() );
+			const resetGallery = withScope( () => actions.resetImageData() );

-			observer.observe( variableProductCartForm, {
+			observer.observe( $form, {
 				attributes: true,
+				attributeFilter: [ 'current-image' ],
 			} );
+			$form.addEventListener( 'change', syncOnChange );
+			$clearVariationsLink?.addEventListener( 'click', resetGallery );

-			const clearVariationsLink = document.querySelector(
-				'.wp-block-add-to-cart-form .reset_variations'
-			);
-
-			if ( clearVariationsLink ) {
-				clearVariationsLink.addEventListener(
-					'click',
-					selectFirstImage
-				);
-			}
+			syncFormVariationGallery();

 			return () => {
 				observer.disconnect();
-				document.removeEventListener( 'click', selectFirstImage );
+				$form.removeEventListener( 'change', syncOnChange );
+				$clearVariationsLink?.removeEventListener(
+					'click',
+					resetGallery
+				);
 			};
 		},
+		/** When the dialog opens, focus it and center the active image vertically. */
 		dialogStateChange: () => {
 			const { selectedImageId, isDialogOpen } = getContext();
 			const { ref: dialogRef } = getElement() || {};
@@ -512,7 +726,7 @@ const productGallery = {
 			if ( isDialogOpen && dialogRef instanceof HTMLElement ) {
 				dialogRef.focus();
 				const selectedImage = dialogRef.querySelector(
-					`[data-image-id="${ selectedImageId }"]`
+					SELECTORS.elementByImageId( selectedImageId )
 				);

 				if (
@@ -529,28 +743,26 @@ const productGallery = {
 				}
 			}
 		},
-		toggleActiveThumbnailAttributes: () => {
+		/** Per-image `data-wp-watch` callback that toggles visibility from `imageData`. */
+		toggleImageVisibility: () => {
 			const element = getElement()?.ref as HTMLElement;
-			if ( ! element ) return false;
-
-			const imageIdValue = element.getAttribute( 'data-image-id' );
-			if ( ! imageIdValue ) return false;
-
-			const { selectedImageId } = getContext();
-			const imageId = Number( imageIdValue );
+			if ( ! element ) {
+				return false;
+			}

-			if ( selectedImageId === imageId ) {
-				element.classList.add(
-					'wc-block-product-gallery-thumbnails__thumbnail__image--is-active'
-				);
-				element.setAttribute( 'tabIndex', '0' );
-			} else {
-				element.classList.remove(
-					'wc-block-product-gallery-thumbnails__thumbnail__image--is-active'
-				);
-				element.setAttribute( 'tabIndex', '-1' );
+			toggleImageVisibility( element );
+		},
+		/** Per-thumbnail callback that updates both visibility and the active-state class. */
+		syncThumbnailState: () => {
+			const element = getElement()?.ref as HTMLElement;
+			if ( ! element ) {
+				return false;
 			}
+
+			toggleImageVisibility( element );
+			toggleActiveThumbnailAttributes( element );
 		},
+		/** Set up a ResizeObserver on the thumbnails strip so overflow flags stay in sync. */
 		initResizeObserver: () => {
 			const scrollableElement = getElement()?.ref;
 			if ( ! scrollableElement ) {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
new file mode 100644
index 00000000000..9c9097247ca
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/legacy-jquery-form.ts
@@ -0,0 +1,93 @@
+/**
+ * Legacy classic Add to Cart form integration.
+ *
+ * The classic single-product variation form (`add-to-cart-variation.js`)
+ * is jQuery-driven and emits its variation lifecycle as jQuery custom
+ * events. This module isolates that integration so the main gallery
+ * frontend stays free of jQuery type acrobatics.
+ *
+ * Returns a teardown callable when subscription succeeds, or `null` when
+ * jQuery isn't loaded on the page so the caller can fall back to a
+ * MutationObserver-based path.
+ */
+
+/**
+ * External dependencies
+ */
+import { withScope } from '@wordpress/interactivity';
+
+/**
+ * Internal dependencies
+ */
+import { LEGACY_FORM_JQUERY_EVENTS } from './constants';
+import type {
+	LegacyJQueryFormHandlers,
+	LegacyJQueryWindow,
+	LegacyVariationPayload,
+} from './types';
+
+/** A WP attachment ID that's safe to use as a gallery slot. */
+const isValidImageId = ( id: unknown ): id is number =>
+	typeof id === 'number' && Number.isInteger( id ) && id > 0;
+
+/**
+ * Coerce the variation event payload's IDs into a deduped list of
+ * positive integers, with the optional featured image at position 0.
+ */
+const normalizeImageData = (
+	imageIds: unknown,
+	featuredImageId?: number
+): number[] => {
+	const featured = isValidImageId( featuredImageId )
+		? [ featuredImageId ]
+		: [];
+	const others = Array.isArray( imageIds )
+		? imageIds
+				.map( ( id ) => Number.parseInt( String( id ), 10 ) )
+				.filter( isValidImageId )
+		: [];
+	return Array.from( new Set( [ ...featured, ...others ] ) );
+};
+
+/**
+ * Subscribe to the legacy classic Add to Cart form's jQuery variation
+ * events. Returns a teardown callable, or `null` when jQuery isn't
+ * loaded on the page.
+ */
+export const subscribeLegacyJQueryFormVariations = (
+	formElement: HTMLElement,
+	handlers: LegacyJQueryFormHandlers
+): null | ( () => void ) => {
+	const legacyJQuery = ( window as LegacyJQueryWindow ).jQuery;
+	if ( ! legacyJQuery ) {
+		return null;
+	}
+
+	const $form = legacyJQuery( formElement );
+
+	const handleFound = withScope(
+		( _event?: unknown, variation?: LegacyVariationPayload ) => {
+			const imageData = normalizeImageData(
+				variation?.gallery_image_ids,
+				variation?.image_id
+			);
+
+			if ( imageData.length ) {
+				handlers.onVariationFound( imageData, variation?.image_id );
+				return;
+			}
+
+			handlers.onVariationReset();
+		}
+	);
+
+	const handleReset = withScope( () => handlers.onVariationReset() );
+
+	$form
+		.on( LEGACY_FORM_JQUERY_EVENTS.foundVariation, handleFound )
+		.on( LEGACY_FORM_JQUERY_EVENTS.hideOrResetVariation, handleReset );
+
+	return () => {
+		$form.off( LEGACY_FORM_JQUERY_EVENTS.namespace );
+	};
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
index b66c813f7ea..efcf228d2b2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/style.scss
@@ -21,6 +21,9 @@ $dialog-padding: 20px;
 		align-items: center;
 		margin: 0;
 		padding: 0;
+		// Chrome drops `scrollTo({behavior:'smooth'})` on `overflow:hidden`
+		// unless `scroll-behavior` is set on the element.
+		scroll-behavior: smooth;
 	}

 	:where(.wc-block-product-gallery-large-image__wrapper) {
@@ -36,6 +39,10 @@ $dialog-padding: 20px;
 		}
 	}

+	.wc-block-product-gallery-large-image__wrapper[hidden] {
+		display: none;
+	}
+
 	// These rules are overriding styles from WooCommerce core, that's why we
 	// can't use `:where()` and they need a higher specificity.
 	.wc-block-components-product-image.wc-block-components-product-image {
@@ -246,6 +253,10 @@ $dialog-padding: 20px;
 	display: flex;
 }

+.wc-block-product-gallery-thumbnails__thumbnail[hidden] {
+	display: none;
+}
+
 :where(.wc-block-product-gallery-thumbnails__thumbnail__image) {
 	cursor: pointer;
 	max-width: 100%;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
index 250134e1fcd..035c54276d3 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-gallery/types.ts
@@ -1,3 +1,8 @@
+/**
+ * External dependencies
+ */
+import type { WooCommerceConfig } from '@woocommerce/stores/woocommerce/cart';
+
 export interface ProductGalleryBlockAttributes {
 	hoverZoom: boolean;
 	fullScreenOnClick: boolean;
@@ -10,6 +15,41 @@ export interface ProductGallerySettingsProps {
 	) => void;
 }

+export type VariationImageSet = {
+	image_id?: number;
+	image_ids?: number[];
+};
+
+export type ProductImageSet = VariationImageSet & {
+	variations?: Record< number, VariationImageSet >;
+};
+
+export type ProductGalleryConfig = WooCommerceConfig & {
+	products?: Record< string, ProductImageSet >;
+};
+
+export type LegacyVariationPayload = {
+	image_id?: number;
+	gallery_image_ids?: number[];
+};
+
+export type LegacyJQueryInstance = {
+	on: (
+		eventName: string,
+		handler: ( event?: unknown, variation?: LegacyVariationPayload ) => void
+	) => LegacyJQueryInstance;
+	off: ( namespace: string ) => LegacyJQueryInstance;
+};
+
+export type LegacyJQueryWindow = Window & {
+	jQuery?: ( target: Element | string ) => LegacyJQueryInstance;
+};
+
+export type LegacyJQueryFormHandlers = {
+	onVariationFound: ( imageIds: number[], featuredImageId?: number ) => void;
+	onVariationReset: () => void;
+};
+
 export interface ProductGalleryContext {
 	selectedImageId: number;
 	isDialogOpen: boolean;
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh b/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
index 16c2ea038b2..8ba81760616 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
+++ b/plugins/woocommerce/client/blocks/tests/e2e/bin/scripts/products.sh
@@ -34,6 +34,8 @@ wp post term set $hoodie_product_id product_brand $brand_id --by=id
 wp post term set $beanie_product_id product_brand $brand_id --by=id
 wp post term set $album_product_id product_brand $brand_id --by=id

+wp post meta update $beanie_product_id _product_image_gallery "$image1,$image2,$image3"
+
 # This is a non-hacky work around to set up the cross sells product.
 cap_product_id=$(wp post list --post_type=product --field=ID --name="Cap" --format=ids)
 wp post meta update $beanie_product_id _crosssell_ids "$cap_product_id"
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
index a00e6b208ab..7082cd592e7 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-large-image/product-gallery-large-image.block_theme.spec.ts
@@ -162,7 +162,7 @@ test.describe( `${ blockData.name }`, () => {
 		} );
 	} );

-	test( 'Renders correct image when selecting a product variation in the Add to Cart with Options block', async ( {
+	test( 'Variable product gallery: featured → variation → cleared', async ( {
 		page,
 		editor,
 		pageObject,
@@ -173,7 +173,6 @@ test.describe( `${ blockData.name }`, () => {
 		const viewerBlock = await pageObject.getViewerBlock( {
 			page: 'editor',
 		} );
-
 		await expect( viewerBlock ).toBeVisible();

 		await editor.saveSiteEditorEntities( {
@@ -182,24 +181,31 @@ test.describe( `${ blockData.name }`, () => {

 		await page.goto( blockData.productPage );

-		const initialImageId = await pageObject.getViewerImageId();
+		// Initial state: only the parent's featured image is visible.
+		const featuredImageId = await pageObject.getViewerImageId();
+		expect( featuredImageId ).not.toBeNull();

-		const addToCartWithOptionsBlock =
-			await pageObject.getAddToCartWithOptionsBlock( {
-				page: 'frontend',
-			} );
-		const addToCartWithOptionsColorSelector =
-			addToCartWithOptionsBlock.getByLabel( 'Color' );
-		const addToCartWithOptionsSizeSelector =
-			addToCartWithOptionsBlock.getByLabel( 'Logo' );
+		const cartForm = await pageObject.getAddToCartWithOptionsBlock( {
+			page: 'frontend',
+		} );
+		const colorSelect = cartForm.getByLabel( 'Color' );
+		const logoSelect = cartForm.getByLabel( 'Logo' );

-		await addToCartWithOptionsColorSelector.selectOption( 'Green' );
-		await addToCartWithOptionsSizeSelector.selectOption( 'No' );
+		// Variation selected: visible image switches to the variation's image.
+		await colorSelect.selectOption( 'Green' );
+		await logoSelect.selectOption( 'No' );

 		await expect( async () => {
 			const variationImageId = await pageObject.getViewerImageId();
+			expect( variationImageId ).not.toEqual( featuredImageId );
+		} ).toPass( { timeout: 5_000 } );
+
+		// Clear: gallery returns to the parent's featured image.
+		await colorSelect.selectOption( '' );

-			expect( initialImageId ).not.toEqual( variationImageId );
+		await expect( async () => {
+			const afterClearImageId = await pageObject.getViewerImageId();
+			expect( afterClearImageId ).toEqual( featuredImageId );
 		} ).toPass( { timeout: 5_000 } );
 	} );

@@ -216,7 +222,7 @@ test.describe( `${ blockData.name }`, () => {
 				isOnlyCurrentEntityDirty: true,
 			} );

-			await page.goto( blockData.productPage );
+			await page.goto( '/product/beanie/' );

 			await page.setViewportSize( {
 				height: 667,
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
index b1e30c28bd1..0b8305721a7 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
@@ -61,7 +61,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
 		} );

 		await test.step( 'in frontend', async () => {
-			await page.goto( '/product/hoodie/' );
+			await page.goto( '/product/beanie/' );

 			const productGalleryBlock = page.locator(
 				'[data-block-name="woocommerce/product-gallery"]'
@@ -134,7 +134,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
 		} );

 		await test.step( 'in frontend', async () => {
-			await page.goto( '/product/hoodie/' );
+			await page.goto( '/product/beanie/' );

 			const thumbnailsBlock = page.locator(
 				'[data-block-name="woocommerce/product-gallery-thumbnails"]'
@@ -192,7 +192,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
 		} );

 		await test.step( 'in frontend', async () => {
-			await page.goto( '/product/hoodie/' );
+			await page.goto( '/product/beanie/' );

 			const thumbnailsContainer = page.locator(
 				'[data-block-name="woocommerce/product-gallery-thumbnails"]'
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
index 37afb520fa7..75bcc4e2851 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/tests/product-gallery/product-gallery.block_theme.spec.ts
@@ -13,7 +13,7 @@ const blockData = {
 	name: 'woocommerce/product-gallery',
 	title: 'Product Gallery',
 	slug: 'single-product',
-	productPage: '/product/hoodie/',
+	productPage: '/product/beanie/',
 };

 const test = base.extend< { pageObject: ProductGalleryPage } >( {
diff --git a/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss b/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss
new file mode 100644
index 00000000000..28f686f6e82
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/css/variation-gallery-admin.scss
@@ -0,0 +1,421 @@
+/**
+ * Variation gallery (classic admin).
+ */
+
+/* === Local utility mixins ============================================= */
+
+/* Stretch a positioned child to fill its containing block. */
+@mixin fill-parent {
+	position: absolute;
+	inset: 0;
+}
+
+/* Flex container that centers a single child both axes. */
+@mixin center-content {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+/* Make an <img> behave as a cover-fitted block of its parent's size. */
+@mixin cover-image {
+	display: block;
+	width: 100%;
+	height: 100%;
+	object-fit: cover;
+}
+
+#variable_product_options
+	.woocommerce_variation:has(.wc-variation-gallery-field) {
+	/* === Design tokens ============================================== */
+
+	/* Colors (WP admin palette). */
+	--wc-vg-link: #2271b1;
+	--wc-vg-link-hover: #135e96;
+	--wc-vg-surface: #f0f0f1;
+	--wc-vg-surface-hover: #f6f7f7;
+	--wc-vg-surface-strong: #1d2327;
+	--wc-vg-surface-strongest: #000;
+	--wc-vg-on-surface: #fff;
+	--wc-vg-border: #dcdcde;
+	--wc-vg-border-dashed: #c3c4c7;
+	--wc-vg-border-hover: #8c8f94;
+	--wc-vg-text-strong: #1d2327;
+	--wc-vg-text-muted: #50575e;
+	--wc-vg-text-subtle: #787c82;
+	--wc-vg-text-placeholder: #a7aaad;
+	--wc-vg-focus-ring: rgba(34, 113, 177, 0.4);
+
+	/* Spacing scale (4px base). */
+	--wc-vg-space-1: 4px;
+	--wc-vg-space-2: 6px;
+	--wc-vg-space-3: 8px;
+	--wc-vg-space-4: 10px;
+	--wc-vg-space-5: 12px;
+	--wc-vg-space-6: 24px;
+
+	/* Sizing tokens. */
+	--wc-vg-thumb-size: 40px;
+	--wc-vg-icon-sm: 14px;
+	--wc-vg-icon-md: 20px;
+	--wc-vg-icon-lg: 28px;
+	--wc-vg-icon-xl: 42px;
+	--wc-vg-upload-col-width: 200px;
+
+	/* Radii (aliased onto the spacing scale). */
+	--wc-vg-radius-sm: var(--wc-vg-space-1);
+	--wc-vg-radius-md: var(--wc-vg-space-3);
+
+	/* Elevation. */
+	--wc-vg-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.12);
+	--wc-vg-elevation-2: 0 1px 2px rgba(0, 0, 0, 0.2);
+	--wc-vg-elevation-3-hover: 0 1px 4px rgba(0, 0, 0, 0.3),
+		0 0 0 2px var(--wc-vg-focus-ring);
+
+	/* Typography. */
+	--wc-vg-font-size-xs: 12px;
+	--wc-vg-font-size-sm: 13px;
+	--wc-vg-font-weight-medium: 500;
+	--wc-vg-font-weight-semibold: 600;
+
+	/* Motion. */
+	--wc-vg-transition-fast: 120ms ease-out;
+
+	/* === Layout overrides for the gallery's flex row ================= */
+	/* Scoped to the specific .form-flex-box that contains the gallery
+	 * field; other variation rows (pricing, weight, dimensions, etc.)
+	 * use the same .form-flex-box class and must keep their own layout. */
+
+	.form-flex-box:has(.wc-variation-gallery-field) {
+		align-items: flex-start;
+		gap: var(--wc-vg-space-6);
+		justify-content: flex-start;
+
+		.form-row.upload_image {
+			width: var(--wc-vg-upload-col-width);
+			flex: 0 0 var(--wc-vg-upload-col-width);
+			margin: 0;
+			padding: 0;
+		}
+
+		.form-row.upload_image > a.upload_image_button {
+			display: none;
+		}
+
+		.form-row.form-row-last {
+			flex: 1 1 auto;
+			width: auto;
+			float: none;
+			margin: 0;
+		}
+	}
+
+	.wc-variation-gallery-field__thumbs .ui-sortable-helper {
+		pointer-events: none;
+	}
+}
+
+/* === Component ===================================================== */
+
+#variable_product_options .wc-variation-gallery-field {
+	display: flex;
+	flex-direction: column;
+	gap: var(--wc-vg-space-4);
+	width: 100%;
+	box-sizing: border-box;
+
+	.wc-variation-gallery-field__header {
+		display: flex;
+		align-items: baseline;
+		justify-content: space-between;
+		gap: var(--wc-vg-space-4);
+	}
+
+	.wc-variation-gallery-field__title-block {
+		display: flex;
+		align-items: baseline;
+		gap: var(--wc-vg-space-2);
+		flex-wrap: wrap;
+	}
+
+	.wc-variation-gallery-field__title {
+		font-size: var(--wc-vg-font-size-sm);
+		font-weight: var(--wc-vg-font-weight-semibold);
+		margin: 0;
+	}
+
+	.wc-variation-gallery-field__count {
+		color: var(--wc-vg-text-subtle);
+		font-size: var(--wc-vg-font-size-xs);
+
+		&::before {
+			content: "·";
+			margin-right: var(--wc-vg-space-1);
+		}
+	}
+
+	.wc-variation-gallery-manage {
+		color: var(--wc-vg-link);
+		background: transparent;
+		border: none;
+		padding: 0;
+		cursor: pointer;
+		font-size: var(--wc-vg-font-size-sm);
+		text-decoration: none;
+
+		&:hover,
+		&:focus {
+			color: var(--wc-vg-link-hover);
+			text-decoration: underline;
+		}
+	}
+
+	.wc-variation-gallery-field__hero {
+		position: relative;
+		width: 100%;
+		aspect-ratio: 1 / 1;
+		border-radius: var(--wc-vg-radius-md);
+		overflow: hidden;
+		background: var(--wc-vg-surface);
+		border: 1px solid var(--wc-vg-border);
+	}
+
+	.wc-variation-gallery-field__hero-img {
+		@include fill-parent;
+		@include cover-image;
+	}
+
+	.wc-variation-gallery-field__hero-broken {
+		@include fill-parent;
+		@include center-content;
+		color: var(--wc-vg-text-placeholder);
+
+		.dashicons {
+			font-size: var(--wc-vg-icon-xl);
+			width: var(--wc-vg-icon-xl);
+			height: var(--wc-vg-icon-xl);
+		}
+	}
+
+	.wc-variation-gallery-field__badge {
+		position: absolute;
+		top: var(--wc-vg-space-4);
+		right: var(--wc-vg-space-4);
+		display: inline-flex;
+		align-items: center;
+		gap: var(--wc-vg-space-1);
+		padding: var(--wc-vg-space-2) var(--wc-vg-space-4);
+		background: var(--wc-vg-on-surface);
+		border-radius: var(--wc-vg-radius-sm);
+		font-size: var(--wc-vg-font-size-xs);
+		font-weight: var(--wc-vg-font-weight-semibold);
+		color: var(--wc-vg-text-strong);
+		box-shadow: var(--wc-vg-elevation-1);
+
+		.dashicons {
+			font-size: var(--wc-vg-icon-sm);
+			width: var(--wc-vg-icon-sm);
+			height: var(--wc-vg-icon-sm);
+			line-height: 1;
+			color: var(--wc-vg-link);
+		}
+	}
+
+	.wc-variation-gallery-replace {
+		position: absolute;
+		bottom: var(--wc-vg-space-4);
+		right: var(--wc-vg-space-4);
+		background: var(--wc-vg-surface-strong);
+		color: var(--wc-vg-on-surface);
+		border: none;
+		border-radius: var(--wc-vg-radius-sm);
+		padding: var(--wc-vg-space-2) var(--wc-vg-space-5);
+		font-size: var(--wc-vg-font-size-xs);
+		font-weight: var(--wc-vg-font-weight-medium);
+		cursor: pointer;
+		box-shadow: var(--wc-vg-elevation-2);
+		min-height: 0;
+		line-height: 1.3;
+
+		&:hover,
+		&:focus {
+			background: var(--wc-vg-surface-strongest);
+			color: var(--wc-vg-on-surface);
+			box-shadow: var(--wc-vg-elevation-3-hover);
+			outline: none;
+		}
+	}
+
+	.wc-variation-gallery-field__empty-cta {
+		@include fill-parent;
+		display: inline-flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		gap: var(--wc-vg-space-3);
+		background: transparent;
+		border: 2px dashed var(--wc-vg-border-dashed);
+		border-radius: var(--wc-vg-radius-md);
+		color: var(--wc-vg-text-muted);
+		cursor: pointer;
+		font-size: var(--wc-vg-font-size-sm);
+		transition: background-color var(--wc-vg-transition-fast),
+			border-color var(--wc-vg-transition-fast);
+
+		&:hover,
+		&:focus {
+			background: var(--wc-vg-surface-hover);
+			border-color: var(--wc-vg-link);
+			color: var(--wc-vg-link);
+			outline: none;
+		}
+
+		.dashicons {
+			font-size: var(--wc-vg-icon-lg);
+			width: var(--wc-vg-icon-lg);
+			height: var(--wc-vg-icon-lg);
+			line-height: 1;
+		}
+	}
+
+	.wc-variation-gallery-field__thumbs {
+		display: flex;
+		flex-wrap: wrap;
+		gap: var(--wc-vg-space-3);
+		margin: 0;
+		padding: 0;
+		list-style: none;
+		min-height: 0;
+
+		.wc-variation-gallery-thumb,
+		.wc-metabox-sortable-placeholder {
+			width: var(--wc-vg-thumb-size);
+			height: var(--wc-vg-thumb-size);
+			margin: 0;
+			padding: 0;
+			list-style: none;
+			border-radius: var(--wc-vg-radius-sm);
+			box-sizing: border-box;
+		}
+
+		.wc-variation-gallery-thumb {
+			position: relative;
+			border: 2px solid transparent;
+			cursor: grab;
+			transition: border-color var(--wc-vg-transition-fast),
+				box-shadow var(--wc-vg-transition-fast);
+
+			&.is-active {
+				border-color: var(--wc-vg-link);
+			}
+
+			&:not(.is-active):hover {
+				border-color: var(--wc-vg-border-hover);
+			}
+
+			&.is-dragging {
+				opacity: 0.6;
+				cursor: grabbing;
+
+				.wc-variation-gallery-thumb__remove {
+					opacity: 0;
+					pointer-events: none;
+				}
+			}
+
+			&.is-broken {
+				@include center-content;
+				background: var(--wc-vg-surface);
+				color: var(--wc-vg-text-placeholder);
+			}
+
+			&:hover .wc-variation-gallery-thumb__remove,
+			.wc-variation-gallery-thumb__remove:focus-visible {
+				opacity: 1;
+			}
+		}
+
+		.wc-variation-gallery-thumb__button {
+			display: block;
+			width: 100%;
+			height: 100%;
+			padding: 0;
+			margin: 0;
+			border: 0;
+			background: var(--wc-vg-surface);
+			cursor: inherit;
+
+			&:focus {
+				outline: none;
+			}
+
+			img {
+				@include cover-image;
+			}
+		}
+
+		.wc-variation-gallery-thumb__broken .dashicons {
+			font-size: var(--wc-vg-icon-md);
+			width: var(--wc-vg-icon-md);
+			height: var(--wc-vg-icon-md);
+		}
+
+		.wc-variation-gallery-thumb__remove {
+			position: absolute;
+			top: -4px;
+			right: -4px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			width: 18px;
+			height: 18px;
+			border-radius: 50%;
+			background: var(--wc-vg-surface-strong);
+			color: var(--wc-vg-on-surface);
+			cursor: pointer;
+			opacity: 0;
+			transition: opacity var(--wc-vg-transition-fast), background-color var(--wc-vg-transition-fast);
+
+			.dashicons {
+				font-size: 14px;
+				width: 14px;
+				height: 14px;
+				line-height: 1;
+			}
+
+			@media (hover: none) {
+				opacity: 1;
+			}
+
+			@media (prefers-reduced-motion: reduce) {
+				transition: none;
+			}
+		}
+
+		.wc-metabox-sortable-placeholder {
+			border: 2px dashed var(--wc-vg-border-dashed);
+			background: transparent;
+		}
+
+		&.is-sorting .wc-variation-gallery-thumb:hover .wc-variation-gallery-thumb__remove,
+		&.is-sorting .wc-variation-gallery-thumb .wc-variation-gallery-thumb__remove {
+			opacity: 0;
+			pointer-events: none;
+		}
+	}
+
+	&.is-empty .wc-variation-gallery-field__thumbs {
+		display: none;
+	}
+
+	.wc-variation-gallery-field__hint {
+		margin: 0;
+		color: var(--wc-vg-text-muted);
+		font-size: var(--wc-vg-font-size-xs);
+		line-height: 1.4;
+	}
+
+	&.is-empty .wc-variation-gallery-field__hint {
+		display: none;
+	}
+}
diff --git a/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js b/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js
new file mode 100644
index 00000000000..a171b89bbad
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/variation-gallery.js
@@ -0,0 +1,947 @@
+/* global jQuery, wp, wcVariationGalleryL10n */
+
+/*
+ * Variation gallery (classic admin).
+ *
+ * Embedded inside the variation meta box on the classic Edit Product screen.
+ */
+
+jQuery( function ( $ ) {
+	'use strict';
+
+	const SELECTORS = {
+		productOptionsRoot: '#variable_product_options',
+		productData: '#woocommerce-product-data',
+		field: '.wc-variation-gallery-field',
+		fieldThumbList: '.wc-variation-gallery-field__thumbs',
+		fieldHero: '.wc-variation-gallery-field__hero',
+		fieldHeroImg: '.wc-variation-gallery-field__hero-img',
+		fieldHeroBroken: '.wc-variation-gallery-field__hero-broken',
+		fieldHeroEmptyCta: '.wc-variation-gallery-field__empty-cta',
+		fieldHint: '.wc-variation-gallery-field__hint',
+		fieldCount: '.wc-variation-gallery-field__count',
+		fieldImageIdsInput: '.wc-variation-gallery-image-ids',
+		thumb: '.wc-variation-gallery-thumb',
+		thumbButton: '.wc-variation-gallery-thumb__button',
+		thumbRemove: '.wc-variation-gallery-thumb__remove',
+		manageTrigger: '.wc-variation-gallery-manage',
+		replaceTrigger: '.wc-variation-gallery-replace',
+		primaryBadge: '[data-primary-badge]',
+		missingFileLabel: '.screen-reader-text[data-missing-file-label]',
+		// Legacy variation-row fields that we keep in sync.
+		variationRow: '.woocommerce_variation',
+		legacyInput: '.upload_image_id',
+		legacyButton: '.upload_image_button',
+	};
+
+	const CLASSES = {
+		isEmpty: 'is-empty',
+		isActive: 'is-active',
+		isBroken: 'is-broken',
+		fieldHeroImg: 'wc-variation-gallery-field__hero-img',
+		fieldHeroBroken: 'wc-variation-gallery-field__hero-broken',
+	};
+
+	const l10n = window.wcVariationGalleryL10n || {};
+	const a11y = ( wp && wp.a11y ) || null;
+
+	/**
+	 * Speak a message via wp.a11y.speak when available. No-op otherwise.
+	 *
+	 * @param {string} message
+	 */
+	const announce = ( message ) => {
+		if ( message && a11y && typeof a11y.speak === 'function' ) {
+			a11y.speak( message );
+		}
+	};
+
+	/**
+	 * Pick the URL of the largest reasonable preview for the hero slot.
+	 * Prefers `medium`, then `full`, then the raw attachment URL.
+	 *
+	 * @param {{ sizes?: Object, url?: string }} attachmentJson
+	 * @return {string}
+	 */
+	const pickAttachmentDisplayUrl = ( attachmentJson ) => {
+		const sizes = attachmentJson.sizes || {};
+		const preferred = sizes.medium || sizes.full;
+		return ( preferred && preferred.url ) || attachmentJson.url || '';
+	};
+
+	/**
+	 * Pick the thumbnail URL for use in small previews (gallery thumbs and
+	 * the legacy inline preview slot). Falls back to the raw URL when no
+	 * thumbnail variant is registered for this attachment.
+	 *
+	 * @param {{ sizes?: Object, url?: string }} attachmentJson
+	 * @return {string}
+	 */
+	const pickAttachmentThumbnailUrl = ( attachmentJson ) => {
+		const sizes = attachmentJson.sizes || {};
+		return (
+			( sizes.thumbnail && sizes.thumbnail.url ) ||
+			attachmentJson.url ||
+			''
+		);
+	};
+
+	/**
+	 * Map an image count to the i18n template key used to label the field.
+	 *
+	 * @param {number} count
+	 * @return {string}
+	 */
+	const getCountTemplateKey = ( count ) => {
+		if ( count === 0 ) {
+			return 'countZero';
+		}
+		if ( count === 1 ) {
+			return 'countSingular';
+		}
+		return 'countPlural';
+	};
+
+	/**
+	 * Remove all hero-state overlays.
+	 *
+	 * @param {jQuery} $hero
+	 */
+	const clearHeroOverlays = ( $hero ) => {
+		$hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
+		$hero.find( SELECTORS.fieldHeroBroken ).remove();
+		$hero.find( SELECTORS.missingFileLabel ).remove();
+	};
+
+	/**
+	 * Return the existing hero <img> if one is present, or create and
+	 * prepend a fresh one.
+	 *
+	 * @param {jQuery} $hero
+	 * @return {jQuery}
+	 */
+	const getOrCreateHeroImage = ( $hero ) => {
+		const $existing = $hero.find( SELECTORS.fieldHeroImg );
+		if ( $existing.length ) {
+			return $existing;
+		}
+
+		const $img = $( '<img />' )
+			.addClass( CLASSES.fieldHeroImg )
+			.attr( 'loading', 'lazy' )
+			.attr( 'decoding', 'async' );
+		$hero.prepend( $img );
+		return $img;
+	};
+
+	const variationGallery = {
+		/** @type {wp.media.frames.MediaFrame|null} */
+		manageFrame: null,
+		/** @type {wp.media.frames.MediaFrame|null} */
+		replaceFrame: null,
+		/** @type {jQuery|null} */
+		activeField: null,
+		/** @type {number[]} */
+		activePreloadIds: [],
+		/** @type {number|null} */
+		activeIndexForReplace: null,
+		wpMediaPostId: wp.media.model.settings.post.id,
+
+		init() {
+			const $root = $( SELECTORS.productOptionsRoot );
+
+			$root.on(
+				'click',
+				SELECTORS.manageTrigger,
+				this.onManage.bind( this )
+			);
+			$root.on(
+				'click',
+				SELECTORS.replaceTrigger,
+				this.onReplace.bind( this )
+			);
+			$root.on(
+				'click',
+				SELECTORS.thumbButton,
+				this.onThumbClick.bind( this )
+			);
+			$root.on(
+				'click',
+				SELECTORS.thumbRemove,
+				this.onRemoveClick.bind( this )
+			);
+
+			// The meta box re-fires these events when variation rows are paginated
+			// in or appended after a save, so re-initialize sortables each time.
+			$root.on(
+				'woocommerce_variations_added',
+				this.initializeSortables.bind( this )
+			);
+			$( SELECTORS.productData ).on(
+				'woocommerce_variations_loaded',
+				this.initializeSortables.bind( this )
+			);
+
+			this.initializeSortables();
+		},
+
+		initializeSortables() {
+			$( SELECTORS.fieldThumbList ).each( function () {
+				const $list = $( this );
+				const $field = $list.closest( SELECTORS.field );
+
+				variationGallery.updateFromDom( $field );
+
+				if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
+					return;
+				}
+
+				$list.sortable( {
+					items: 'li' + SELECTORS.thumb,
+					cancel: SELECTORS.thumbRemove,
+					cursor: 'grabbing',
+					scrollSensitivity: 40,
+					forcePlaceholderSize: true,
+					helper: 'clone',
+					opacity: 0.65,
+					placeholder: 'wc-metabox-sortable-placeholder',
+					start( _event, ui ) {
+						ui.item.addClass( 'is-dragging' );
+						$list.addClass( 'is-sorting' );
+					},
+					stop( _event, ui ) {
+						ui.item.removeClass( 'is-dragging' );
+						$list.removeClass( 'is-sorting' );
+					},
+					update() {
+						const wasPrimary =
+							variationGallery.getActiveAttachmentId( $field );
+						variationGallery.setActiveIndex( $field, 0 );
+						variationGallery.syncField( $field );
+
+						const isPrimary =
+							variationGallery.getActiveAttachmentId( $field );
+						announce(
+							wasPrimary !== isPrimary
+								? l10n.announcePrimary
+								: l10n.announceReorder
+						);
+					},
+				} );
+
+				$list.data( 'wc-variation-gallery-sortable', true );
+			} );
+		},
+
+		/**
+		 * Click on a thumbnail: surface that image in the hero slot.
+		 *
+		 * Does not change the gallery order or the primary image.
+		 *
+		 * @param {jQuery.Event} event
+		 */
+		onThumbClick( event ) {
+			const $button = $( event.currentTarget );
+			const $thumb = $button.closest( SELECTORS.thumb );
+			const $field = $thumb.closest( SELECTORS.field );
+			const index = $thumb.index();
+
+			event.preventDefault();
+			this.setActiveIndex( $field, index );
+		},
+
+		/**
+		 * Click on a thumbnail's remove button: drop that image from the
+		 * gallery.
+		 *
+		 * @param {jQuery.Event} event
+		 */
+		onRemoveClick( event ) {
+			event.preventDefault();
+			event.stopPropagation();
+
+			const $trigger = $( event.currentTarget );
+			const $thumb = $trigger.closest( SELECTORS.thumb );
+			const $field = $thumb.closest( SELECTORS.field );
+			const removedIndex = $thumb.index();
+			const currentActive = this.getActiveIndex( $field );
+			const ids = this.getFieldIds( $field );
+
+			ids.splice( removedIndex, 1 );
+
+			let nextActive;
+			if ( removedIndex < currentActive ) {
+				nextActive = currentActive - 1;
+			} else if ( removedIndex === currentActive ) {
+				nextActive = Math.min( removedIndex, ids.length - 1 );
+			} else {
+				nextActive = currentActive;
+			}
+
+			this.writeGallery( $field, ids, Math.max( 0, nextActive ) );
+			announce( l10n.announceRemoved );
+		},
+
+		/**
+		 * "Manage" button: open the WP media frame in multi-select mode,
+		 * preselect the variation's current gallery, and rewrite the
+		 * gallery from whatever the merchant selects.
+		 *
+		 * @param {jQuery.Event} event
+		 */
+		onManage( event ) {
+			const $trigger = $( event.currentTarget );
+			const $field = $trigger.closest( SELECTORS.field );
+			const variationId = $field.data( 'variationId' );
+
+			event.preventDefault();
+
+			// Scope newly-uploaded attachments to this variation post.
+			wp.media.model.settings.post.id = variationId;
+
+			// Update per-open state read by the cached frame's handlers.
+			this.activeField = $field;
+			this.activePreloadIds = this.getFieldIds( $field );
+
+			if ( ! this.manageFrame ) {
+				this.manageFrame = wp.media( {
+					title: l10n.manageTitle,
+					library: { type: 'image' },
+					button: { text: l10n.manageButton },
+					multiple: 'add',
+				} );
+
+				this.manageFrame.on( 'open', () =>
+					this.preloadFrameSelection(
+						this.manageFrame,
+						this.activePreloadIds
+					)
+				);
+				this.manageFrame.on( 'select', () =>
+					this.onManageSelect( this.manageFrame, this.activeField )
+				);
+				this.manageFrame.on( 'close', () => this.restoreMediaPostId() );
+			}
+
+			this.manageFrame.open();
+		},
+
+		/**
+		 * "Replace" button on the hero slot: open the WP media frame in
+		 * single-select mode and swap the currently-active gallery slot
+		 * with the chosen attachment.
+		 *
+		 * @param {jQuery.Event} event
+		 */
+		onReplace( event ) {
+			const $trigger = $( event.currentTarget );
+			const $field = $trigger.closest( SELECTORS.field );
+			const variationId = $field.data( 'variationId' );
+
+			event.preventDefault();
+
+			wp.media.model.settings.post.id = variationId;
+
+			this.activeField = $field;
+			this.activeIndexForReplace = this.getActiveIndex( $field );
+
+			if ( ! this.replaceFrame ) {
+				this.replaceFrame = wp.media( {
+					title: l10n.replaceTitle,
+					library: { type: 'image' },
+					button: { text: l10n.replaceButton },
+					multiple: false,
+				} );
+
+				this.replaceFrame.on( 'select', () =>
+					this.onReplaceSelect( this.replaceFrame, this.activeField )
+				);
+				this.replaceFrame.on( 'close', () =>
+					this.restoreMediaPostId()
+				);
+			}
+
+			this.replaceFrame.open();
+		},
+
+		/**
+		 * When the manage frame opens, populate its selection with the
+		 * variation's current gallery.
+		 *
+		 * @param {wp.media.frames.MediaFrame} frame
+		 * @param {number[]}                   currentIds
+		 */
+		preloadFrameSelection( frame, currentIds ) {
+			if ( ! frame ) {
+				return;
+			}
+
+			const selection = frame.state().get( 'selection' );
+			if ( ! selection ) {
+				return;
+			}
+
+			selection.reset();
+
+			currentIds.forEach( ( id ) => {
+				const attachment = wp.media.attachment( id );
+				attachment.fetch();
+				selection.add( attachment );
+			} );
+		},
+
+		/**
+		 * Manage frame "select" handler: read attachments out of the
+		 * frame's selection model and rewrite the gallery to match.
+		 *
+		 * @param {wp.media.frames.MediaFrame} frame
+		 * @param {jQuery}                     $field
+		 */
+		onManageSelect( frame, $field ) {
+			const selection = frame.state().get( 'selection' );
+			const nextIds = [];
+
+			selection.each( ( attachment ) => {
+				const json = attachment.toJSON();
+				if ( json.id ) {
+					nextIds.push( Number( json.id ) );
+				}
+			} );
+
+			this.writeGallery( $field, nextIds );
+			announce( l10n.announceUpdated );
+			this.restoreMediaPostId();
+		},
+
+		/**
+		 * Replace frame "select" handler: swap the attachment at the
+		 * cached active index with the chosen one and keep that slot
+		 * surfaced as the hero image.
+		 *
+		 * @param {wp.media.frames.MediaFrame} frame
+		 * @param {jQuery}                     $field
+		 */
+		onReplaceSelect( frame, $field ) {
+			const selection = frame.state().get( 'selection' );
+			const attachment = selection.first();
+
+			if ( ! attachment ) {
+				return;
+			}
+
+			const newId = Number( attachment.toJSON().id );
+			const index = this.activeIndexForReplace;
+			const ids = this.getFieldIds( $field );
+
+			if ( index === null || index < 0 || index >= ids.length ) {
+				return;
+			}
+
+			ids[ index ] = newId;
+			this.writeGallery( $field, ids, index );
+			announce( l10n.announceReplaced );
+			this.restoreMediaPostId();
+		},
+
+		/**
+		 * Replace the field's gallery with the given ID list, dedupe,
+		 * re-render thumbs, surface the active slot, and sync the field.
+		 *
+		 * @param {jQuery}   $field
+		 * @param {number[]} nextIds
+		 * @param {number}   [activeIndex=0]
+		 */
+		writeGallery( $field, nextIds, activeIndex = 0 ) {
+			const uniqueIds = Array.from( new Set( nextIds ) ).filter(
+				( id ) => Number.isInteger( id ) && id > 0
+			);
+
+			this.rebuildThumbs( $field, uniqueIds );
+			this.setActiveIndex(
+				$field,
+				Math.min( activeIndex, Math.max( uniqueIds.length - 1, 0 ) )
+			);
+			this.syncField( $field );
+		},
+
+		/**
+		 * Read the cached thumbnail/hero `src` for an attachment from the
+		 * existing field DOM. Server-rendered <img> tags carry valid URLs
+		 * even for attachments that haven't been loaded into wp.media's
+		 * client cache (e.g. migrator-imported images), so prefer the DOM
+		 * over `wp.media.attachment(id).attributes`.
+		 *
+		 * @param {jQuery} $field
+		 * @param {number} id
+		 * @return {string}
+		 */
+		getCachedAttachmentUrl( $field, id ) {
+			const $existingThumb = $field.find(
+				SELECTORS.thumb +
+					'[data-attachment_id="' +
+					id +
+					'"] ' +
+					SELECTORS.thumbButton +
+					' img'
+			);
+
+			if ( $existingThumb.length ) {
+				return $existingThumb.attr( 'src' ) || '';
+			}
+
+			const $existingHero = $field.find(
+				SELECTORS.fieldHeroImg + '[data-id="' + id + '"]'
+			);
+
+			if ( $existingHero.length ) {
+				return $existingHero.attr( 'src' ) || '';
+			}
+
+			return '';
+		},
+
+		/**
+		 * Resolve a usable URL for the given attachment, preferring the
+		 * server-rendered DOM and falling back to the media-frame cache
+		 * (which is hydrated for attachments the merchant just selected
+		 * via wp.media).
+		 *
+		 * @param {jQuery}                              $field
+		 * @param {number}                              id
+		 * @param {(json: Object) => string}            pick
+		 * @return {string}
+		 */
+		resolveAttachmentUrl( $field, id, pick ) {
+			const cached = this.getCachedAttachmentUrl( $field, id );
+			if ( cached ) {
+				return cached;
+			}
+
+			const attachment = wp.media.attachment( id );
+			return pick( attachment.attributes || {} );
+		},
+
+		/**
+		 * Re-render the thumbnail list from scratch for the given IDs.
+		 * Caller is responsible for ensuring the IDs are unique and
+		 * non-empty before this is invoked.
+		 *
+		 * @param {jQuery}   $field
+		 * @param {number[]} ids
+		 */
+		rebuildThumbs( $field, ids ) {
+			const $list = $field.find( SELECTORS.fieldThumbList );
+			const urls = ids.map( ( id ) =>
+				this.resolveAttachmentUrl(
+					$field,
+					id,
+					pickAttachmentThumbnailUrl
+				)
+			);
+
+			$list.empty();
+
+			ids.forEach( ( id, index ) => {
+				$list.append(
+					this.buildThumbMarkup( id, urls[ index ], index === 0 )
+				);
+			} );
+
+			if ( $list.data( 'wc-variation-gallery-sortable' ) ) {
+				$list.sortable( 'refresh' );
+			}
+		},
+
+		/**
+		 * Build the markup for a single thumbnail list item.
+		 *
+		 * Renders a "missing file" placeholder when no thumbnail
+		 * URL is available.
+		 *
+		 * @param {number}  id
+		 * @param {string}  thumbnailUrl
+		 * @param {boolean} isActive
+		 * @return {jQuery}
+		 */
+		buildThumbMarkup( id, thumbnailUrl, isActive ) {
+			const labelTemplate = l10n.thumbLabel || 'Show gallery image %d';
+			const label = labelTemplate.replace( '%d', id );
+			const $li = $( '<li></li>' )
+				.addClass( 'wc-variation-gallery-thumb' )
+				.toggleClass( CLASSES.isActive, isActive )
+				.attr( 'data-attachment_id', id );
+			const $button = $( '<button type="button"></button>' )
+				.addClass( 'wc-variation-gallery-thumb__button' )
+				.attr( 'aria-label', label );
+			const $remove = $( '<button type="button"></button>' )
+				.addClass( 'wc-variation-gallery-thumb__remove' )
+				.attr( 'aria-label', l10n.removeLabel || 'Remove image' )
+				.append(
+					$( '<span></span>' )
+						.addClass( 'dashicons dashicons-no-alt' )
+						.attr( 'aria-hidden', 'true' )
+				);
+
+			if ( thumbnailUrl ) {
+				const $img = $( '<img />' )
+					.attr( 'src', thumbnailUrl )
+					.attr( 'alt', '' );
+				$button.append( $img );
+				return $li.append( $button, $remove );
+			}
+
+			$li.addClass( CLASSES.isBroken );
+
+			const $brokenIcon = $( '<span></span>' ).addClass(
+				'dashicons dashicons-format-image'
+			);
+			const $brokenWrapper = $( '<span></span>' )
+				.addClass( 'wc-variation-gallery-thumb__broken' )
+				.attr( 'aria-hidden', 'true' )
+				.append( $brokenIcon );
+			const $srLabel = $( '<span></span>' )
+				.addClass( 'screen-reader-text' )
+				.text( l10n.missingFileLabel || 'Attachment file missing' );
+
+			$button.append( $brokenWrapper, $srLabel );
+			return $li.append( $button, $remove );
+		},
+
+		/**
+		 * Surface the slot at `index` in the hero area and mark its thumb
+		 * as active. Falls back to the empty state if there are no images.
+		 *
+		 * @param {jQuery} $field
+		 * @param {number} index
+		 */
+		setActiveIndex( $field, index ) {
+			const ids = this.getFieldIds( $field );
+
+			if ( ! ids.length ) {
+				this.setHeroEmpty( $field );
+				return;
+			}
+
+			const safeIndex = Math.max( 0, Math.min( index, ids.length - 1 ) );
+			const activeId = ids[ safeIndex ];
+
+			$field
+				.find( SELECTORS.fieldHero )
+				.attr( 'data-active-index', safeIndex );
+
+			this.setHeroImage( $field, activeId, safeIndex === 0 );
+
+			$field.find( SELECTORS.thumb ).removeClass( CLASSES.isActive );
+			$field
+				.find(
+					SELECTORS.thumb + '[data-attachment_id="' + activeId + '"]'
+				)
+				.addClass( CLASSES.isActive );
+		},
+
+		/**
+		 * @param {jQuery} $field
+		 * @return {number}
+		 */
+		getActiveIndex( $field ) {
+			return Number(
+				$field
+					.find( SELECTORS.fieldHero )
+					.attr( 'data-active-index' ) || 0
+			);
+		},
+
+		/**
+		 * @param {jQuery} $field
+		 * @return {number}
+		 */
+		getActiveAttachmentId( $field ) {
+			const ids = this.getFieldIds( $field );
+
+			if ( ! ids.length ) {
+				return 0;
+			}
+
+			return ids[ this.getActiveIndex( $field ) ] || ids[ 0 ];
+		},
+
+		/**
+		 * Render the given attachment in the hero slot. Falls through to
+		 * the missing-file state if the attachment record has no usable
+		 * URL (e.g. the underlying file has been deleted).
+		 *
+		 * @param {jQuery}  $field
+		 * @param {number}  attachmentId
+		 * @param {boolean} isPrimary
+		 */
+		setHeroImage( $field, attachmentId, isPrimary ) {
+			const $hero = $field.find( SELECTORS.fieldHero );
+			const url = this.resolveAttachmentUrl(
+				$field,
+				attachmentId,
+				pickAttachmentDisplayUrl
+			);
+
+			if ( ! url ) {
+				this.setHeroMissingFile( $field, isPrimary );
+				return;
+			}
+
+			clearHeroOverlays( $hero );
+
+			const $img = getOrCreateHeroImage( $hero );
+			$img.attr( 'src', url )
+				.attr( 'data-id', attachmentId )
+				.attr( 'alt', '' );
+
+			this.ensureHeroControls( $hero, isPrimary );
+		},
+
+		/**
+		 * Render the "attachment file is missing" placeholder in the hero
+		 * slot. Used when an attachment row exists but the underlying file
+		 * has been deleted or is otherwise unreachable.
+		 *
+		 * @param {jQuery}  $field
+		 * @param {boolean} isPrimary
+		 */
+		setHeroMissingFile( $field, isPrimary ) {
+			const $hero = $field.find( SELECTORS.fieldHero );
+
+			$hero.find( SELECTORS.fieldHeroEmptyCta ).remove();
+			$hero.find( SELECTORS.fieldHeroImg ).remove();
+
+			if ( ! $hero.find( SELECTORS.fieldHeroBroken ).length ) {
+				const $brokenIcon = $( '<span></span>' ).addClass(
+					'dashicons dashicons-format-image'
+				);
+				const $brokenWrapper = $( '<span></span>' )
+					.addClass( CLASSES.fieldHeroBroken )
+					.attr( 'aria-hidden', 'true' )
+					.append( $brokenIcon );
+				const $srLabel = $( '<span></span>' )
+					.addClass( 'screen-reader-text' )
+					.attr( 'data-missing-file-label', 'true' )
+					.text( l10n.missingFileLabel || 'Attachment file missing' );
+
+				$hero.prepend( $brokenWrapper, $srLabel );
+			}
+
+			this.ensureHeroControls( $hero, isPrimary );
+		},
+
+		/**
+		 * Ensure the primary-image badge and the Replace button are
+		 * present in the hero slot. Idempotent.
+		 *
+		 * @param {jQuery}  $hero
+		 * @param {boolean} isPrimary
+		 */
+		ensureHeroControls( $hero, isPrimary ) {
+			if ( ! $hero.find( SELECTORS.primaryBadge ).length ) {
+				const $badgeIcon = $( '<span></span>' ).addClass(
+					'dashicons dashicons-star-filled'
+				);
+				const badgeLabel = document.createTextNode(
+					' ' + ( l10n.primaryLabel || 'Primary' )
+				);
+				const $badge = $( '<span></span>' )
+					.addClass( 'wc-variation-gallery-field__badge' )
+					.attr( 'data-primary-badge', '' )
+					.attr( 'aria-hidden', 'true' )
+					.append( $badgeIcon )
+					.append( badgeLabel );
+
+				$hero.append( $badge );
+			}
+
+			$hero.find( SELECTORS.primaryBadge ).toggle( Boolean( isPrimary ) );
+
+			if ( ! $hero.find( SELECTORS.replaceTrigger ).length ) {
+				const $replace = $( '<button type="button"></button>' )
+					.addClass( 'button wc-variation-gallery-replace' )
+					.text( l10n.replaceLabel || 'Replace' );
+
+				$hero.append( $replace );
+			}
+		},
+
+		/**
+		 * Render the empty-gallery state in the hero slot: a single CTA
+		 * that opens the WP media frame.
+		 *
+		 * @param {jQuery} $field
+		 */
+		setHeroEmpty( $field ) {
+			const $hero = $field.find( SELECTORS.fieldHero );
+
+			const $ctaIcon = $( '<span></span>' )
+				.addClass( 'dashicons dashicons-plus-alt2' )
+				.attr( 'aria-hidden', 'true' );
+			const ctaLabel = document.createTextNode(
+				' ' + ( l10n.emptyCtaLabel || 'Add variation images' )
+			);
+			const $cta = $( '<button type="button"></button>' )
+				.addClass(
+					'wc-variation-gallery-field__empty-cta wc-variation-gallery-manage'
+				)
+				.append( $ctaIcon )
+				.append( ctaLabel );
+
+			$hero.empty().attr( 'data-active-index', 0 ).append( $cta );
+			$field.addClass( CLASSES.isEmpty );
+		},
+
+		/**
+		 * @param {jQuery} $field
+		 * @return {number[]}
+		 */
+		getFieldIds( $field ) {
+			return $field
+				.find( SELECTORS.thumb )
+				.map( function () {
+					return Number( $( this ).attr( 'data-attachment_id' ) );
+				} )
+				.get()
+				.filter( ( id ) => Number.isInteger( id ) && id > 0 );
+		},
+
+		/**
+		 * Persist current DOM state back into the hidden form input and
+		 * the legacy single-image slot, then refresh the count label.
+		 *
+		 * @param {jQuery} $field
+		 */
+		syncField( $field ) {
+			const ids = this.getFieldIds( $field );
+			const primaryId = ids[ 0 ] || '';
+
+			$field
+				.find( SELECTORS.fieldImageIdsInput )
+				.val( ids.join( ',' ) )
+				.trigger( 'change' );
+
+			this.syncLegacyImageSlot( $field, primaryId );
+			this.updateFromDom( $field, ids.length );
+		},
+
+		/**
+		 * Keep the existing single-image variation field (`upload_image_id`,
+		 * its upload-image button, and the inline preview) in sync with the
+		 * gallery's primary image. This lets the existing variation save
+		 * path persist the featured image without changes.
+		 *
+		 * @param {jQuery}        $field
+		 * @param {number|string} primaryId Attachment ID, or empty string when no primary is set.
+		 */
+		syncLegacyImageSlot( $field, primaryId ) {
+			const $row = $field.closest( SELECTORS.variationRow );
+
+			this.updateLegacyInput( $row, primaryId );
+			this.updateLegacyButton( $row, primaryId );
+			this.updateLegacyPreview( $row, primaryId );
+		},
+
+		/**
+		 * Mirror the gallery's primary image into the hidden
+		 * `upload_image_id[ loop ]` input that the variation save path
+		 * already reads.
+		 *
+		 * @param {jQuery}        $row
+		 * @param {number|string} primaryId
+		 */
+		updateLegacyInput( $row, primaryId ) {
+			const $input = $row.find( SELECTORS.legacyInput );
+			if ( ! $input.length ) {
+				return;
+			}
+			$input.val( primaryId ).trigger( 'change' );
+		},
+
+		/**
+		 * Toggle the upload-image button's "remove" affordance based on
+		 * whether a primary image is currently set.
+		 *
+		 * @param {jQuery}        $row
+		 * @param {number|string} primaryId
+		 */
+		updateLegacyButton( $row, primaryId ) {
+			const $button = $row.find( SELECTORS.legacyButton );
+			if ( ! $button.length ) {
+				return;
+			}
+			$button.toggleClass( 'remove', Boolean( primaryId ) );
+		},
+
+		/**
+		 * Update the inline thumbnail preview inside the upload-image
+		 * button. Stashes the original placeholder src on the first call
+		 * so it can be restored when the merchant clears the gallery.
+		 *
+		 * @param {jQuery}        $row
+		 * @param {number|string} primaryId
+		 */
+		updateLegacyPreview( $row, primaryId ) {
+			const $preview = $row
+				.find( SELECTORS.legacyButton )
+				.find( 'img' )
+				.first();
+			if ( ! $preview.length ) {
+				return;
+			}
+
+			// Stash the placeholder src on first call so we can restore it later.
+			if ( ! $preview.attr( 'data-placeholder-src' ) ) {
+				$preview.attr(
+					'data-placeholder-src',
+					$preview.attr( 'src' ) || ''
+				);
+			}
+
+			if ( ! primaryId ) {
+				$preview.attr(
+					'src',
+					$preview.attr( 'data-placeholder-src' ) || ''
+				);
+				return;
+			}
+
+			const $field = $row.find( SELECTORS.field );
+			const url = this.resolveAttachmentUrl(
+				$field,
+				primaryId,
+				pickAttachmentThumbnailUrl
+			);
+			if ( url ) {
+				$preview.attr( 'src', url );
+			}
+		},
+
+		/**
+		 * Refresh the count label, the empty-state class, and the hint
+		 * visibility from the field's current image count. Pass an
+		 * explicit count to skip the DOM lookup.
+		 *
+		 * @param {jQuery}      $field
+		 * @param {number|null} [precomputedCount=null]
+		 */
+		updateFromDom( $field, precomputedCount = null ) {
+			const count =
+				precomputedCount === null
+					? this.getFieldIds( $field ).length
+					: precomputedCount;
+			const template = l10n[ getCountTemplateKey( count ) ] || '%d';
+			const label = template.replace( '%d', count );
+
+			$field.toggleClass( CLASSES.isEmpty, count === 0 );
+			$field.find( SELECTORS.fieldCount ).text( label );
+			$field.find( SELECTORS.fieldHint ).prop( 'hidden', count === 0 );
+		},
+
+		restoreMediaPostId() {
+			wp.media.model.settings.post.id = this.wpMediaPostId;
+		},
+	};
+
+	variationGallery.init();
+} );
diff --git a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
index b4334f7befa..b51d8d35450 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart-variation.js
@@ -869,22 +869,181 @@
 		}
 	};

+	/**
+	 * Check whether the current gallery is using variation-specific markup.
+	 */
+	$.fn.wc_variations_gallery_is_active = function () {
+		return 'yes' === $( this ).attr( 'data-product_gallery_active' );
+	};
+
+	/**
+	 * Get the default product gallery HTML used for restoring classic templates.
+	 *
+	 * Prefer the snapshot embedded in this form. Fall back to the page-level
+	 * defaults for older template overrides that don't include it.
+	 */
+	$.fn.wc_get_default_product_gallery_html = function () {
+		var $form = $( this );
+		var $template = $form.find( '.wc-product-gallery-default-template' );
+
+		if ( $template.length ) {
+			return $.trim( $template.html() );
+		}
+
+		var defaults = window.wc_variation_gallery_defaults || {};
+		return defaults[ $form.data( 'product_id' ) ] || '';
+	};
+
+	/**
+	 * Find the gallery root within a container.
+	 */
+	$.fn.wc_find_gallery_root = function () {
+		return $( this )
+			.find( '.woocommerce-product-gallery' )
+			.addBack( '.woocommerce-product-gallery' )
+			.first();
+	};
+
+	/**
+	 * Replace the current classic product gallery markup and reinitialize gallery behaviors.
+	 */
+	$.fn.wc_variations_gallery_replace = function ( gallery_html, variation ) {
+		var $form = $( this ),
+			$product = $form.closest( '.product' ),
+			$current_gallery = $product.wc_find_gallery_root(),
+			$parsed_gallery = $( $.parseHTML( $.trim( gallery_html ) ) ),
+			$new_gallery = $parsed_gallery.wc_find_gallery_root(),
+			current_image_id =
+				variation && variation.image_id ? variation.image_id : '';
+
+		if ( ! $current_gallery.length || ! $new_gallery.length ) {
+			return false;
+		}
+
+		/**
+		 * Notify subscribers that the current gallery DOM is about to be
+		 * removed and replaced.
+		 *
+		 * @event wc-product-gallery-before-destroy
+		 * @since 10.8.0
+		 * @param {HTMLElement} galleryEl - The gallery root element being torn down.
+		 * @param {Object}      params    - The `wc_single_product_params` object.
+		 */
+		if ( typeof wc_single_product_params !== 'undefined' ) {
+			$current_gallery.trigger( 'wc-product-gallery-before-destroy', [
+				$current_gallery[ 0 ],
+				wc_single_product_params,
+			] );
+		}
+
+		$current_gallery.replaceWith( $new_gallery );
+
+		if (
+			typeof $.fn.wc_product_gallery === 'function' &&
+			typeof wc_single_product_params !== 'undefined'
+		) {
+			$new_gallery.trigger( 'wc-product-gallery-before-init', [
+				$new_gallery[ 0 ],
+				wc_single_product_params,
+			] );
+
+			$new_gallery.wc_product_gallery( wc_single_product_params );
+
+			$new_gallery.trigger( 'wc-product-gallery-after-init', [
+				$new_gallery[ 0 ],
+				wc_single_product_params,
+			] );
+		} else {
+			$new_gallery.css( 'opacity', 1 );
+		}
+
+		$form.attr( 'current-image', current_image_id );
+		$form.attr( 'data-product_gallery_active', 'yes' );
+
+		return true;
+	};
+
+	/**
+	 * Restore the default classic product gallery markup after a variation gallery swap.
+	 */
+	$.fn.wc_variations_gallery_reset = function () {
+		var $form = $( this ),
+			default_gallery_html = $form.wc_get_default_product_gallery_html(),
+			reset_gallery = false;
+
+		if ( default_gallery_html.length ) {
+			reset_gallery = $form.wc_variations_gallery_replace(
+				default_gallery_html,
+				false
+			);
+		}
+
+		if ( reset_gallery ) {
+			$form.removeAttr( 'data-product_gallery_active' );
+			$form.attr( 'current-image', '' );
+		}
+
+		return reset_gallery;
+	};
+
 	/**
 	 * Sets product images for the chosen variation
 	 */
 	$.fn.wc_variations_image_update = function ( variation ) {
 		var $form = this,
 			$product = $form.closest( '.product' ),
-			$product_gallery = $product.find( '.images' ),
-			$gallery_nav = $product.find( '.flex-control-nav' ),
-			$gallery_img = $gallery_nav.find( 'li:eq(0) img' ),
-			$product_img_wrap = $product_gallery
-				.find(
-					'.woocommerce-product-gallery__image, .woocommerce-product-gallery__image--placeholder'
-				)
-				.eq( 0 ),
-			$product_img = $product_img_wrap.find( '.wp-post-image' ),
-			$product_link = $product_img_wrap.find( 'a' ).eq( 0 );
+			$product_gallery,
+			$gallery_nav,
+			$gallery_img,
+			$product_img_wrap,
+			$product_img,
+			$product_link;
+
+		if (
+			variation &&
+			variation.gallery_images_html &&
+			variation.gallery_images_html.length &&
+			$form.wc_variations_gallery_replace(
+				variation.gallery_images_html,
+				variation
+			)
+		) {
+			window.setTimeout( function () {
+				$( window ).trigger( 'resize' );
+			}, 20 );
+
+			return;
+		}
+
+		if ( $form.wc_variations_gallery_is_active() ) {
+			var reset_succeeded = $form.wc_variations_gallery_reset();
+
+			if ( ! variation ) {
+				if ( reset_succeeded ) {
+					window.setTimeout( function () {
+						$( window ).trigger( 'resize' );
+					}, 20 );
+				}
+				return;
+			}
+
+			if ( ! reset_succeeded ) {
+				// Don't fall through to the single-image mutation path without
+				// a clean reset snapshot.
+				return;
+			}
+		}
+
+		$product_gallery = $product.find( '.images' );
+		$gallery_nav = $product.find( '.flex-control-nav' );
+		$gallery_img = $gallery_nav.find( 'li:eq(0) img' );
+		$product_img_wrap = $product_gallery
+			.find(
+				'.woocommerce-product-gallery__image, .woocommerce-product-gallery__image--placeholder'
+			)
+			.eq( 0 );
+		$product_img = $product_img_wrap.find( '.wp-post-image' );
+		$product_link = $product_img_wrap.find( 'a' ).eq( 0 );

 		if (
 			variation &&
@@ -1002,6 +1161,11 @@
 			$product_img = $product_img_wrap.find( '.wp-post-image' ),
 			$product_link = $product_img_wrap.find( 'a' ).eq( 0 );

+		if ( $form.wc_variations_gallery_is_active() ) {
+			$form.wc_variations_gallery_reset();
+			return;
+		}
+
 		$product_img.wc_reset_variation_attr( 'src' );
 		$product_img.wc_reset_variation_attr( 'width' );
 		$product_img.wc_reset_variation_attr( 'height' );
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index d0ded7453fd..ea0bb0d9c4a 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -71,11 +71,23 @@ defined( 'ABSPATH' ) || exit;
 	<div class="woocommerce_variable_attributes wc-metabox-content" style="display: none;">
 		<div class="data">
 			<div class="form-flex-box">
-				<p class="form-row upload_image">
+				<div class="form-row upload_image">
 					<a href="#" class="upload_image_button tips <?php echo $variation_object->get_image_id( 'edit' ) ? 'remove' : ''; ?>" data-tip="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_attr__( 'Remove this image', 'woocommerce' ) : esc_attr__( 'Upload an image', 'woocommerce' ); ?>" rel="<?php echo esc_attr( $variation_id ); ?>">
 						<img src="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_url( wp_get_attachment_thumb_url( $variation_object->get_image_id( 'edit' ) ) ) : esc_url( wc_placeholder_img_src() ); ?>" /><input type="hidden" name="upload_image_id[<?php echo esc_attr( $loop ); ?>]" class="upload_image_id" value="<?php echo esc_attr( $variation_object->get_image_id( 'edit' ) ); ?>" />
 					</a>
-				</p>
+					<?php
+					/**
+					 * Variation upload image action.
+					 *
+					 * @since 10.8.0
+					 *
+					 * @param int     $loop           Position in the loop.
+					 * @param array   $variation_data Variation data.
+					 * @param WP_Post $variation      Post data.
+					 */
+					do_action( 'woocommerce_variation_after_upload_image', $loop, $variation_data, $variation );
+					?>
+				</div>
 				<div class="form-row form-row-last">
 				<?php
 				if ( wc_product_sku_enabled() ) {
diff --git a/plugins/woocommerce/includes/class-wc-product-variable.php b/plugins/woocommerce/includes/class-wc-product-variable.php
index a2c971cfe9b..943c2db3546 100644
--- a/plugins/woocommerce/includes/class-wc-product-variable.php
+++ b/plugins/woocommerce/includes/class-wc-product-variable.php
@@ -10,6 +10,7 @@

 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;

 defined( 'ABSPATH' ) || exit;

@@ -416,6 +417,45 @@ class WC_Product_Variable extends WC_Product {
 		if ( ! $variation instanceof WC_Product_Variation ) {
 			return false;
 		}
+
+		$variation_featured_id    = (int) $variation->get_image_id();
+		$variation_featured_valid = $variation_featured_id && wp_attachment_is_image( $variation_featured_id );
+		$parent_featured_id       = (int) $this->get_image_id();
+		$parent_featured_valid    = $parent_featured_id && wp_attachment_is_image( $parent_featured_id );
+
+		$variation_gallery_image_ids = array();
+		$variation_gallery_html      = '';
+
+		if ( VariationGalleryPackage::is_enabled() ) {
+			$variation_gallery_image_ids = array_values(
+				array_filter(
+					array_map( 'intval', $variation->get_gallery_image_ids() ),
+					'wp_attachment_is_image'
+				)
+			);
+		}
+
+		// Prefer variation-owned images over the parent fallback.
+		if ( $variation_featured_valid ) {
+			$selected_image_id = $variation_featured_id;
+		} elseif ( ! empty( $variation_gallery_image_ids ) ) {
+			$selected_image_id = $variation_gallery_image_ids[0];
+		} elseif ( $parent_featured_valid ) {
+			$selected_image_id = $parent_featured_id;
+		} else {
+			$selected_image_id = 0;
+		}
+
+		if ( ! empty( $variation_gallery_image_ids ) ) {
+			$gallery_html_ids = $variation_gallery_image_ids;
+
+			if ( $selected_image_id && ! in_array( $selected_image_id, $gallery_html_ids, true ) ) {
+				array_unshift( $gallery_html_ids, $selected_image_id );
+			}
+
+			$variation_gallery_html = wc_get_product_gallery_html( $this, $gallery_html_ids );
+		}
+
 		// See if prices should be shown for each variation after selection.
 		$show_variation_price = apply_filters( 'woocommerce_show_variation_price', $variation->get_price() === '' || $this->get_variation_sale_price( 'min' ) !== $this->get_variation_sale_price( 'max' ) || $this->get_variation_regular_price( 'min' ) !== $this->get_variation_regular_price( 'max' ), $this, $variation );

@@ -429,8 +469,10 @@ class WC_Product_Variable extends WC_Product {
 				'dimensions_html'       => wc_format_dimensions( $variation->get_dimensions( false ) ),
 				'display_price'         => wc_get_price_to_display( $variation ),
 				'display_regular_price' => wc_get_price_to_display( $variation, array( 'price' => $variation->get_regular_price() ) ),
-				'image'                 => wc_get_product_attachment_props( $variation->get_image_id() ),
-				'image_id'              => $variation->get_image_id(),
+				'gallery_image_ids'     => $variation_gallery_image_ids,
+				'gallery_images_html'   => $variation_gallery_html,
+				'image'                 => wc_get_product_attachment_props( $selected_image_id ),
+				'image_id'              => $selected_image_id,
 				'is_downloadable'       => $variation->is_downloadable(),
 				'is_in_stock'           => $variation->is_in_stock(),
 				'is_purchasable'        => $variation->is_purchasable(),
@@ -659,7 +701,8 @@ class WC_Product_Variable extends WC_Product {
 			$data_store = WC_Data_Store::load( 'product-' . $product->get_type() );
 			$data_store->sync_price( $product );
 			$data_store->sync_stock_status( $product );
-			self::sync_attributes( $product ); // Legacy update of attributes.
+			self::sync_attributes( $product );
+			// Legacy update of attributes.

 			do_action( 'woocommerce_variable_product_sync_data', $product );

diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 5999b66ea5c..c631625f155 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -394,6 +394,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Settings\PaymentsProviders\WooPayments\WooPaymentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
 		$container->get( Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 1582a04d0b9..3ed2b60d4d9 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -12,6 +12,8 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use Automattic\WooCommerce\Internal\VariationGallery\Telemetry as VariationGalleryTelemetry;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;

@@ -155,6 +157,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			'shipping_class'        => $object->get_shipping_class(),
 			'shipping_class_id'     => $object->get_shipping_class_id(),
 			'image'                 => $this->get_image( $object, $context ),
+			'gallery_image_ids'     => $object instanceof WC_Product ? array_map( 'intval', $object->get_gallery_image_ids() ) : array(),
 			'attributes'            => $this->get_attributes( $object ),
 			'menu_order'            => $object->get_menu_order(),
 			'meta_data'             => $object->get_meta_data(),
@@ -192,6 +195,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 	 * @param  WP_REST_Request $request Request object.
 	 * @param  bool            $creating If is creating a new object.
 	 * @return WP_Error|WC_Data
+	 * @throws \Throwable When setting gallery_image_ids fails.
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
 		if ( isset( $request['id'] ) ) {
@@ -226,6 +230,37 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 			}
 		}

+		if ( isset( $request['gallery_image_ids'] ) && $variation instanceof WC_Product_Variation ) {
+			// Enforce the schema: gallery is disjoint from the featured image.
+			$gallery_ids = array_values(
+				array_diff(
+					wp_parse_id_list( $request['gallery_image_ids'] ),
+					array( (int) $variation->get_image_id() )
+				)
+			);
+			try {
+				$variation->set_gallery_image_ids( $gallery_ids );
+				LegacyVariationGalleryCompatibility::mark_core_managed( $variation );
+			} catch ( \Throwable $e ) {
+				VariationGalleryTelemetry::record_event(
+					VariationGalleryTelemetry::EVENT_SAVE_FAILED,
+					array(
+						'context' => 'rest_v3',
+						'reason'  => get_class( $e ),
+					)
+				);
+				throw $e;
+			}
+			VariationGalleryTelemetry::record_event(
+				VariationGalleryTelemetry::EVENT_SAVE_SUCCEEDED,
+				array(
+					'context'     => 'rest_v3',
+					'image_count' => count( $gallery_ids ),
+					'is_multi'    => count( $gallery_ids ) > 1 ? 'yes' : 'no',
+				)
+			);
+		}
+
 		// Virtual variation.
 		if ( isset( $request['virtual'] ) ) {
 			$variation->set_virtual( $request['virtual'] );
@@ -822,6 +857,15 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
 						),
 					),
 				),
+				'gallery_image_ids'     => array(
+					'description' => __( 'Variation gallery image IDs, excluding the featured image (which is set via "image"). Mirrors how galleries work on parent products.', 'woocommerce' ),
+					'type'        => 'array',
+					'context'     => array( 'view', 'edit' ),
+					'items'       => array(
+						'type'    => 'integer',
+						'minimum' => 1,
+					),
+				),
 				'attributes'            => array(
 					'description' => __( 'List of attributes.', 'woocommerce' ),
 					'type'        => 'array',
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index b6fd148c144..f088e6ba65b 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1870,6 +1870,136 @@ function wc_get_gallery_image_html( $attachment_id, $main_image = false, $image_
 	return '<div data-thumb="' . esc_url( isset( $thumbnail_src[0] ) ? $thumbnail_src[0] : '' ) . '" data-thumb-alt="' . esc_attr( $alt_text ) . '" data-thumb-srcset="' . esc_attr( isset( $thumbnail_srcset ) ? $thumbnail_srcset : '' ) . '"  data-thumb-sizes="' . esc_attr( isset( $thumbnail_sizes ) ? $thumbnail_sizes : '' ) . '" class="woocommerce-product-gallery__image"><a href="' . esc_url( isset( $full_src[0] ) ? $full_src[0] : '' ) . '">' . $image . '</a></div>';
 }

+/**
+ * Get HTML for a complete product gallery.
+ *
+ * Delegates to the `single-product/product-image.php` template so themes that
+ * override that template are honored, and so any extensions hooked on
+ * `woocommerce_product_thumbnails` or `woocommerce_single_product_image_thumbnail_html`
+ * still fire on variation gallery swaps.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Product $product   Product used for gallery context.
+ * @param array|null $image_ids Ordered image IDs to render. When null, the
+ *                              template renders the product's own featured
+ *                              image and gallery; otherwise the supplied IDs
+ *                              are presented to the template as if they were
+ *                              the product's own images (the first ID becomes
+ *                              the featured slot, the rest the gallery).
+ * @return string
+ */
+function wc_get_product_gallery_html( $product, $image_ids = null ) {
+	if ( ! $product instanceof WC_Product ) {
+		return '';
+	}
+
+	if ( null === $image_ids ) {
+		return wc_render_product_image_template_for( $product );
+	}
+
+	return wc_render_product_image_template_for_image_ids( $product, $image_ids );
+}
+
+/**
+ * Render the `single-product/product-image.php` template for a given product.
+ *
+ * Temporarily promotes $product into `$GLOBALS['product']` so the template,
+ * which reads from globals, sees the right object. Restores the previous
+ * global before returning.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product Product to render.
+ * @return string
+ */
+function wc_render_product_image_template_for( WC_Product $product ): string {
+	$had_previous_product = array_key_exists( 'product', $GLOBALS );
+	$previous_product     = $had_previous_product ? $GLOBALS['product'] : null;
+	$GLOBALS['product']   = $product;
+
+	try {
+		return trim( wc_get_template_html( 'single-product/product-image.php' ) );
+	} finally {
+		if ( $had_previous_product ) {
+			$GLOBALS['product'] = $previous_product;
+		} else {
+			unset( $GLOBALS['product'] );
+		}
+	}
+}
+
+/**
+ * Render the product-image template using a caller-supplied image set instead
+ * of the product's stored featured + gallery.
+ *
+ * Used by the variation gallery swap path: the variation has its own ordered
+ * list of images, but we still want the parent product's gallery template to
+ * render them so theme overrides and `woocommerce_product_thumbnails` hooks
+ * apply uniformly. We achieve this by hooking the product's
+ * `get_image_id` / `get_gallery_image_ids` filters for the duration of the
+ * render; the template sees the substituted values and is none the wiser.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product   Product being rendered.
+ * @param mixed      $image_ids Image IDs to substitute. Will be normalized.
+ * @return string
+ */
+function wc_render_product_image_template_for_image_ids( WC_Product $product, $image_ids ): string {
+	$normalized  = array_values( array_unique( array_map( 'intval', array_filter( (array) $image_ids ) ) ) );
+	$featured_id = $normalized[0] ?? 0;
+	$gallery_ids = array_slice( $normalized, 1 );
+
+	$remove_overrides = wc_apply_product_image_overrides( $product, $featured_id, $gallery_ids );
+
+	try {
+		return wc_render_product_image_template_for( $product );
+	} finally {
+		$remove_overrides();
+	}
+}
+
+/**
+ * Hook get_image_id / get_gallery_image_ids on a single product instance and
+ * return a callable that removes the hooks. Each filter only fires for the
+ * specified product ID — other product objects in the same render pass are
+ * unaffected.
+ *
+ * @since 10.8.0
+ * @internal
+ *
+ * @param WC_Product $product     Product to scope the override to.
+ * @param int        $featured_id Image ID to return from get_image_id.
+ * @param int[]      $gallery_ids Image IDs to return from get_gallery_image_ids.
+ * @return callable Invocation of the returned callable removes both filters.
+ */
+function wc_apply_product_image_overrides( WC_Product $product, int $featured_id, array $gallery_ids ): callable {
+	$product_id = $product->get_id();
+
+	$featured_filter = static function ( $value, $instance ) use ( $product_id, $featured_id ) {
+		return ( $instance instanceof WC_Product && $instance->get_id() === $product_id )
+			? $featured_id
+			: $value;
+	};
+
+	$gallery_filter = static function ( $value, $instance ) use ( $product_id, $gallery_ids ) {
+		return ( $instance instanceof WC_Product && $instance->get_id() === $product_id )
+			? $gallery_ids
+			: $value;
+	};
+
+	add_filter( 'woocommerce_product_get_image_id', $featured_filter, 10, 2 );
+	add_filter( 'woocommerce_product_get_gallery_image_ids', $gallery_filter, 10, 2 );
+
+	return static function () use ( $featured_filter, $gallery_filter ) {
+		remove_filter( 'woocommerce_product_get_image_id', $featured_filter, 10 );
+		remove_filter( 'woocommerce_product_get_gallery_image_ids', $gallery_filter, 10 );
+	};
+}
+
 if ( ! function_exists( 'woocommerce_get_alt_from_product_title_and_position' ) ) {

 	/**
@@ -2066,6 +2196,7 @@ if ( ! function_exists( 'woocommerce_variable_add_to_cart' ) ) {
 	 */
 	function woocommerce_variable_add_to_cart() {
 		global $product;
+		static $attached_gallery_defaults = array();

 		if ( ! ( $product instanceof WC_Product ) ) {
 			return;
@@ -2074,6 +2205,24 @@ if ( ! function_exists( 'woocommerce_variable_add_to_cart' ) ) {
 		// Enqueue variation scripts.
 		wp_enqueue_script( 'wc-add-to-cart-variation' );

+		// Attach a reset snapshot only when variation-gallery swaps are enabled.
+		if (
+			\Automattic\WooCommerce\Internal\VariationGallery\Package::is_enabled() &&
+			! isset( $attached_gallery_defaults[ $product->get_id() ] )
+		) {
+			wp_add_inline_script(
+				'wc-add-to-cart-variation',
+				sprintf(
+					'(window.wc_variation_gallery_defaults = window.wc_variation_gallery_defaults || {})[%d] = %s;',
+					$product->get_id(),
+					wp_json_encode( wc_get_product_gallery_html( $product ) )
+				),
+				'before'
+			);
+
+			$attached_gallery_defaults[ $product->get_id() ] = true;
+		}
+
 		// Get Available variations?
 		$get_variations = count( $product->get_children() ) <= apply_filters( 'woocommerce_ajax_variation_threshold', 30, $product );

diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 73ae5c0095f..0432d84946c 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -6363,7 +6363,7 @@ parameters:
 		-
 			message: '#^Variable \$loop might not be defined\.$#'
 			identifier: variable.undefined
-			count: 51
+			count: 52
 			path: includes/admin/meta-boxes/views/html-variation-admin.php

 		-
@@ -14118,12 +14118,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-product-variable.php

-		-
-			message: '#^Parameter \#1 \$attachment_id of function wc_get_product_attachment_props expects int\|null, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-product-variable.php
-
 		-
 			message: '#^Parameter \#1 \$dimensions of function wc_format_dimensions expects array, array\|string given\.$#'
 			identifier: argument.type
@@ -53925,12 +53919,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/ProductGallery.php

-		-
-			message: '#^Call to an undefined method WC_Product\:\:get_available_variations\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: src/Blocks/BlockTypes/ProductGallery.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductGallery\:\:render_dialog\(\) should return string but returns string\|false\.$#'
 			identifier: return.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
index 5e80e52a791..ac8b9e28302 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php
@@ -44,7 +44,7 @@ class ProductGallery extends AbstractBlock {
 			$sizes        = $image['sizes'];
 			$alt          = $image['alt'];
 			$loading      = 0 === $index ? 'fetchpriority="high"' : 'loading="lazy"';
-			$images_html .= "<img data-image-id='{$id}' src='{$src}' srcset='{$srcset}' sizes='{$sizes}' loading='{$loading}' decoding='async' alt='{$alt}' />";
+			$images_html .= "<img data-image-id='{$id}' data-wp-watch='callbacks.toggleImageVisibility' src='{$src}' srcset='{$srcset}' sizes='{$sizes}' loading='{$loading}' decoding='async' alt='{$alt}' />";
 		}
 		ob_start();
 		?>
@@ -113,10 +113,12 @@ class ProductGallery extends AbstractBlock {
 			return '';
 		}

-		$image_ids              = ProductGalleryUtils::get_all_image_ids( $product );
-		$number_of_images       = count( $image_ids );
+		$image_ids         = ProductGalleryUtils::get_all_image_ids( $product );
+		$default_image_ids = array_map( 'intval', ProductGalleryUtils::get_product_gallery_image_ids( $product ) );
+
+		$number_of_images       = count( $default_image_ids );
 		$classname              = StyleAttributesUtils::get_classes_by_attributes( $attributes, array( 'extra_classes' ) );
-		$initial_image_id       = $number_of_images > 0 ? $image_ids[0] : -1;
+		$initial_image_id       = $number_of_images > 0 ? $default_image_ids[0] : -1;
 		$classname_single_image = $number_of_images < 2 ? 'is-single-product-gallery-image' : '';
 		$product_id             = strval( $product->get_id() );
 		$fullsize_image_data    = ProductGalleryUtils::get_image_src_data( $image_ids, 'full', $product->get_title() );
@@ -130,7 +132,7 @@ class ProductGallery extends AbstractBlock {
 				'data-wp-context',
 				wp_json_encode(
 					array(
-						'imageData'               => $image_ids,
+						'imageData'               => $default_image_ids,
 						'isDialogOpen'            => false,
 						'isDragging'              => false,
 						'touchStartX'             => 0,
@@ -146,7 +148,7 @@ class ProductGallery extends AbstractBlock {
 						// Next/Previous Buttons block context.
 						'hideNextPreviousButtons' => $number_of_images <= 1,
 						'isDisabledPrevious'      => true,
-						'isDisabledNext'          => false,
+						'isDisabledNext'          => $number_of_images <= 1,
 						'ariaLabelPrevious'       => __( 'Previous image', 'woocommerce' ),
 						'ariaLabelNext'           => __( 'Next image', 'woocommerce' ),
 					),
@@ -155,15 +157,22 @@ class ProductGallery extends AbstractBlock {
 			);

 			if ( $product->is_type( ProductType::VARIABLE ) ) {
-				$has_variation_images = false;
-				foreach ( $product->get_available_variations( 'objects' ) as $variation ) {
-					if ( (int) $variation->get_image_id() ) {
-						$has_variation_images = true;
-						break;
-					}
-				}
+				$formatted_variations_data = ProductGalleryUtils::get_product_variation_gallery_data( $product );
+
+				if ( ! empty( $formatted_variations_data ) ) {
+					wp_interactivity_config(
+						'woocommerce',
+						array(
+							'products' => array(
+								$product->get_id() => array(
+									'image_id'   => (int) $product->get_image_id(),
+									'image_ids'  => $default_image_ids,
+									'variations' => $formatted_variations_data,
+								),
+							),
+						)
+					);

-				if ( $has_variation_images ) {
 					// Support legacy Add to Cart with Options block.
 					$p->set_attribute( 'data-wp-init--watch-changes-on-add-to-cart-form', 'callbacks.watchForChangesOnAddToCartForm' );
 					// Support blockified Add to Cart + Options block.
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
index 2b750105ae0..e786048096d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryLargeImage.php
@@ -158,6 +158,7 @@ class ProductGalleryLargeImage extends AbstractBlock {

 		$p->set_attribute( 'tabindex', '-1' );
 		$p->set_attribute( 'draggable', 'false' );
+		$p->set_attribute( 'data-wp-watch', 'callbacks.toggleImageVisibility' );
 		$p->set_attribute( 'data-wp-on--click', 'actions.onViewerClick' );
 		$p->set_attribute( 'data-wp-on--touchstart', 'actions.onTouchStart' );
 		$p->set_attribute( 'data-wp-on--touchmove', 'actions.onTouchMove' );
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
index 4096c37d149..d7fa9a8913d 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGalleryThumbnails.php
@@ -76,6 +76,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
 			class="wc-block-product-gallery-thumbnails wc-block-product-gallery-thumbnails--active-<?php echo esc_attr( $active_thumbnail_style ); ?> <?php echo esc_attr( $classes_and_styles['classes'] ); ?>"
 			style="<?php echo '--wc-block-product-gallery-thumbnails-size:' . absint( $thumbnail_size ) . ';' . esc_attr( $classes_and_styles['styles'] ); ?>"
 			data-wp-interactive="woocommerce/product-gallery"
+			data-wp-bind--hidden="context.hideNextPreviousButtons"
 			data-wp-class--wc-block-product-gallery-thumbnails--overflow-top="context.thumbnailsOverflow.top"
 			data-wp-class--wc-block-product-gallery-thumbnails--overflow-bottom="context.thumbnailsOverflow.bottom"
 			data-wp-class--wc-block-product-gallery-thumbnails--overflow-left="context.thumbnailsOverflow.left"
@@ -97,7 +98,7 @@ class ProductGalleryThumbnails extends AbstractBlock {
 							alt="<?php echo esc_attr( $image['alt'] ); ?>"
 							data-wp-on--click="actions.selectCurrentImage"
 							data-wp-on--keydown="actions.onThumbnailsArrowsKeyDown"
-							data-wp-watch="callbacks.toggleActiveThumbnailAttributes"
+							data-wp-watch="callbacks.syncThumbnailState"
 							decoding="async"
 							tabindex="<?php echo 0 === $index ? '0' : '-1'; ?>"
 							draggable="false"
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
index f873ad787b6..2fe6dbe246f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductImage.php
@@ -172,7 +172,13 @@ class ProductImage extends AbstractBlock {
 		$target_image_id = $provided_image_id_is_valid ? $image_id : $featured_image_id;

 		if ( ! $target_image_id ) {
-			return wc_placeholder_img( $image_size, array( 'style' => $image_style ) );
+			return wc_placeholder_img(
+				$image_size,
+				array(
+					'style'         => $image_style,
+					'data-image-id' => 0,
+				)
+			);
 		}

 		$alt_text = get_post_meta( $target_image_id, '_wp_attachment_image_alt', true );
diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
index 4bc75c0dfce..abe9b2300c6 100644
--- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
+++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php
@@ -1,6 +1,8 @@
 <?php
 namespace Automattic\WooCommerce\Blocks\Utils;

+use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGalleryPackage;
+
 /**
  * Utility methods used for the Product Gallery block.
  * {@internal This class and its methods are not intended for public use.}
@@ -117,18 +119,10 @@ class ProductGalleryUtils {

 		try {
 			if ( $product->is_type( 'variable' ) ) {
-				$variations = $product->get_children();
-				if ( ! empty( $variations ) ) {
-					_prime_post_caches( $variations );
-				}
-				foreach ( $variations as $variation_id ) {
-					$variation = wc_get_product( $variation_id );
-					if ( $variation ) {
-						$variation_image_id = $variation->get_image_id();
-						if ( ! empty( $variation_image_id ) && ! in_array( strval( $variation_image_id ), $variation_image_ids, true ) ) {
-							$variation_image_ids[] = strval( $variation_image_id );
-						}
-					}
+				$variation_gallery_data = self::get_product_variation_gallery_data( $product );
+
+				foreach ( $variation_gallery_data as $variation_data ) {
+					$variation_image_ids = array_merge( $variation_image_ids, $variation_data['image_ids'] );
 				}
 			}
 		} catch ( \Exception $e ) {
@@ -136,7 +130,146 @@ class ProductGalleryUtils {
 			error_log( 'Error getting product variation image IDs: ' . $e->getMessage() );
 		}

-		return $variation_image_ids;
+		$unique_int_ids = array_unique( array_map( 'intval', $variation_image_ids ) );
+
+		return array_values( array_map( 'strval', $unique_int_ids ) );
+	}
+
+	/**
+	 * Get variation gallery data keyed by variation ID.
+	 *
+	 * @param \WC_Product $product The product object to retrieve variation gallery data for.
+	 * @return array<int, array<string, mixed>> Variation gallery data.
+	 */
+	public static function get_product_variation_gallery_data( $product ) {
+		$variation_gallery_data = array();
+
+		if ( ! $product instanceof \WC_Product ) {
+			wc_doing_it_wrong( __FUNCTION__, __( 'Invalid product object.', 'woocommerce' ), '10.8.0' );
+			return $variation_gallery_data;
+		}
+
+		if ( ! $product->is_type( 'variable' ) ) {
+			return $variation_gallery_data;
+		}
+
+		$variations = $product->get_children();
+		if ( ! empty( $variations ) ) {
+			// Bulk-load posts + postmeta into WP's object cache.
+			_prime_post_caches( $variations );
+		}
+
+		$parent_image_ids = array_values(
+			array_filter(
+				array_map( 'intval', self::get_product_gallery_image_ids( $product ) ),
+				static function ( $id ) {
+					return $id > 0 && wp_attachment_is_image( $id );
+				}
+			)
+		);
+
+		foreach ( $variations as $variation_id ) {
+			$variation_id = (int) $variation_id;
+			$entry        = self::build_variation_gallery_entry( $variation_id, $parent_image_ids );
+
+			if ( null !== $entry ) {
+				$variation_gallery_data[ $variation_id ] = $entry;
+			}
+		}
+
+		return $variation_gallery_data;
+	}
+
+	/**
+	 * Build the gallery payload for a single variation, or null when the
+	 * variation has no images or isn't a real variation.
+	 *
+	 * @param int   $variation_id     Variation post ID.
+	 * @param int[] $parent_image_ids Parent product's full gallery (featured + extras),
+	 *                                used as fallback when the variation has no images.
+	 * @return array<string, mixed>|null
+	 */
+	private static function build_variation_gallery_entry( int $variation_id, array $parent_image_ids ): ?array {
+		$variation = wc_get_product( $variation_id );
+
+		if ( ! $variation instanceof \WC_Product_Variation ) {
+			return null;
+		}
+
+		$variation_image_id    = (int) $variation->get_image_id();
+		$variation_image_valid = $variation_image_id && wp_attachment_is_image( $variation_image_id );
+		$parent_fallback       = ! empty( $parent_image_ids )
+			? array(
+				'image_id'  => $parent_image_ids[0],
+				'image_ids' => $parent_image_ids,
+			)
+			: array(
+				'image_id'  => 0,
+				'image_ids' => array( 0 ),
+			);
+
+		if ( ! VariationGalleryPackage::is_enabled() ) {
+			if ( $variation_image_valid ) {
+				return array(
+					'image_id'  => $variation_image_id,
+					'image_ids' => array( $variation_image_id ),
+				);
+			}
+			return $parent_fallback;
+		}
+
+		$image_ids = self::get_variation_gallery_image_ids( $variation );
+
+		if ( empty( $image_ids ) ) {
+			return $parent_fallback;
+		}
+
+		// Prefer variation-owned images over the parent fallback.
+		if ( $variation_image_valid ) {
+			$selected_image_id = $variation_image_id;
+		} else {
+			$selected_image_id = $image_ids[0];
+		}
+
+		if ( ! in_array( $selected_image_id, $image_ids, true ) ) {
+			array_unshift( $image_ids, $selected_image_id );
+		}
+
+		return array(
+			'image_id'  => $selected_image_id,
+			'image_ids' => $image_ids,
+		);
+	}
+
+	/**
+	 * Get all image IDs relevant to a variation gallery.
+	 *
+	 * @param \WC_Product_Variation $variation The variation object.
+	 * @return array<int> Variation image IDs.
+	 */
+	public static function get_variation_gallery_image_ids( \WC_Product_Variation $variation ) {
+		$image_ids          = array();
+		$variation_image_id = (int) $variation->get_image_id();
+		$gallery_image_ids  = array_map( 'intval', $variation->get_gallery_image_ids() );
+
+		if ( $variation_image_id ) {
+			$image_ids[] = $variation_image_id;
+		}
+
+		if ( ! empty( $gallery_image_ids ) ) {
+			$image_ids = array_merge( $image_ids, $gallery_image_ids );
+		}
+
+		// Filter out missing/invalid attachments to avoid rendering phantom
+		// empty `<li>` wrappers that the visibility watch can't manage.
+		$image_ids = array_filter(
+			$image_ids,
+			function ( $id ) {
+				return $id > 0 && wp_attachment_is_image( $id );
+			}
+		);
+
+		return array_values( array_unique( $image_ids ) );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index caa86505e46..1ddbc738ab6 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -512,6 +512,18 @@ class FeaturesController {
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 				'enabled_by_default'           => false,
 			),
+			\Automattic\WooCommerce\Internal\VariationGallery\Package::FEATURE_ID => array(
+				'name'                         => __( 'Variation gallery', 'woocommerce' ),
+				'description'                  => __(
+					'Add multiple images per product variation. Once enabled, the Additional Variation Images extension will be deactivated and its data migrated.',
+					'woocommerce'
+				),
+				'option_key'                   => \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME,
+				'is_experimental'              => true,
+				'enabled_by_default'           => false,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			'point_of_sale'                      => array(
 				'name'                         => __( 'Point of Sale', 'woocommerce' ),
 				'description'                  => __(
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php b/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php
new file mode 100644
index 00000000000..68f0ac81361
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/ClassicVariationGalleryAdmin.php
@@ -0,0 +1,372 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Product_Variation;
+use WP_Post;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Adds a classic-editor authoring UI for variation galleries.
+ *
+ * The editor unifies the variation's featured image and gallery into a single
+ * ordered list. The legacy single-image slot is hidden visually, and kept
+ * in sync with the first gallery image via JS.
+ *
+ * This preserves the existing variation save path while giving merchants
+ * one control to manage.
+ */
+class ClassicVariationGalleryAdmin implements RegisterHooksInterface {
+
+	private const SCRIPT_HANDLE = 'wc-admin-variation-gallery';
+
+	private const STYLE_HANDLE = 'wc-admin-variation-gallery-styles';
+
+	/**
+	 * Register hooks.
+	 *
+	 * @return void
+	 */
+	public function register() {
+		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ), 20 );
+		add_action( 'woocommerce_variation_after_upload_image', array( $this, 'render_variation_gallery_field' ), 10, 3 );
+		add_action( 'woocommerce_admin_process_variation_object', array( $this, 'persist_variation_gallery_field' ), 10, 2 );
+	}
+
+	/**
+	 * Enqueue admin assets for the classic variation gallery editor.
+	 *
+	 * @return void
+	 */
+	public function enqueue_assets(): void {
+		if ( ! $this->is_product_edit_screen() ) {
+			return;
+		}
+
+		$suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min';
+
+		wp_enqueue_script(
+			self::SCRIPT_HANDLE,
+			\WC()->plugin_url() . '/assets/js/admin/variation-gallery' . $suffix . '.js',
+			array( 'wc-admin-variation-meta-boxes', 'wp-a11y' ),
+			Constants::get_constant( 'WC_VERSION' ),
+			true
+		);
+
+		wp_localize_script(
+			self::SCRIPT_HANDLE,
+			'wcVariationGalleryL10n',
+			array(
+				'manageTitle'      => __( 'Manage variation gallery', 'woocommerce' ),
+				'manageButton'     => __( 'Update gallery', 'woocommerce' ),
+				'replaceTitle'     => __( 'Replace image', 'woocommerce' ),
+				'replaceButton'    => __( 'Use this image', 'woocommerce' ),
+				'replaceLabel'     => __( 'Replace', 'woocommerce' ),
+				'addTitle'         => __( 'Add images to variation gallery', 'woocommerce' ),
+				'addButton'        => __( 'Add to gallery', 'woocommerce' ),
+				'emptyCtaLabel'    => __( 'Add variation images', 'woocommerce' ),
+				'announceUpdated'  => __( 'Variation gallery updated.', 'woocommerce' ),
+				'announceReplaced' => __( 'Image replaced.', 'woocommerce' ),
+				'announceRemoved'  => __( 'Image removed from variation gallery.', 'woocommerce' ),
+				'announceReorder'  => __( 'Variation gallery order updated.', 'woocommerce' ),
+				'announcePrimary'  => __( 'New primary image set.', 'woocommerce' ),
+				'removeLabel'      => __( 'Remove image', 'woocommerce' ),
+				'countZero'        => __( 'No images yet', 'woocommerce' ),
+				/* translators: %d: number of variation gallery images */
+				'countSingular'    => __( '%d image', 'woocommerce' ),
+				/* translators: %d: number of variation gallery images */
+				'countPlural'      => __( '%d images', 'woocommerce' ),
+				'primaryLabel'     => __( 'Primary', 'woocommerce' ),
+				/* translators: %d: gallery image position */
+				'thumbLabel'       => __( 'Show gallery image %d', 'woocommerce' ),
+				'missingFileLabel' => __( 'Attachment file missing', 'woocommerce' ),
+			)
+		);
+
+		wp_enqueue_style(
+			self::STYLE_HANDLE,
+			\WC()->plugin_url() . '/assets/css/variation-gallery-admin.css',
+			array(),
+			Constants::get_constant( 'WC_VERSION' )
+		);
+	}
+
+	/**
+	 * Render the variation gallery field.
+	 *
+	 * @param int     $loop           Variation row index.
+	 * @param array   $variation_data Variation data.
+	 * @param WP_Post $variation      Variation post object.
+	 * @return void
+	 */
+	public function render_variation_gallery_field( int $loop, array $variation_data, WP_Post $variation ): void {
+		$variation_object = wc_get_product( $variation->ID );
+
+		if ( ! $variation_object instanceof WC_Product_Variation ) {
+			return;
+		}
+
+		$image_ids = $this->get_display_image_ids( $variation_object );
+		$count     = count( $image_ids );
+		$field_id  = 'variable_gallery_image_ids_' . $loop;
+		$hero_id   = $count > 0 ? $image_ids[0] : 0;
+		?>
+		<div
+			class="wc-variation-gallery-field<?php echo 0 === $count ? ' is-empty' : ''; ?>"
+			data-variation-id="<?php echo esc_attr( (string) $variation->ID ); ?>"
+		>
+			<div class="wc-variation-gallery-field__header">
+				<div class="wc-variation-gallery-field__title-block">
+					<strong class="wc-variation-gallery-field__title">
+						<?php esc_html_e( 'Variation gallery', 'woocommerce' ); ?>
+					</strong>
+					<span class="wc-variation-gallery-field__count" aria-live="polite">
+						<?php echo esc_html( $this->get_count_text( $count ) ); ?>
+					</span>
+				</div>
+				<button
+					type="button"
+					class="button-link wc-variation-gallery-manage"
+					aria-label="<?php esc_attr_e( 'Manage variation gallery images', 'woocommerce' ); ?>"
+				>
+					<?php esc_html_e( 'Manage', 'woocommerce' ); ?>
+				</button>
+			</div>
+
+			<div class="wc-variation-gallery-field__hero" data-active-index="0">
+				<?php if ( $hero_id > 0 ) : ?>
+					<?php $this->render_hero_image( $hero_id ); ?>
+					<span class="wc-variation-gallery-field__badge" data-primary-badge aria-hidden="true">
+						<span class="dashicons dashicons-star-filled"></span>
+						<?php esc_html_e( 'Primary', 'woocommerce' ); ?>
+					</span>
+					<button type="button" class="button wc-variation-gallery-replace">
+						<?php esc_html_e( 'Replace', 'woocommerce' ); ?>
+					</button>
+				<?php else : ?>
+					<button type="button" class="wc-variation-gallery-field__empty-cta wc-variation-gallery-manage">
+						<span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>
+						<?php esc_html_e( 'Add variation images', 'woocommerce' ); ?>
+					</button>
+				<?php endif; ?>
+			</div>
+
+			<ul class="wc-variation-gallery-field__thumbs">
+				<?php foreach ( $image_ids as $index => $image_id ) : ?>
+					<?php $this->render_thumbnail( $image_id, 0 === $index ); ?>
+				<?php endforeach; ?>
+			</ul>
+
+			<p class="wc-variation-gallery-field__hint"<?php echo 0 === $count ? ' hidden' : ''; ?>>
+				<?php esc_html_e( 'First image is used as the primary. Drag to reorder.', 'woocommerce' ); ?>
+			</p>
+
+			<input
+				type="hidden"
+				id="<?php echo esc_attr( $field_id ); ?>"
+				name="variable_gallery_image_ids[<?php echo esc_attr( (string) $loop ); ?>]"
+				class="wc-variation-gallery-image-ids"
+				value="<?php echo esc_attr( implode( ',', $image_ids ) ); ?>"
+			/>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Persist the variation gallery field.
+	 *
+	 * The merchant-facing UI presents featured + gallery as a single ordered
+	 * list.
+	 *
+	 * @param WC_Product_Variation $variation Variation being saved.
+	 * @param int                  $index     Variation row index.
+	 * @return void
+	 * @throws \Throwable When setting the variation image or gallery fails.
+	 */
+	public function persist_variation_gallery_field( WC_Product_Variation $variation, int $index ): void {
+		// We verify the variation save nonce before firing `woocommerce_admin_process_variation_object`.
+		if ( ! isset( $_POST['variable_gallery_image_ids'][ $index ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
+			return;
+		}
+
+		$unified_ids = wp_parse_id_list( wp_unslash( $_POST['variable_gallery_image_ids'][ $index ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+		$featured_id = (int) ( $unified_ids[0] ?? 0 );
+		$gallery_ids = array_values( array_slice( $unified_ids, 1 ) );
+
+		try {
+			$variation->set_image_id( $featured_id );
+			$variation->set_gallery_image_ids( $gallery_ids );
+			LegacyVariationGalleryCompatibility::mark_core_managed( $variation );
+		} catch ( \Throwable $e ) {
+			Telemetry::record_event(
+				Telemetry::EVENT_SAVE_FAILED,
+				array(
+					'context' => 'classic_admin',
+					'reason'  => get_class( $e ),
+				)
+			);
+			throw $e;
+		}
+
+		Telemetry::record_event(
+			Telemetry::EVENT_SAVE_SUCCEEDED,
+			array(
+				'context'     => 'classic_admin',
+				'image_count' => count( $unified_ids ),
+				'is_multi'    => count( $unified_ids ) > 1 ? 'yes' : 'no',
+			)
+		);
+	}
+
+	/**
+	 * Display-time image list (render-only).
+	 *
+	 * Prepends the variation's featured image to the gallery when it's not
+	 * already present, so the meta-box UI shows a single ordered set instead
+	 * of two separate fields. This synthesis is **not persisted** — storage
+	 * only changes when the merchant saves the variation, at which point
+	 * `gallery[0]` becomes the canonical primary image.
+	 *
+	 * @param WC_Product_Variation $variation Variation object.
+	 * @return array<int>
+	 */
+	private function get_display_image_ids( WC_Product_Variation $variation ): array {
+		$image_ids   = array_values( array_map( 'intval', $variation->get_gallery_image_ids() ) );
+		$featured_id = (int) $variation->get_image_id();
+
+		if ( $featured_id > 0 && ! in_array( $featured_id, $image_ids, true ) ) {
+			array_unshift( $image_ids, $featured_id );
+		}
+
+		if ( ! empty( $image_ids ) ) {
+			_prime_post_caches( $image_ids );
+		}
+
+		return $image_ids;
+	}
+
+	/**
+	 * Render the hero image.
+	 *
+	 * @param int $image_id Attachment ID.
+	 * @return void
+	 */
+	private function render_hero_image( int $image_id ): void {
+		$html = wp_get_attachment_image(
+			$image_id,
+			'woocommerce_single',
+			false,
+			array(
+				'class'    => 'wc-variation-gallery-field__hero-img',
+				'data-id'  => (string) $image_id,
+				'decoding' => 'async',
+				'loading'  => 'lazy',
+			)
+		);
+
+		if ( '' === $html ) {
+			?>
+			<span class="wc-variation-gallery-field__hero-broken" aria-hidden="true">
+				<span class="dashicons dashicons-format-image"></span>
+			</span>
+			<span class="screen-reader-text">
+				<?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?>
+			</span>
+			<?php
+			return;
+		}
+
+		echo wp_kses_post( $html );
+	}
+
+	/**
+	 * Render a single thumbnail list item.
+	 *
+	 * @param int  $image_id  Attachment ID.
+	 * @param bool $is_active Whether this thumbnail is the active/primary one.
+	 * @return void
+	 */
+	private function render_thumbnail( int $image_id, bool $is_active ): void {
+		$thumbnail = wp_get_attachment_image( $image_id, 'thumbnail' );
+		$is_broken = '' === $thumbnail;
+		$classes   = array( 'wc-variation-gallery-thumb' );
+
+		if ( $is_active ) {
+			$classes[] = 'is-active';
+		}
+
+		if ( $is_broken ) {
+			$classes[] = 'is-broken';
+		}
+
+		/* translators: %d attachment ID */
+		$thumb_label = sprintf( __( 'Show gallery image %d', 'woocommerce' ), $image_id );
+		?>
+		<li
+			class="<?php echo esc_attr( implode( ' ', $classes ) ); ?>"
+			data-attachment_id="<?php echo esc_attr( (string) $image_id ); ?>"
+		>
+			<button
+				type="button"
+				class="wc-variation-gallery-thumb__button"
+				aria-label="<?php echo esc_attr( $thumb_label ); ?>"
+			>
+				<?php if ( $is_broken ) : ?>
+					<span class="wc-variation-gallery-thumb__broken" aria-hidden="true">
+						<span class="dashicons dashicons-format-image"></span>
+					</span>
+					<span class="screen-reader-text">
+						<?php esc_html_e( 'Attachment file missing', 'woocommerce' ); ?>
+					</span>
+				<?php else : ?>
+					<?php echo wp_kses_post( $thumbnail ); ?>
+				<?php endif; ?>
+			</button>
+			<button
+				type="button"
+				class="wc-variation-gallery-thumb__remove"
+				aria-label="<?php esc_attr_e( 'Remove image', 'woocommerce' ); ?>"
+			>
+				<span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
+			</button>
+		</li>
+		<?php
+	}
+
+	/**
+	 * Get the image count label shown beside the field title.
+	 *
+	 * @param int $count Number of images.
+	 * @return string
+	 */
+	private function get_count_text( int $count ): string {
+		if ( 0 === $count ) {
+			return __( 'No images yet', 'woocommerce' );
+		}
+
+		return sprintf(
+			/* translators: %d number of variation gallery images */
+			_n( '%d image', '%d images', $count, 'woocommerce' ),
+			$count
+		);
+	}
+
+	/**
+	 * Determine if the current screen is the classic product editor.
+	 *
+	 * @return bool
+	 */
+	private function is_product_edit_screen(): bool {
+		if ( ! function_exists( 'get_current_screen' ) ) {
+			return false;
+		}
+
+		$screen = get_current_screen();
+
+		return $screen && 'product' === $screen->id;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php b/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php
new file mode 100644
index 00000000000..77759199c04
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/LegacyVariationGalleryCompatibility.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use WC_Product_Variation;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Provides read compatibility for variation galleries stored by the
+ * Additional Variation Images extension.
+ */
+class LegacyVariationGalleryCompatibility implements RegisterHooksInterface {
+
+	/**
+	 * Legacy meta key used by the retired extension.
+	 */
+	private const LEGACY_META_KEY = '_wc_additional_variation_images';
+
+	/**
+	 * Marks a variation as explicitly managed by core, so legacy fallback stops applying.
+	 */
+	private const LEGACY_FALLBACK_DISABLED_META_KEY = '_wc_variation_gallery_legacy_fallback_disabled';
+
+	/**
+	 * Get the internal meta key used to mark legacy fallback as disabled.
+	 *
+	 * @return string
+	 */
+	public static function get_core_managed_meta_key(): string {
+		return self::LEGACY_FALLBACK_DISABLED_META_KEY;
+	}
+
+	/**
+	 * Mark a variation as managed by core so legacy fallback stops applying.
+	 *
+	 * @param WC_Product_Variation $variation Variation managed by core.
+	 * @return void
+	 */
+	public static function mark_core_managed( WC_Product_Variation $variation ): void {
+		if ( ! metadata_exists( 'post', $variation->get_id(), self::LEGACY_META_KEY ) ) {
+			return;
+		}
+
+		$variation->update_meta_data( self::LEGACY_FALLBACK_DISABLED_META_KEY, 'yes' );
+	}
+
+	/**
+	 * Mark a variation ID as managed by core so legacy fallback stops applying.
+	 *
+	 * @param int $variation_id Variation ID managed by core.
+	 * @return void
+	 */
+	public static function mark_variation_id_core_managed( int $variation_id ): void {
+		if ( ! metadata_exists( 'post', $variation_id, self::LEGACY_META_KEY ) ) {
+			return;
+		}
+
+		update_post_meta( $variation_id, self::LEGACY_FALLBACK_DISABLED_META_KEY, 'yes' );
+	}
+
+	/**
+	 * Determine whether a variation ID is already managed by core.
+	 *
+	 * @param int $variation_id Variation ID.
+	 * @return bool
+	 */
+	public static function is_variation_id_core_managed( int $variation_id ): bool {
+		return metadata_exists( 'post', $variation_id, self::LEGACY_FALLBACK_DISABLED_META_KEY );
+	}
+
+	/**
+	 * Register compatibility hooks.
+	 *
+	 * @return void
+	 */
+	public function register() {
+		add_filter( 'woocommerce_product_variation_get_gallery_image_ids', array( $this, 'maybe_read_legacy_gallery_image_ids' ), 10, 2 );
+	}
+
+	/**
+	 * Use legacy variation gallery meta when the core gallery is empty and the
+	 * variation has not been marked as core-managed.
+	 *
+	 * @param array<mixed>         $gallery_image_ids Gallery image IDs already resolved by core.
+	 * @param WC_Product_Variation $variation Variation instance.
+	 * @return array<int>
+	 */
+	public function maybe_read_legacy_gallery_image_ids( $gallery_image_ids, WC_Product_Variation $variation ): array {
+		// Core has variation images, just normalize.
+		if ( ! empty( $gallery_image_ids ) ) {
+			return array_values( wp_parse_id_list( $gallery_image_ids ) );
+		}
+
+		// Sentinel set: respect the explicit "no images" choice; legacy meta remains for BC.
+		if ( self::is_variation_id_core_managed( $variation->get_id() ) ) {
+			return array();
+		}
+
+		$legacy_gallery_image_ids = get_post_meta( $variation->get_id(), self::LEGACY_META_KEY, true );
+
+		// Nothing to fall back to.
+		if ( empty( $legacy_gallery_image_ids ) ) {
+			return array();
+		}
+
+		// Pre-migration variation: fall back to the legacy extension's meta.
+		return array_values( wp_parse_id_list( $legacy_gallery_image_ids ) );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Migration.php b/plugins/woocommerce/src/Internal/VariationGallery/Migration.php
new file mode 100644
index 00000000000..93b32c672ba
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Migration.php
@@ -0,0 +1,91 @@
+<?php
+/**
+ * Batched migration of legacy variation gallery meta into core's native prop.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Migrate legacy variation gallery meta into WooCommerce's native variation gallery prop.
+ */
+class Migration {
+
+	/**
+	 * Number of variations processed per batch.
+	 */
+	private const BATCH_SIZE = 250;
+
+	/**
+	 * Option name recording when the migration finished.
+	 */
+	public const COMPLETED_OPTION = 'wc_variation_gallery_migration_completed_at';
+
+	/**
+	 * Run one batch of the migration.
+	 *
+	 * @return bool Whether there are pending migration records.
+	 */
+	public static function run(): bool {
+		global $wpdb;
+
+		if ( get_option( self::COMPLETED_OPTION ) ) {
+			return false;
+		}
+
+		$legacy_meta_key      = '_wc_additional_variation_images';
+		$core_gallery_meta    = '_product_image_gallery';
+		$fallback_disabled    = LegacyVariationGalleryCompatibility::get_core_managed_meta_key();
+		$select_variation_ids = static function ( int $limit ) use ( $wpdb, $legacy_meta_key, $fallback_disabled ): array {
+			$query = $wpdb->prepare(
+				"SELECT legacy.post_id
+				FROM {$wpdb->postmeta} AS legacy
+				INNER JOIN {$wpdb->posts} AS posts
+					ON posts.ID = legacy.post_id
+					AND posts.post_type = 'product_variation'
+				LEFT JOIN {$wpdb->postmeta} AS disabled
+					ON disabled.post_id = legacy.post_id
+					AND disabled.meta_key = %s
+				WHERE legacy.meta_key = %s
+					AND legacy.meta_value <> ''
+					AND disabled.post_id IS NULL
+				GROUP BY legacy.post_id
+				ORDER BY legacy.post_id ASC
+				LIMIT %d",
+				$fallback_disabled,
+				$legacy_meta_key,
+				$limit
+			);
+
+			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is prepared immediately above.
+			return array_map( 'intval', $wpdb->get_col( $query ) );
+		};
+
+		$variation_ids = $select_variation_ids( self::BATCH_SIZE );
+
+		foreach ( $variation_ids as $variation_id ) {
+			$legacy_gallery_image_ids = wp_parse_id_list( get_post_meta( $variation_id, $legacy_meta_key, true ) );
+			$core_gallery_image_ids   = wp_parse_id_list( get_post_meta( $variation_id, $core_gallery_meta, true ) );
+
+			if ( empty( $core_gallery_image_ids ) && ! empty( $legacy_gallery_image_ids ) ) {
+				update_post_meta( $variation_id, $core_gallery_meta, implode( ',', $legacy_gallery_image_ids ) );
+			}
+
+			// Keep legacy meta for third-party readers; disable fallback via the sentinel instead.
+			LegacyVariationGalleryCompatibility::mark_variation_id_core_managed( $variation_id );
+		}
+
+		$has_more = ! empty( $select_variation_ids( 1 ) );
+
+		// Guard against duplicate completion events if this runner is invoked twice.
+		if ( ! $has_more && ! get_option( self::COMPLETED_OPTION ) ) {
+			update_option( self::COMPLETED_OPTION, time() );
+			Telemetry::record_event( Telemetry::EVENT_MIGRATION_COMPLETED );
+		}
+
+		return $has_more;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Package.php b/plugins/woocommerce/src/Internal/VariationGallery/Package.php
new file mode 100644
index 00000000000..efeb7188d72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Package.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Package class file for the variation gallery feature.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Variation gallery package entry point.
+ *
+ * Registered in `\Automattic\WooCommerce\Packages::$merged_packages` against
+ * the `woocommerce-additional-variation-images` slug, so this class is the
+ * single bootstrap surface for the merged variation gallery feature.
+ */
+class Package {
+
+	/**
+	 * Action Scheduler hook for DB update callbacks.
+	 */
+	private const UPDATE_CALLBACK_HOOK = 'woocommerce_run_update_callback';
+
+	/**
+	 * Action Scheduler group for DB update callbacks.
+	 */
+	private const UPDATE_CALLBACK_GROUP = 'woocommerce-db-updates';
+
+	/**
+	 * The feature id used by `FeaturesController` (Settings → Advanced → Features).
+	 */
+	public const FEATURE_ID = 'variation_gallery';
+
+	/**
+	 * Option backing the variation gallery feature toggle.
+	 */
+	public const ENABLE_OPTION_NAME = 'wc_feature_woocommerce_additional_variation_images_enabled';
+
+	/**
+	 * Whether the merged variation gallery feature is enabled for the current
+	 * request.
+	 *
+	 * Reads the same option as the Features toggles, so the `FeaturesController`
+	 * and the merged-package machinery share a single source of truth. Defaults
+	 * to off for the 10.9 canary period.
+	 *
+	 * @return bool
+	 */
+	public static function is_enabled() {
+		return 'yes' === get_option( self::ENABLE_OPTION_NAME, 'no' );
+	}
+
+	/**
+	 * Early bootstrap hook fired by `Packages::prepare_packages` at
+	 * plugins_loaded priority -100. No-op for the variation gallery feature.
+	 *
+	 * @internal
+	 */
+	public static function prepare(): void {
+	}
+
+	/**
+	 * Initialize the merged variation gallery feature.
+	 *
+	 * @internal
+	 */
+	final public static function init(): void {
+		if ( ! self::is_enabled() ) {
+			return;
+		}
+
+		$container = wc_get_container();
+		$container->get( ClassicVariationGalleryAdmin::class )->register();
+		$container->get( LegacyVariationGalleryCompatibility::class )->register();
+
+		// Action Scheduler initializes on `init`, not `plugins_loaded`.
+		add_action( 'init', array( __CLASS__, 'maybe_schedule_migration' ), 20 );
+	}
+
+	/**
+	 * Schedule the legacy variation gallery migration if it hasn't already
+	 * completed and isn't already pending or running.
+	 *
+	 * @internal
+	 */
+	public static function maybe_schedule_migration(): void {
+		if ( get_option( Migration::COMPLETED_OPTION ) ) {
+			return;
+		}
+
+		$args = array( 'update_callback' => array( Migration::class, 'run' ) );
+
+		if ( self::has_pending_or_running_migration( $args ) ) {
+			return;
+		}
+
+		WC()->queue()->add(
+			self::UPDATE_CALLBACK_HOOK,
+			$args,
+			self::UPDATE_CALLBACK_GROUP
+		);
+	}
+
+	/**
+	 * Determine whether the migration is already pending or running.
+	 *
+	 * @param array<string, array<int, string>> $args Exact callback args for the migration action.
+	 * @return bool
+	 */
+	private static function has_pending_or_running_migration( array $args ): bool {
+		if ( null !== WC()->queue()->get_next( self::UPDATE_CALLBACK_HOOK, $args, self::UPDATE_CALLBACK_GROUP ) ) {
+			return true;
+		}
+
+		$running_actions = WC()->queue()->search(
+			array(
+				'hook'     => self::UPDATE_CALLBACK_HOOK,
+				'args'     => $args,
+				'status'   => \ActionScheduler_Store::STATUS_RUNNING,
+				'per_page' => 1,
+				'group'    => self::UPDATE_CALLBACK_GROUP,
+			),
+			'ids'
+		);
+
+		return ! empty( $running_actions );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php b/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php
new file mode 100644
index 00000000000..ff706ccb4e7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/VariationGallery/Telemetry.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Telemetry for the variation gallery feature.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Tracker snapshot + Tracks events for the merged variation gallery feature.
+ */
+class Telemetry implements RegisterHooksInterface {
+
+	public const EVENT_SAVE_SUCCEEDED      = 'variation_gallery_save_succeeded';
+	public const EVENT_SAVE_FAILED         = 'variation_gallery_save_failed';
+	public const EVENT_MIGRATION_COMPLETED = 'variation_gallery_migration_completed';
+
+	private const LEGACY_PLUGIN_FILE = 'woocommerce-additional-variation-images/woocommerce-additional-variation-images.php';
+
+	/**
+	 * Register the tracker snapshot filter.
+	 *
+	 * @return void
+	 */
+	public function register() {
+		add_filter( 'woocommerce_tracker_data', array( $this, 'add_snapshot_to_tracker_data' ), 10, 1 );
+	}
+
+	/**
+	 * Append the variation gallery snapshot fields to WC_Tracker's payload.
+	 *
+	 * @param array $data The aggregated tracker data.
+	 * @return array
+	 */
+	public function add_snapshot_to_tracker_data( array $data ): array {
+		$data['variation_gallery'] = self::collect_snapshot();
+		return $data;
+	}
+
+	/**
+	 * Collect the variation gallery snapshot fields.
+	 *
+	 * @return array<string, mixed>
+	 */
+	public static function collect_snapshot(): array {
+		global $wpdb;
+
+		$option_value         = get_option( Package::ENABLE_OPTION_NAME, '' );
+		$variant_assignment   = (int) get_option( 'woocommerce_remote_variant_assignment', 0 );
+		$cohort               = ( $variant_assignment > 0 && $variant_assignment <= 5 ) ? 'treatment' : 'control';
+		$legacy_plugin_active = self::is_legacy_plugin_active();
+		$legacy_plugin_file   = WP_PLUGIN_DIR . '/' . self::LEGACY_PLUGIN_FILE;
+
+		$migrated_variation_count = (int) $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s",
+				LegacyVariationGalleryCompatibility::get_core_managed_meta_key()
+			)
+		);
+
+		$variation_gallery_rows = $wpdb->get_col(
+			"SELECT pm.meta_value
+			 FROM {$wpdb->postmeta} pm
+			 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+			 WHERE pm.meta_key = '_product_image_gallery'
+			   AND pm.meta_value <> ''
+			   AND p.post_type = 'product_variation'"
+		);
+
+		$authored_variation_count     = 0;
+		$total_image_count            = 0;
+		$single_image_variation_count = 0;
+		$multi_image_variation_count  = 0;
+
+		foreach ( $variation_gallery_rows as $meta_value ) {
+			$image_ids = wp_parse_id_list( $meta_value );
+			$count     = count( $image_ids );
+			if ( 0 === $count ) {
+				continue;
+			}
+			++$authored_variation_count;
+			$total_image_count += $count;
+			if ( 1 === $count ) {
+				++$single_image_variation_count;
+			} else {
+				++$multi_image_variation_count;
+			}
+		}
+
+		$authored_without_legacy_count = (int) $wpdb->get_var(
+			"SELECT COUNT(*) FROM {$wpdb->postmeta} core_pm
+			 INNER JOIN {$wpdb->posts} p ON p.ID = core_pm.post_id
+			 LEFT JOIN {$wpdb->postmeta} legacy_pm
+			   ON legacy_pm.post_id = core_pm.post_id
+			   AND legacy_pm.meta_key = '_wc_additional_variation_images'
+			 WHERE core_pm.meta_key = '_product_image_gallery'
+			   AND core_pm.meta_value <> ''
+			   AND p.post_type = 'product_variation'
+			   AND legacy_pm.post_id IS NULL"
+		);
+
+		return array(
+			'feature_enabled'               => 'yes' === $option_value ? 'yes' : 'no',
+			'feature_option_explicit'       => '' === $option_value ? 'no' : 'yes',
+			'remote_variant_cohort'         => $cohort,
+			'legacy_avi_plugin_active'      => $legacy_plugin_active ? 'yes' : 'no',
+			'legacy_avi_plugin_installed'   => file_exists( $legacy_plugin_file ) ? 'yes' : 'no',
+			'migrated_variation_count'      => $migrated_variation_count,
+			'authored_variation_count'      => $authored_variation_count,
+			'authored_without_legacy_count' => $authored_without_legacy_count,
+			'total_image_count'             => $total_image_count,
+			'single_image_variation_count'  => $single_image_variation_count,
+			'multi_image_variation_count'   => $multi_image_variation_count,
+		);
+	}
+
+	/**
+	 * Record a Tracks event.
+	 *
+	 * @param string               $event_name One of the `EVENT_*` class constants.
+	 * @param array<string, mixed> $properties Event properties to attach.
+	 * @return void
+	 */
+	public static function record_event( string $event_name, array $properties = array() ): void {
+		if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
+			return;
+		}
+		wc_admin_record_tracks_event( $event_name, $properties );
+	}
+
+	/**
+	 * Whether the legacy Additional Variation Images extension is currently
+	 * active.
+	 *
+	 * @return bool
+	 */
+	private static function is_legacy_plugin_active(): bool {
+		if ( ! function_exists( 'is_plugin_active' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/plugin.php';
+		}
+		return is_plugin_active( self::LEGACY_PLUGIN_FILE );
+	}
+}
diff --git a/plugins/woocommerce/src/Packages.php b/plugins/woocommerce/src/Packages.php
index 736196444df..9952fd64b69 100644
--- a/plugins/woocommerce/src/Packages.php
+++ b/plugins/woocommerce/src/Packages.php
@@ -60,7 +60,8 @@ class Packages {
 	 * @var array Key is the package name/directory, value is the main package class which handles init.
 	 */
 	protected static $merged_packages = array(
-		'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands',
+		'woocommerce-brands'                      => '\\Automattic\\WooCommerce\\Internal\\Brands',
+		'woocommerce-additional-variation-images' => '\\Automattic\\WooCommerce\\Internal\\VariationGallery\\Package',
 	);


diff --git a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
index d35c19a7179..e2291b43b14 100644
--- a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
+++ b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates
- * @version 9.6.0
+ * @version 10.9.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -60,6 +60,14 @@ do_action( 'woocommerce_before_add_to_cart_form' ); ?>
 			</tbody>
 		</table>
 		<div class="reset_variations_alert screen-reader-text" role="alert" aria-live="polite" aria-relevant="all"></div>
+		<?php
+		// Reset snapshot for cases where a theme/plugin loads the variation form later, like quick-view modals.
+		if ( \Automattic\WooCommerce\Internal\VariationGallery\Package::is_enabled() ) :
+			?>
+			<script type="text/template" class="wc-product-gallery-default-template"><?php echo wc_get_product_gallery_html( $product ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></script>
+			<?php
+		endif;
+		?>
 		<?php do_action( 'woocommerce_after_variations_table' ); ?>

 		<div class="single_variation_wrap">
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
index be52ae02db8..be67947956d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-variations.php
@@ -400,7 +400,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertEquals( 41, count( $properties ) );
+		$this->assertEquals( 42, count( $properties ) );
 		$this->assertArrayHasKey( 'id', $properties );
 		$this->assertArrayHasKey( 'date_created', $properties );
 		$this->assertArrayHasKey( 'date_modified', $properties );
@@ -434,6 +434,7 @@ class Product_Variations_API extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'shipping_class', $properties );
 		$this->assertArrayHasKey( 'shipping_class_id', $properties );
 		$this->assertArrayHasKey( 'image', $properties );
+		$this->assertArrayHasKey( 'gallery_image_ids', $properties );
 		$this->assertArrayHasKey( 'attributes', $properties );
 		$this->assertArrayHasKey( 'menu_order', $properties );
 		$this->assertArrayHasKey( 'meta_data', $properties );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
index c6bf23364f6..7e8a2716c8d 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-variable-test.php
@@ -4,6 +4,14 @@
  * Tests for WC_Product_Variable.
  */
 class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
+	/**
+	 * Reset variation gallery feature-flag option leaked by individual tests.
+	 */
+	public function tearDown(): void {
+		delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+		parent::tearDown();
+	}
+
 	/**
 	 * @testdox 'get_available_variations' returns the variations as arrays if no parameters is passed.
 	 */
@@ -162,4 +170,176 @@ class WC_Product_Variable_Test extends \WC_Unit_Test_Case {
 		$this->assertIsBool( $has_purchasable_variations );
 		$this->assertFalse( $has_purchasable_variations );
 	}
+
+	/**
+	 * @testdox 'get_available_variation' exposes typed variation gallery image IDs.
+	 */
+	public function test_get_available_variation_includes_gallery_image_ids() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$product   = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+		$image_id  = wp_insert_attachment(
+			array(
+				'post_title'     => 'Variation Image',
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		$image_ids = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+
+		update_post_meta( $image_id, '_wp_attached_file', 'variation-featured.jpg' );
+		foreach ( $image_ids as $i => $gallery_image_id ) {
+			update_post_meta( $gallery_image_id, '_wp_attached_file', 'variation-gallery-' . ( $i + 1 ) . '.jpg' );
+		}
+
+		$variation->set_image_id( $image_id );
+		$variation->set_gallery_image_ids( $image_ids );
+		$variation->save();
+
+		$available_variation = $product->get_available_variation( $variation );
+
+		$this->assertSame( $image_ids, $available_variation['gallery_image_ids'] );
+		$this->assertNotEmpty( $available_variation['gallery_images_html'] );
+	}
+
+	/**
+	 * @testdox 'get_available_variation' omits multi-image gallery data when the variation gallery feature flag is disabled.
+	 */
+	public function test_get_available_variation_returns_single_image_shape_when_feature_flag_disabled() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'no' );
+
+		$product   = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+		$image_id  = wp_insert_attachment(
+			array(
+				'post_title'     => 'Variation Image',
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		$image_ids = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+
+		update_post_meta( $image_id, '_wp_attached_file', 'variation-disabled.jpg' );
+
+		$variation->set_image_id( $image_id );
+		$variation->set_gallery_image_ids( $image_ids );
+		$variation->save();
+
+		$available_variation = $product->get_available_variation( $variation );
+
+		$this->assertSame( array(), $available_variation['gallery_image_ids'] );
+		$this->assertSame( '', $available_variation['gallery_images_html'] );
+		$this->assertSame( $image_id, $available_variation['image_id'] );
+	}
+
+	/**
+	 * @testdox 'get_available_variation' falls back to the variation's own gallery when the variation featured image is stale.
+	 */
+	public function test_get_available_variation_falls_back_to_variation_gallery_when_featured_is_stale() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$product              = WC_Helper_Product::create_variation_product();
+		$variation            = wc_get_product( $product->get_children()[0] );
+		$parent_featured_id   = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+		$stale_featured_id    = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+		$variation_gallery_id = $this->create_image_attachment( 'Variation Gallery Image', 'variation-gallery.jpg' );
+
+		// Delete-then-assign: set_image_id() doesn't validate the attachment,
+		// but wp_delete_attachment() would clear _thumbnail_id on any post
+		// pointing at it. Doing it in this order leaves the variation
+		// referencing a deleted attachment, which is the bug we're testing.
+
+		$product->set_image_id( $parent_featured_id );
+		$product->save();
+
+		wp_delete_attachment( $stale_featured_id, true );
+
+		$variation->set_image_id( $stale_featured_id );
+		$variation->set_gallery_image_ids( array( $variation_gallery_id ) );
+		$variation->save();
+
+		$available_variation = $product->get_available_variation( $variation );
+
+		$this->assertSame( $variation_gallery_id, $available_variation['image_id'] );
+		$this->assertStringContainsString( 'wp-image-' . $variation_gallery_id, $available_variation['gallery_images_html'] );
+		$this->assertStringNotContainsString( 'wp-image-' . $parent_featured_id, $available_variation['gallery_images_html'] );
+	}
+
+	/**
+	 * @testdox 'get_available_variation' falls back to the parent featured image when both the variation featured image and gallery are absent.
+	 */
+	public function test_get_available_variation_falls_back_to_parent_featured_when_variation_has_no_images() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$product            = WC_Helper_Product::create_variation_product();
+		$variation          = wc_get_product( $product->get_children()[0] );
+		$parent_featured_id = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+		$stale_featured_id  = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+
+		$product->set_image_id( $parent_featured_id );
+		$product->save();
+
+		wp_delete_attachment( $stale_featured_id, true );
+
+		$variation->set_image_id( $stale_featured_id );
+		$variation->set_gallery_image_ids( array() );
+		$variation->save();
+
+		$available_variation = $product->get_available_variation( $variation );
+
+		$this->assertSame( $parent_featured_id, $available_variation['image_id'] );
+		$this->assertSame( '', $available_variation['gallery_images_html'] );
+	}
+
+	/**
+	 * Create a real image attachment that passes `wp_attachment_is_image()`.
+	 *
+	 * @param string $title         Post title.
+	 * @param string $attached_file Synthetic file path.
+	 * @return int
+	 */
+	private function create_image_attachment( string $title, string $attached_file ): int {
+		$attachment_id = wp_insert_attachment(
+			array(
+				'post_title'     => $title,
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+
+		update_post_meta( $attachment_id, '_wp_attached_file', $attached_file );
+
+		return $attachment_id;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
index 3482165fc2e..ac7db2c9eb5 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller-tests.php
@@ -168,6 +168,131 @@ class WC_REST_Product_Variations_Controller_Tests extends WC_REST_Unit_Test_Case
 		$this->assertEquals( 200, $response->get_status() );
 	}

+	/**
+	 * @testdox The variation GET endpoint returns typed gallery image IDs.
+	 */
+	public function test_variation_get_returns_gallery_image_ids() {
+		$parent_product = WC_Helper_Product::create_variation_product();
+		$variation      = wc_get_product( $parent_product->get_children()[0] );
+		$image_ids      = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+
+		$variation->set_gallery_image_ids( $image_ids );
+		$variation->save();
+
+		$response = $this->server->dispatch(
+			new WP_REST_Request(
+				'GET',
+				"/wc/v3/products/{$parent_product->get_id()}/variations/{$variation->get_id()}"
+			)
+		);
+
+		$this->assertEquals( 200, $response->get_status() );
+		$this->assertSame( $image_ids, $response->get_data()['gallery_image_ids'] );
+	}
+
+	/**
+	 * @testdox The variation POST endpoint updates the gallery without touching the featured image (disjoint storage).
+	 */
+	public function test_variation_post_updates_gallery_image_ids() {
+		$parent_product       = WC_Helper_Product::create_variation_product();
+		$variation            = wc_get_product( $parent_product->get_children()[0] );
+		$pre_existing_feature = wp_insert_attachment(
+			array(
+				'post_title'     => 'Pre-existing featured image',
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		$variation->set_image_id( $pre_existing_feature );
+		$variation->save();
+
+		$image_ids = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+
+		$this->update_variation_via_post_request(
+			$variation,
+			array(
+				'gallery_image_ids' => $image_ids,
+			)
+		);
+
+		$variation = wc_get_product( $variation->get_id() );
+
+		$this->assertSame( $image_ids, array_map( 'intval', $variation->get_gallery_image_ids() ) );
+		$this->assertSame(
+			$pre_existing_feature,
+			$variation->get_image_id(),
+			'Setting gallery_image_ids must not touch the featured image — they are disjoint, like on parent products.'
+		);
+		// No legacy meta exists, so the core-managed sentinel is intentionally
+		// not written: the legacy fallback would no-op anyway, and skipping the
+		// row keeps postmeta clean on stores that never used the extension.
+		$this->assertFalse(
+			\Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility::is_variation_id_core_managed(
+				$variation->get_id()
+			)
+		);
+	}
+
+	/**
+	 * @testdox The variation POST endpoint can clear a legacy-only variation gallery.
+	 */
+	public function test_variation_post_can_clear_legacy_gallery_image_ids() {
+		$parent_product = WC_Helper_Product::create_variation_product();
+		$variation      = wc_get_product( $parent_product->get_children()[0] );
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			'101,102'
+		);
+
+		$this->update_variation_via_post_request(
+			$variation,
+			array(
+				'gallery_image_ids' => array(),
+			)
+		);
+
+		$variation = wc_get_product( $variation->get_id() );
+
+		$this->assertSame( array(), $variation->get_gallery_image_ids() );
+		$this->assertTrue(
+			\Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility::is_variation_id_core_managed(
+				$variation->get_id()
+			)
+		);
+	}
+
 	/**
 	 * Test that creating a variation with attributes containing special characters in their slug
 	 * properly saves the attributes.
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index 5d538dc69a2..3d5cf9f766d 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -16,6 +16,15 @@ use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
  */
 class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {

+	/**
+	 * Reset the variation gallery feature-flag option after each test so
+	 * individual cases that flip it on don't leak global state.
+	 */
+	public function tearDown(): void {
+		delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+		parent::tearDown();
+	}
+
 	/**
 	 * @testdox If 'wc_get_price_excluding_tax' gets an order as argument, it passes the order customer to 'WC_Tax::get_rates'.
 	 *
@@ -1019,4 +1028,62 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {

 		WC_Helper_Product::delete_product( $product->get_id() );
 	}
+
+	/**
+	 * @testdox Variable add-to-cart attaches a pristine gallery snapshot to the variation script when the feature is on.
+	 */
+	public function test_woocommerce_variable_add_to_cart_attaches_gallery_snapshot() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$inline_js = $this->capture_variable_add_to_cart_inline_js();
+
+		$this->assertStringContainsString( 'wc_variation_gallery_defaults', $inline_js );
+
+		// Extract the JSON snapshot from the inline JS and verify it contains gallery markup.
+		preg_match( '/\)\[\d+\]\s*=\s*("(?:\\\\.|[^"])*");/', $inline_js, $matches );
+		$this->assertNotEmpty( $matches, 'Inline JS should expose a JSON-encoded snapshot.' );
+		$decoded_snapshot = json_decode( $matches[1] );
+		$this->assertIsString( $decoded_snapshot );
+		$this->assertStringContainsString( 'woocommerce-product-gallery', $decoded_snapshot );
+	}
+
+	/**
+	 * @testdox Variable add-to-cart skips the gallery snapshot when the feature is off.
+	 */
+	public function test_woocommerce_variable_add_to_cart_skips_gallery_snapshot_when_feature_off() {
+		delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+
+		$inline_js = $this->capture_variable_add_to_cart_inline_js();
+
+		$this->assertStringNotContainsString( 'wc_variation_gallery_defaults', $inline_js );
+	}
+
+	/**
+	 * Render the variable add-to-cart template and return the inline JS
+	 * attached to the variation script.
+	 */
+	private function capture_variable_add_to_cart_inline_js(): string {
+		$product = WC_Helper_Product::create_variation_product();
+
+		WC_Frontend_Scripts::load_scripts();
+
+		$wp_scripts = wp_scripts();
+		if ( isset( $wp_scripts->registered['wc-add-to-cart-variation'] ) ) {
+			unset( $wp_scripts->registered['wc-add-to-cart-variation']->extra['before'] );
+		}
+
+		$previous_product   = $GLOBALS['product'] ?? null;
+		$GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		ob_start();
+		woocommerce_variable_add_to_cart();
+		ob_end_clean();
+
+		$before_data = $wp_scripts->registered['wc-add-to-cart-variation']->extra['before'] ?? array();
+
+		$GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		WC_Helper_Product::delete_product( $product->get_id() );
+
+		return implode( "\n", (array) $before_data );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
index cd9e95bd2a8..128fe7c1cb3 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Utils/ProductGalleryUtilsTest.php
@@ -9,10 +9,20 @@ use WP_UnitTestCase;
  * Tests for the ProductGalleryUtils class.
  */
 class ProductGalleryUtilsTest extends \WP_UnitTestCase {
+	/**
+	 * Reset variation gallery feature-flag option leaked by individual tests.
+	 */
+	public function tearDown(): void {
+		delete_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME );
+		parent::tearDown();
+	}
+
 	/**
 	 * Test get_product_gallery_image_data method.
 	 */
 	public function test_get_product_gallery_image_data() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
 		// Create the variable product.
 		$variable_product = \WC_Helper_Product::create_variation_product();

@@ -25,6 +35,7 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 			)
 		);
 		$variable_product->set_image_id( $image_id );
+		update_post_meta( $image_id, '_wp_attached_file', 'product-featured.jpg' );

 		// Create a variation image but don't add it to the gallery.
 		$variation_image_id = wp_insert_attachment(
@@ -34,6 +45,7 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 				'post_mime_type' => 'image/jpeg',
 			)
 		);
+		update_post_meta( $variation_image_id, '_wp_attached_file', 'variation-featured.jpg' );

 		// Get the variations.
 		$variations = $variable_product->get_children();
@@ -62,6 +74,34 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 		);
 		$variable_product->set_gallery_image_ids( $gallery_image_ids );
 		$variable_product->save();
+		foreach ( $gallery_image_ids as $i => $gallery_image_id ) {
+			update_post_meta( $gallery_image_id, '_wp_attached_file', 'product-gallery-' . ( $i + 1 ) . '.jpg' );
+		}
+
+		$variation_gallery_image_ids = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+		foreach ( $variation_gallery_image_ids as $i => $variation_gallery_image_id ) {
+			update_post_meta( $variation_gallery_image_id, '_wp_attached_file', 'variation-gallery-' . ( $i + 1 ) . '.jpg' );
+		}
+
+		if ( isset( $variation ) ) {
+			$variation->set_gallery_image_ids( $variation_gallery_image_ids );
+			$variation->save();
+		}

 		$image_data = ProductGalleryUtils::get_product_gallery_image_data( $variable_product, 'woocommerce_thumbnail' );

@@ -81,6 +121,9 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 		// Assert that the child product image is included in the image data array.
 		$ids = array_column( $image_data, 'id' );
 		$this->assertContains( $variation_image_id, $ids );
+		foreach ( $variation_gallery_image_ids as $variation_gallery_image_id ) {
+			$this->assertContains( $variation_gallery_image_id, $ids );
+		}

 		// Clean up.
 		$variable_product->delete( true );
@@ -89,5 +132,110 @@ class ProductGalleryUtilsTest extends \WP_UnitTestCase {
 		foreach ( $gallery_image_ids as $gallery_image_id ) {
 			wp_delete_attachment( $gallery_image_id, true );
 		}
+		foreach ( $variation_gallery_image_ids as $variation_gallery_image_id ) {
+			wp_delete_attachment( $variation_gallery_image_id, true );
+		}
+	}
+
+	/**
+	 * Test that get_product_variation_gallery_data returns the single-image
+	 * shape when the variation gallery feature flag is disabled, even when
+	 * the variation has multiple gallery images saved.
+	 */
+	public function test_get_product_variation_gallery_data_returns_single_image_when_feature_flag_disabled() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'no' );
+
+		$variable_product = \WC_Helper_Product::create_variation_product();
+
+		$variation_image_id = wp_insert_attachment(
+			array(
+				'post_title'     => 'Variation Featured Image',
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		update_post_meta( $variation_image_id, '_wp_attached_file', 'variation-featured.jpg' );
+
+		$variation_gallery_image_ids = array(
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 1',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+			wp_insert_attachment(
+				array(
+					'post_title'     => 'Variation Gallery Image 2',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			),
+		);
+
+		$variation = wc_get_product( $variable_product->get_children()[0] );
+		$variation->set_image_id( $variation_image_id );
+		$variation->set_gallery_image_ids( $variation_gallery_image_ids );
+		$variation->save();
+
+		$variation_entry = ProductGalleryUtils::get_product_variation_gallery_data( $variable_product )[ $variation->get_id() ];
+
+		$this->assertSame( $variation_image_id, $variation_entry['image_id'] );
+		$this->assertSame( array( $variation_image_id ), $variation_entry['image_ids'] );
+	}
+
+	/**
+	 * Test that variation gallery data falls back to the variation's own gallery when the variation featured image is stale.
+	 */
+	public function test_get_product_variation_gallery_data_falls_back_to_variation_gallery_when_featured_is_stale() {
+		update_option( \Automattic\WooCommerce\Internal\VariationGallery\Package::ENABLE_OPTION_NAME, 'yes' );
+
+		$variable_product     = \WC_Helper_Product::create_variation_product();
+		$parent_featured_id   = $this->create_image_attachment( 'Parent Featured Image', 'parent-featured.jpg' );
+		$stale_featured_id    = $this->create_image_attachment( 'Stale Variation Image', 'stale-featured.jpg' );
+		$variation_gallery_id = $this->create_image_attachment( 'Variation Gallery Image', 'variation-gallery.jpg' );
+
+		$variable_product->set_image_id( $parent_featured_id );
+		$variable_product->save();
+
+		$variation = wc_get_product( $variable_product->get_children()[0] );
+
+		// Delete-then-assign: leaves the variation referencing a deleted
+		// attachment (the bug we're testing). Reversing the order would let
+		// wp_delete_attachment clear _thumbnail_id automatically.
+		wp_delete_attachment( $stale_featured_id, true );
+
+		$variation->set_image_id( $stale_featured_id );
+		$variation->set_gallery_image_ids( array( $variation_gallery_id ) );
+		$variation->save();
+
+		$variation_gallery_data = ProductGalleryUtils::get_product_variation_gallery_data( $variable_product );
+
+		$this->assertSame( $variation_gallery_id, $variation_gallery_data[ $variation->get_id() ]['image_id'] );
+		$this->assertSame(
+			array( $variation_gallery_id ),
+			$variation_gallery_data[ $variation->get_id() ]['image_ids']
+		);
+	}
+
+	/**
+	 * Create a real image attachment that passes `wp_attachment_is_image()`.
+	 *
+	 * @param string $title         Post title.
+	 * @param string $attached_file Synthetic file path.
+	 * @return int
+	 */
+	private function create_image_attachment( string $title, string $attached_file ): int {
+		$attachment_id = wp_insert_attachment(
+			array(
+				'post_title'     => $title,
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+
+		update_post_meta( $attachment_id, '_wp_attached_file', $attached_file );
+
+		return $attachment_id;
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php
new file mode 100644
index 00000000000..b9681cf1628
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/ClassicVariationGalleryAdminTest.php
@@ -0,0 +1,215 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\ClassicVariationGalleryAdmin;
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use WC_Helper_Product;
+use WC_Product_Variation;
+
+/**
+ * Tests for ClassicVariationGalleryAdmin.
+ */
+class ClassicVariationGalleryAdminTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * System under test.
+	 *
+	 * @var ClassicVariationGalleryAdmin
+	 */
+	private $sut;
+
+	/**
+	 * Set up test dependencies.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new ClassicVariationGalleryAdmin();
+	}
+
+	/**
+	 * Reset globals after each test.
+	 */
+	public function tearDown(): void {
+		unset( $_POST['variable_gallery_image_ids'] );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox The classic variation gallery field renders legacy images via the runtime fallback.
+	 */
+	public function test_render_variation_gallery_field_renders_legacy_images() {
+		$variation = $this->create_variation();
+		$image_ids = array(
+			$this->create_attachment( 'Legacy gallery image 1' ),
+			$this->create_attachment( 'Legacy gallery image 2' ),
+		);
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			implode( ',', $image_ids )
+		);
+
+		ob_start();
+		$this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+		$output = (string) ob_get_clean();
+
+		$this->assertStringContainsString(
+			'value="' . implode( ',', $image_ids ) . '"',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox A featured image absent from the gallery gets prepended in the editor view so the first save normalizes storage.
+	 */
+	public function test_render_variation_gallery_field_prepends_featured_image_when_missing_from_gallery() {
+		$variation = $this->create_variation();
+		$featured  = $this->create_attachment( 'Featured-only image' );
+		$gallery   = $this->create_attachment( 'Gallery-only image' );
+
+		$variation->set_image_id( $featured );
+		$variation->set_gallery_image_ids( array( $gallery ) );
+		$variation->save();
+
+		ob_start();
+		$this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+		$output = (string) ob_get_clean();
+
+		// Featured image must be prepended to the list (value) AND get the active class.
+		$this->assertStringContainsString( 'value="' . $featured . ',' . $gallery . '"', $output );
+		$this->assertMatchesRegularExpression(
+			'/<li\s+class="wc-variation-gallery-thumb is-active"\s+data-attachment_id="' . $featured . '"/',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox Broken attachments are kept in the rendered list instead of silently dropped.
+	 */
+	public function test_render_variation_gallery_field_keeps_broken_attachments() {
+		$variation = $this->create_variation();
+		$missing   = 99999;
+		$good      = $this->create_attachment( 'Intact gallery image' );
+
+		$variation->set_gallery_image_ids( array( $missing, $good ) );
+		$variation->save();
+
+		ob_start();
+		$this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+		$output = (string) ob_get_clean();
+
+		$this->assertStringContainsString(
+			'data-attachment_id="' . $missing . '"',
+			$output
+		);
+	}
+
+	/**
+	 * @testdox Empty variation rows render the hidden gallery input needed for the first save.
+	 */
+	public function test_render_variation_gallery_field_renders_hidden_input_for_empty_gallery() {
+		$variation = $this->create_variation();
+
+		ob_start();
+		$this->sut->render_variation_gallery_field( 0, array(), get_post( $variation->get_id() ) );
+		$output = (string) ob_get_clean();
+
+		$this->assertStringContainsString( 'name="variable_gallery_image_ids[0]"', $output );
+		$this->assertStringContainsString( 'value=""', $output );
+	}
+
+	/**
+	 * @testdox Saving an empty variation gallery clears featured + gallery and disables the legacy fallback.
+	 */
+	public function test_saving_empty_variation_gallery_disables_legacy_fallback() {
+		$variation             = $this->create_variation();
+		$pre_existing_featured = $this->create_attachment( 'Pre-existing featured image' );
+		$image_ids             = array(
+			$this->create_attachment( 'Legacy gallery image 1' ),
+			$this->create_attachment( 'Legacy gallery image 2' ),
+		);
+
+		// Pre-set a featured image so we can assert the empty save also clears it.
+		$variation->set_image_id( $pre_existing_featured );
+		$variation->save();
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			implode( ',', $image_ids )
+		);
+
+		$_POST['variable_gallery_image_ids'][0] = '';
+
+		$this->sut->persist_variation_gallery_field( $variation, 0 );
+		$variation->save();
+
+		$reloaded_variation = wc_get_product( $variation->get_id() );
+
+		$this->assertSame( 0, $reloaded_variation->get_image_id( 'edit' ), 'Featured image should be cleared on empty save.' );
+		$this->assertSame( array(), $reloaded_variation->get_gallery_image_ids() );
+		$this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation->get_id() ) );
+	}
+
+	/**
+	 * @testdox Saving the unified list assigns position 0 as the featured image and the rest as the gallery (matching parent product semantics).
+	 */
+	public function test_saving_variation_gallery_splits_featured_from_gallery() {
+		$variation = $this->create_variation();
+		$image_ids = array(
+			$this->create_attachment( 'Core gallery image 1' ),
+			$this->create_attachment( 'Core gallery image 2' ),
+			$this->create_attachment( 'Core gallery image 3' ),
+		);
+
+		// Includes a non-numeric token plus a duplicate of position 0 to verify
+		// sanitization survives the split.
+		$_POST['variable_gallery_image_ids'][0] = $image_ids[0] . ',not-an-id,' . $image_ids[1] . ',' . $image_ids[2] . ',' . $image_ids[0];
+
+		$this->sut->persist_variation_gallery_field( $variation, 0 );
+		$variation->save();
+
+		$reloaded_variation = wc_get_product( $variation->get_id() );
+
+		$this->assertSame(
+			$image_ids[0],
+			$reloaded_variation->get_image_id( 'edit' ),
+			'Position 0 of the unified list should become the featured image.'
+		);
+		$this->assertSame(
+			array( $image_ids[1], $image_ids[2] ),
+			array_values( array_map( 'intval', $reloaded_variation->get_gallery_image_ids( 'edit' ) ) ),
+			'Subsequent positions should become the gallery, disjoint from featured.'
+		);
+	}
+
+	/**
+	 * Create a variation for testing.
+	 *
+	 * @return WC_Product_Variation
+	 */
+	private function create_variation(): WC_Product_Variation {
+		$product = WC_Helper_Product::create_variation_product();
+
+		return wc_get_product( $product->get_children()[0] );
+	}
+
+	/**
+	 * Create a test attachment.
+	 *
+	 * @param string $title Attachment title.
+	 * @return int
+	 */
+	private function create_attachment( string $title ): int {
+		return wp_insert_attachment(
+			array(
+				'post_title'     => $title,
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php
new file mode 100644
index 00000000000..6343a49f5ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/LegacyVariationGalleryCompatibilityTest.php
@@ -0,0 +1,141 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use WC_Helper_Product;
+use WC_Product_Variation;
+
+/**
+ * Tests for LegacyVariationGalleryCompatibility.
+ */
+class LegacyVariationGalleryCompatibilityTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * System under test.
+	 *
+	 * @var LegacyVariationGalleryCompatibility
+	 */
+	private $sut;
+
+	/**
+	 * Set up test dependencies.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut = new LegacyVariationGalleryCompatibility();
+	}
+
+	/**
+	 * Remove registered filters after each test.
+	 */
+	public function tearDown(): void {
+		remove_filter(
+			'woocommerce_product_variation_get_gallery_image_ids',
+			array( $this->sut, 'maybe_read_legacy_gallery_image_ids' ),
+			10
+		);
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Legacy variation gallery meta is ignored when the core gallery already has values.
+	 */
+	public function test_legacy_gallery_meta_is_ignored_when_core_gallery_is_present() {
+		$variation          = $this->create_variation();
+		$core_gallery_ids   = array(
+			$this->create_attachment( 'Core variation gallery 1' ),
+			$this->create_attachment( 'Core variation gallery 2' ),
+		);
+		$legacy_gallery_ids = array(
+			$this->create_attachment( 'Legacy variation gallery 1' ),
+			$this->create_attachment( 'Legacy variation gallery 2' ),
+		);
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			implode( ',', $legacy_gallery_ids )
+		);
+
+		$this->assertSame(
+			$core_gallery_ids,
+			$this->sut->maybe_read_legacy_gallery_image_ids(
+				$core_gallery_ids,
+				$variation
+			)
+		);
+	}
+
+	/**
+	 * @testdox Legacy variation gallery meta is ignored when fallback has been disabled.
+	 */
+	public function test_legacy_gallery_meta_is_ignored_when_fallback_has_been_disabled() {
+		$variation = $this->create_variation();
+		$image_ids = array(
+			$this->create_attachment( 'Legacy variation gallery 1' ),
+			$this->create_attachment( 'Legacy variation gallery 2' ),
+		);
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			implode( ',', $image_ids )
+		);
+		LegacyVariationGalleryCompatibility::mark_variation_id_core_managed( $variation->get_id() );
+
+		$this->assertSame(
+			array(),
+			$this->sut->maybe_read_legacy_gallery_image_ids( array(), $variation )
+		);
+	}
+
+	/**
+	 * @testdox Register wires the legacy gallery fallback into variation prop reads.
+	 */
+	public function test_register_wires_legacy_gallery_fallback_into_variation_prop_reads() {
+		$variation = $this->create_variation();
+		$image_ids = array(
+			$this->create_attachment( 'Legacy variation gallery 1' ),
+			$this->create_attachment( 'Legacy variation gallery 2' ),
+		);
+
+		update_post_meta(
+			$variation->get_id(),
+			'_wc_additional_variation_images',
+			implode( ',', $image_ids )
+		);
+
+		$this->sut->register();
+
+		$this->assertSame( $image_ids, array_map( 'intval', $variation->get_gallery_image_ids() ) );
+	}
+
+	/**
+	 * Create a variation for testing.
+	 *
+	 * @return WC_Product_Variation
+	 */
+	private function create_variation(): WC_Product_Variation {
+		$product = WC_Helper_Product::create_variation_product();
+
+		return wc_get_product( $product->get_children()[0] );
+	}
+
+	/**
+	 * Create a test attachment.
+	 *
+	 * @param string $title Attachment title.
+	 * @return int
+	 */
+	private function create_attachment( string $title ): int {
+		return wp_insert_attachment(
+			array(
+				'post_title'     => $title,
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php
new file mode 100644
index 00000000000..403c5fca2aa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/MigrationTest.php
@@ -0,0 +1,146 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\LegacyVariationGalleryCompatibility;
+use Automattic\WooCommerce\Internal\VariationGallery\Migration;
+use WC_Helper_Product;
+
+/**
+ * Tests for the legacy variation gallery migration runner.
+ */
+class MigrationTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Migration copies legacy variation gallery meta into the core gallery prop and disables fallback.
+	 */
+	public function test_migration_copies_legacy_gallery_and_disables_fallback(): void {
+		$variation_id = $this->create_variation();
+		$image_ids    = array(
+			$this->create_attachment( 'Legacy gallery image 1' ),
+			$this->create_attachment( 'Legacy gallery image 2' ),
+		);
+
+		update_post_meta( $variation_id, '_wc_additional_variation_images', implode( ',', $image_ids ) );
+
+		$this->assertFalse( Migration::run() );
+
+		$this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+		$this->assertSame( implode( ',', $image_ids ), get_post_meta( $variation_id, '_product_image_gallery', true ) );
+	}
+
+	/**
+	 * @testdox Migration preserves existing core variation gallery values while disabling fallback.
+	 */
+	public function test_migration_preserves_existing_core_gallery(): void {
+		$variation_id       = $this->create_variation();
+		$core_gallery_ids   = array(
+			$this->create_attachment( 'Core gallery image 1' ),
+			$this->create_attachment( 'Core gallery image 2' ),
+		);
+		$legacy_gallery_ids = array(
+			$this->create_attachment( 'Legacy gallery image 1' ),
+			$this->create_attachment( 'Legacy gallery image 2' ),
+		);
+
+		update_post_meta( $variation_id, '_product_image_gallery', implode( ',', $core_gallery_ids ) );
+		update_post_meta( $variation_id, '_wc_additional_variation_images', implode( ',', $legacy_gallery_ids ) );
+
+		$this->assertFalse( Migration::run() );
+
+		$this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+		$this->assertSame( implode( ',', $core_gallery_ids ), get_post_meta( $variation_id, '_product_image_gallery', true ) );
+	}
+
+	/**
+	 * @testdox Migration disables fallback for malformed legacy variation gallery meta without writing invalid core values.
+	 */
+	public function test_migration_disables_fallback_for_malformed_legacy_meta(): void {
+		$variation_id = $this->create_variation();
+
+		update_post_meta( $variation_id, '_wc_additional_variation_images', 'not-an-id' );
+
+		$this->assertFalse( Migration::run() );
+
+		$this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+		$this->assertSame( '', get_post_meta( $variation_id, '_product_image_gallery', true ) );
+	}
+
+	/**
+	 * @testdox Migration batches legacy variation gallery rows and requeues until complete.
+	 */
+	public function test_migration_batches_updates(): void {
+		global $wpdb;
+
+		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Test setup needs to scope deletes by meta_key.
+		$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_wc_additional_variation_images' ) );
+		$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_product_image_gallery' ) );
+		$wpdb->delete( $wpdb->postmeta, array( 'meta_key' => LegacyVariationGalleryCompatibility::get_core_managed_meta_key() ) );
+		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+
+		$variation_ids = array();
+
+		for ( $index = 0; $index < 251; ++$index ) {
+			$variation_id    = $this->create_variation_post();
+			$variation_ids[] = $variation_id;
+			update_post_meta( $variation_id, '_wc_additional_variation_images', (string) ( $index + 1 ) );
+		}
+
+		$this->assertTrue( Migration::run() );
+
+		$processed_after_first_batch = 0;
+
+		foreach ( $variation_ids as $variation_id ) {
+			if ( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) ) {
+				++$processed_after_first_batch;
+			}
+		}
+
+		$this->assertSame( 250, $processed_after_first_batch );
+		$this->assertFalse( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( end( $variation_ids ) ) );
+
+		$this->assertFalse( Migration::run() );
+
+		foreach ( $variation_ids as $variation_id ) {
+			$this->assertTrue( LegacyVariationGalleryCompatibility::is_variation_id_core_managed( $variation_id ) );
+		}
+	}
+
+	/**
+	 * Create a variation for testing.
+	 */
+	private function create_variation(): int {
+		$product = WC_Helper_Product::create_variation_product();
+
+		return (int) $product->get_children()[0];
+	}
+
+	/**
+	 * Create a bare variation post for migration batching tests.
+	 */
+	private function create_variation_post(): int {
+		return self::factory()->post->create(
+			array(
+				'post_type'   => 'product_variation',
+				'post_status' => 'publish',
+			)
+		);
+	}
+
+	/**
+	 * Create a test attachment.
+	 *
+	 * @param string $title Attachment title.
+	 * @return int
+	 */
+	private function create_attachment( string $title ): int {
+		return wp_insert_attachment(
+			array(
+				'post_title'     => $title,
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php
new file mode 100644
index 00000000000..c2d9be64c89
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/VariationGallery/PackageTest.php
@@ -0,0 +1,120 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\VariationGallery;
+
+use Automattic\WooCommerce\Internal\VariationGallery\Migration;
+use Automattic\WooCommerce\Internal\VariationGallery\Package;
+
+/**
+ * Tests for the variation gallery package bootstrap.
+ */
+class PackageTest extends \WC_Unit_Test_Case {
+
+	/**
+	 * Reset migration-related state between tests so action queue and
+	 * completion option don't leak across cases.
+	 */
+	public function tearDown(): void {
+		WC()->queue()->cancel_all(
+			'woocommerce_run_update_callback',
+			$this->get_migration_action_args(),
+			'woocommerce-db-updates'
+		);
+		WC()->queue()->cancel_all(
+			'woocommerce_run_update_callback',
+			$this->get_unrelated_update_action_args(),
+			'woocommerce-db-updates'
+		);
+		delete_option( Migration::COMPLETED_OPTION );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox maybe_schedule_migration queues the migration.
+	 */
+	public function test_maybe_schedule_migration_queues_the_migration(): void {
+		Package::maybe_schedule_migration();
+
+		$this->assertNotNull(
+			WC()->queue()->get_next(
+				'woocommerce_run_update_callback',
+				$this->get_migration_action_args(),
+				'woocommerce-db-updates'
+			)
+		);
+	}
+
+	/**
+	 * @testdox maybe_schedule_migration does not duplicate the migration when other DB updates are pending.
+	 */
+	public function test_maybe_schedule_migration_does_not_duplicate_existing_migration(): void {
+		WC()->queue()->add(
+			'woocommerce_run_update_callback',
+			$this->get_unrelated_update_action_args(),
+			'woocommerce-db-updates'
+		);
+		WC()->queue()->add(
+			'woocommerce_run_update_callback',
+			$this->get_migration_action_args(),
+			'woocommerce-db-updates'
+		);
+
+		Package::maybe_schedule_migration();
+
+		$scheduled = WC()->queue()->search(
+			array(
+				'hook'     => 'woocommerce_run_update_callback',
+				'args'     => $this->get_migration_action_args(),
+				'status'   => \ActionScheduler_Store::STATUS_PENDING,
+				'per_page' => -1,
+				'group'    => 'woocommerce-db-updates',
+			),
+			'ids'
+		);
+
+		$this->assertCount( 1, $scheduled );
+	}
+
+	/**
+	 * @testdox maybe_schedule_migration does not queue after completion.
+	 */
+	public function test_maybe_schedule_migration_does_not_queue_after_completion(): void {
+		update_option( Migration::COMPLETED_OPTION, time() );
+
+		Package::maybe_schedule_migration();
+
+		$this->assertNull(
+			WC()->queue()->get_next(
+				'woocommerce_run_update_callback',
+				$this->get_migration_action_args(),
+				'woocommerce-db-updates'
+			)
+		);
+	}
+
+	/**
+	 * The action args expected for the migration callback.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function get_migration_action_args(): array {
+		return array(
+			'update_callback' => array( Migration::class, 'run' ),
+		);
+	}
+
+	/**
+	 * Stand-in action args for an unrelated DB update callback, used to
+	 * verify the migration scheduler doesn't confuse other pending actions
+	 * for its own.
+	 *
+	 * @return array<string, mixed>
+	 */
+	private function get_unrelated_update_action_args(): array {
+		return array(
+			'update_callback' => 'some_other_update_callback',
+		);
+	}
+}