Commit c1eee83e7d8 for woocommerce

commit c1eee83e7d887da27de6289d733bf8ba21be2046
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Mon Jun 1 09:35:58 2026 +0200

    Add support for images in wc-visual attributes (#65347)

diff --git a/plugins/woocommerce/changelog/add-wc-visual-images b/plugins/woocommerce/changelog/add-wc-visual-images
new file mode 100644
index 00000000000..38b45277d15
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wc-visual-images
@@ -0,0 +1,3 @@
+Significance: patch
+Type: add
+Comment: Add support for images in wc-visual attributes
diff --git a/plugins/woocommerce/client/admin/client/typings/global.d.ts b/plugins/woocommerce/client/admin/client/typings/global.d.ts
index 0a9f2fedfb4..330b05da639 100644
--- a/plugins/woocommerce/client/admin/client/typings/global.d.ts
+++ b/plugins/woocommerce/client/admin/client/typings/global.d.ts
@@ -104,6 +104,7 @@ declare global {
 					img_select?: wp.media.frame;
 				};
 				( options: wp.media.frameOptions ): wp.media.frame;
+				attachment: ( id: number ) => wp.media.attachment;
 			};
 		};
 		tinymce?: {
@@ -132,9 +133,19 @@ declare global {
 		}

 		interface frameOptions {
+			title?: string;
+			button?: {
+				text: string;
+			};
 			library: {
 				type: string;
 			};
+			multiple?: boolean;
+		}
+
+		interface attachment {
+			fetch(): Promise< void >;
+			get( key: string ): unknown;
 		}
 	}
 }
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/image-field.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/image-field.tsx
new file mode 100644
index 00000000000..f425b09ddba
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/image-field.tsx
@@ -0,0 +1,259 @@
+/**
+ * External dependencies
+ */
+import { createRoot, useEffect, useRef, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import {
+	COLOR_INPUT_SELECTOR,
+	clearSiblingVisualInput,
+	clearVisualInput,
+	IMAGE_INPUT_SELECTOR,
+	observeInputValueChanges,
+} from './utils';
+
+const WRAPPER_CLASS = 'wc-admin-visual-attribute-image-picker-root';
+const EMPTY_IMAGE_VALUE = '';
+
+const getInitialImageId = ( input: HTMLInputElement ) => {
+	const attributeValue = input.value || input.getAttribute( 'value' ) || '';
+
+	return attributeValue.trim();
+};
+
+type MediaAttachment = {
+	id: number;
+	url: string;
+	sizes?: {
+		thumbnail?: {
+			url: string;
+		};
+	};
+};
+
+const ImageField = ( { input }: { input: HTMLInputElement } ) => {
+	const [ imageId, setImageId ] = useState( () =>
+		getInitialImageId( input )
+	);
+	const [ previewUrl, setPreviewUrl ] = useState( '' );
+	const [ isMediaFrameOpen, setIsMediaFrameOpen ] = useState( false );
+	const triggerRef = useRef< HTMLButtonElement | null >( null );
+	const mediaFrameRef = useRef< wp.media.frame | null >( null );
+
+	// Listen to changes in the input field. Because WP core uses jQuery, we
+	// can't listen to native `change` and `input` events. Instead, we override
+	// the `value` property to sync input changes to the image picker.
+	// @see https://github.com/WordPress/wordpress-develop/blob/bd4e3c97903743ab455682f32dbf38d1b38b715a/src/js/_enqueues/admin/tags.js#L194
+	useEffect( () => {
+		return observeInputValueChanges( input, ( nextValue ) => {
+			const nextImageId = nextValue.trim();
+			setImageId( nextImageId );
+
+			if ( ! nextImageId ) {
+				setPreviewUrl( '' );
+			}
+		} );
+	}, [ input ] );
+
+	useEffect( () => {
+		if ( getInitialImageId( input ) === imageId ) {
+			return;
+		}
+
+		input.value = imageId;
+		input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
+		input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+	}, [ imageId, input ] );
+
+	useEffect( () => {
+		let isCurrent = true;
+
+		if ( ! imageId ) {
+			setPreviewUrl( '' );
+			return () => {
+				isCurrent = false;
+			};
+		}
+
+		const attachmentId = Number.parseInt( imageId, 10 );
+
+		if ( Number.isNaN( attachmentId ) || attachmentId <= 0 ) {
+			setPreviewUrl( '' );
+			return () => {
+				isCurrent = false;
+			};
+		}
+
+		const attachment = window.wp.media.attachment( attachmentId );
+
+		attachment
+			.fetch()
+			.then( () => {
+				if ( ! isCurrent ) {
+					return;
+				}
+
+				const sizes = attachment.get(
+					'sizes'
+				) as MediaAttachment[ 'sizes' ];
+				const url =
+					sizes?.thumbnail?.url ||
+					( attachment.get( 'url' ) as string ) ||
+					'';
+
+				setPreviewUrl( url );
+			} )
+			.catch( () => {
+				if ( isCurrent ) {
+					setPreviewUrl( '' );
+				}
+			} );
+
+		return () => {
+			isCurrent = false;
+		};
+	}, [ imageId ] );
+
+	const setImageValue = ( attachment: MediaAttachment ) => {
+		const nextImageId = String( attachment.id );
+		const thumbnailUrl =
+			attachment.sizes?.thumbnail?.url || attachment.url || '';
+
+		setImageId( nextImageId );
+		setPreviewUrl( thumbnailUrl );
+		clearSiblingVisualInput( input, COLOR_INPUT_SELECTOR );
+	};
+
+	const openMediaLibrary = () => {
+		let frame = mediaFrameRef.current;
+
+		if ( ! frame ) {
+			frame = window.wp.media( {
+				title: __( 'Choose an image', 'woocommerce' ),
+				button: {
+					text: __( 'Use image', 'woocommerce' ),
+				},
+				library: {
+					type: 'image',
+				},
+				multiple: false,
+			} );
+
+			frame.on( 'select', () => {
+				const selection = frame?.state().get( 'selection' );
+
+				if ( ! selection?.first ) {
+					return;
+				}
+
+				const attachment = selection
+					.first()
+					.toJSON() as MediaAttachment;
+
+				if ( attachment?.id ) {
+					setImageValue( attachment );
+				}
+			} );
+			frame.on( 'open', () => setIsMediaFrameOpen( true ) );
+			frame.on( 'close', () => setIsMediaFrameOpen( false ) );
+
+			mediaFrameRef.current = frame;
+		}
+
+		frame.open();
+	};
+
+	const clearImage = () => {
+		setImageId( EMPTY_IMAGE_VALUE );
+		setPreviewUrl( '' );
+		clearVisualInput( input );
+	};
+
+	const hasImage = Number.parseInt( imageId, 10 ) > 0;
+
+	const displayedImageValue = hasImage
+		? __( 'Change image', 'woocommerce' )
+		: __( 'Select an image', 'woocommerce' );
+
+	return (
+		<>
+			<button
+				ref={ triggerRef }
+				type="button"
+				className="wc-admin-visual-attribute-image-picker-trigger"
+				onClick={ openMediaLibrary }
+				aria-haspopup="dialog"
+				aria-expanded={ isMediaFrameOpen }
+			>
+				<span
+					className={ `wc-admin-image-swatch${
+						previewUrl ? '' : ' is-empty'
+					}` }
+					aria-hidden="true"
+				>
+					{ previewUrl ? <img src={ previewUrl } alt="" /> : null }
+				</span>
+				<span>{ displayedImageValue }</span>
+			</button>
+			{ hasImage && (
+				<button
+					type="button"
+					className="button-link wc-admin-visual-attribute-image-picker-clear"
+					onClick={ clearImage }
+				>
+					{ __( 'Clear', 'woocommerce' ) }
+				</button>
+			) }
+		</>
+	);
+};
+
+export const mountImagePicker = ( input: HTMLInputElement ) => {
+	if ( input.dataset.wcImagePickerMounted === '1' ) {
+		return;
+	}
+
+	input.dataset.wcImagePickerMounted = '1';
+	input.style.height = '0';
+	input.style.width = '0';
+	input.style.position = 'absolute';
+	input.style.top = '0';
+	input.style.left = '0';
+	input.style.opacity = '0';
+	input.style.visibility = 'hidden';
+	input.style.pointerEvents = 'none';
+	input.style.userSelect = 'none';
+
+	const wrapper = document.createElement( 'div' );
+	wrapper.className = WRAPPER_CLASS;
+	input.insertAdjacentElement( 'beforebegin', wrapper );
+
+	const root = createRoot( wrapper );
+	root.render( <ImageField input={ input } /> );
+
+	const associatedLabels = input.labels ? Array.from( input.labels ) : [];
+	associatedLabels.forEach( ( labelElement ) => {
+		labelElement.addEventListener( 'click', ( event ) => {
+			event.preventDefault();
+
+			const trigger = wrapper.querySelector< HTMLButtonElement >(
+				'.wc-admin-visual-attribute-image-picker-trigger'
+			);
+
+			trigger?.click();
+		} );
+	} );
+};
+
+export const mountAllImagePickers = ( context: ParentNode = document ) => {
+	const imageInputs = context.querySelectorAll( IMAGE_INPUT_SELECTOR );
+
+	imageInputs.forEach( ( inputElement ) => {
+		if ( inputElement instanceof HTMLInputElement ) {
+			mountImagePicker( inputElement );
+		}
+	} );
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx
index 0a82c2764af..361734d7863 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/index.tsx
@@ -5,7 +5,18 @@ import { ColorPicker, Popover } from '@wordpress/components';
 import { createRoot, useEffect, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';

-const INPUT_SELECTOR = 'input.wc-admin-visual-attribute-color-input';
+/**
+ * Internal dependencies
+ */
+import { mountAllImagePickers } from './image-field';
+import { mountAllVisualTypeSwitchers } from './type-switcher';
+import {
+	COLOR_INPUT_SELECTOR,
+	IMAGE_INPUT_SELECTOR,
+	clearSiblingVisualInput,
+	observeInputValueChanges,
+} from './utils';
+
 const WRAPPER_CLASS = 'wc-admin-visual-attribute-color-picker-root';
 const FALLBACK_COLOR = '#000000';
 const EMPTY_COLOR_VALUE = '';
@@ -36,35 +47,10 @@ const ColorField = ( { input }: { input: HTMLInputElement } ) => {
 	// the `value` property to sync input changes to the color picker.
 	// @see https://github.com/WordPress/wordpress-develop/blob/bd4e3c97903743ab455682f32dbf38d1b38b715a/src/js/_enqueues/admin/tags.js#L194
 	useEffect( () => {
-		const syncColorWithInput = ( nextValue: string ) => {
+		return observeInputValueChanges( input, ( nextValue ) => {
 			const nextColor = normalizeColor( nextValue );
-
 			setColor( nextColor );
-		};
-		const inputPrototype = HTMLInputElement.prototype;
-		const valueDescriptor = Object.getOwnPropertyDescriptor(
-			inputPrototype,
-			'value'
-		);
-		let hasValueOverride = false;
-
-		if ( valueDescriptor?.get && valueDescriptor.set ) {
-			Object.defineProperty( input, 'value', {
-				...valueDescriptor,
-				configurable: true,
-				set( nextValue: string ) {
-					valueDescriptor.set?.call( this, nextValue );
-					syncColorWithInput( nextValue );
-				},
-			} );
-			hasValueOverride = true;
-		}
-
-		return () => {
-			if ( hasValueOverride ) {
-				delete ( input as { value?: string } ).value;
-			}
-		};
+		} );
 	}, [ input ] );

 	useEffect( () => {
@@ -78,6 +64,11 @@ const ColorField = ( { input }: { input: HTMLInputElement } ) => {

 	const handleColorSelection = ( value: string ) => {
 		const nextColor = normalizeColor( value );
+
+		if ( nextColor ) {
+			clearSiblingVisualInput( input, IMAGE_INPUT_SELECTOR );
+		}
+
 		setColor( nextColor );
 	};

@@ -175,7 +166,7 @@ const mountColorPicker = ( input: HTMLInputElement ) => {
 };

 const mountAllColorPickers = ( context: ParentNode = document ) => {
-	const colorInputs = context.querySelectorAll( INPUT_SELECTOR );
+	const colorInputs = context.querySelectorAll( COLOR_INPUT_SELECTOR );

 	colorInputs.forEach( ( inputElement ) => {
 		if ( inputElement instanceof HTMLInputElement ) {
@@ -184,12 +175,18 @@ const mountAllColorPickers = ( context: ParentNode = document ) => {
 	} );
 };

+const mountAllVisualAttributeFields = ( context: ParentNode = document ) => {
+	mountAllColorPickers( context );
+	mountAllImagePickers( context );
+	mountAllVisualTypeSwitchers( context );
+};
+
 const startObserver = () => {
 	const observer = new MutationObserver( ( mutationList ) => {
 		mutationList.forEach( ( mutation ) => {
 			mutation.addedNodes.forEach( ( node ) => {
 				if ( node instanceof HTMLElement ) {
-					mountAllColorPickers( node );
+					mountAllVisualAttributeFields( node );
 				}
 			} );
 		} );
@@ -201,5 +198,5 @@ const startObserver = () => {
 	} );
 };

-mountAllColorPickers();
+mountAllVisualAttributeFields();
 startObserver();
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/type-switcher.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/type-switcher.tsx
new file mode 100644
index 00000000000..9c103c6ac8a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/type-switcher.tsx
@@ -0,0 +1,88 @@
+/**
+ * Internal dependencies
+ */
+import { IMAGE_INPUT_SELECTOR } from './utils';
+
+const VISUAL_TYPE_RADIO_SELECTOR = 'input[name="wc_visual_attribute_type"]';
+const COLOR_FIELD_WRAPPER_SELECTOR = '.wc-admin-visual-attribute-color';
+const IMAGE_FIELD_WRAPPER_SELECTOR = '.wc-admin-visual-attribute-image';
+const VISUAL_TYPE_COLOR = 'color';
+const VISUAL_TYPE_IMAGE = 'image';
+
+const hasStoredImage = ( input: HTMLInputElement ) => {
+	const inputValue = input.value || input.getAttribute( 'value' ) || '';
+	const imageId = Number.parseInt( inputValue, 10 );
+
+	return imageId > 0;
+};
+
+const setVisualFieldVisibility = (
+	container: ParentNode,
+	visualType: typeof VISUAL_TYPE_COLOR | typeof VISUAL_TYPE_IMAGE
+) => {
+	const colorField = container.querySelector( COLOR_FIELD_WRAPPER_SELECTOR );
+	const imageField = container.querySelector( IMAGE_FIELD_WRAPPER_SELECTOR );
+
+	if ( colorField instanceof HTMLElement ) {
+		colorField.hidden = visualType !== VISUAL_TYPE_COLOR;
+	}
+
+	if ( imageField instanceof HTMLElement ) {
+		imageField.hidden = visualType !== VISUAL_TYPE_IMAGE;
+	}
+};
+
+const mountVisualTypeSwitcher = ( visualTypeSwitcher: HTMLElement ) => {
+	const container = visualTypeSwitcher.closest( 'form' );
+
+	if ( ! container ) {
+		return;
+	}
+
+	const imageInput = container.querySelector( IMAGE_INPUT_SELECTOR );
+	const visualTypeRadios = Array.from(
+		container.querySelectorAll< HTMLInputElement >(
+			VISUAL_TYPE_RADIO_SELECTOR
+		)
+	);
+
+	if (
+		! ( imageInput instanceof HTMLInputElement ) ||
+		visualTypeRadios.length === 0
+	) {
+		return;
+	}
+
+	const defaultVisualType = hasStoredImage( imageInput )
+		? VISUAL_TYPE_IMAGE
+		: VISUAL_TYPE_COLOR;
+	visualTypeRadios.forEach( ( radio ) => {
+		radio.checked = radio.value === defaultVisualType;
+		radio.addEventListener( 'change', () => {
+			const selectedVisualType =
+				radio.value === VISUAL_TYPE_IMAGE
+					? VISUAL_TYPE_IMAGE
+					: VISUAL_TYPE_COLOR;
+
+			if ( radio.checked ) {
+				setVisualFieldVisibility( container, selectedVisualType );
+			}
+		} );
+	} );
+
+	setVisualFieldVisibility( container, defaultVisualType );
+};
+
+export const mountAllVisualTypeSwitchers = (
+	context: ParentNode = document
+) => {
+	const visualTypeSwitchers = context.querySelectorAll(
+		'.wc-admin-visual-attribute-type'
+	);
+
+	visualTypeSwitchers.forEach( ( visualTypeSwitcher ) => {
+		if ( visualTypeSwitcher instanceof HTMLElement ) {
+			mountVisualTypeSwitcher( visualTypeSwitcher );
+		}
+	} );
+};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/utils.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/utils.ts
new file mode 100644
index 00000000000..aff88dd7ee2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/visual-attribute-color-picker/utils.ts
@@ -0,0 +1,70 @@
+export const COLOR_INPUT_SELECTOR =
+	'input.wc-admin-visual-attribute-color-input';
+export const IMAGE_INPUT_SELECTOR =
+	'input.wc-admin-visual-attribute-image-input';
+
+type InputValueChangeHandler = ( nextValue: string ) => void;
+
+export const observeInputValueChanges = (
+	input: HTMLInputElement,
+	onValueChange: InputValueChangeHandler
+) => {
+	const inputPrototype = HTMLInputElement.prototype;
+	const valueDescriptor = Object.getOwnPropertyDescriptor(
+		inputPrototype,
+		'value'
+	);
+	let hasValueOverride = false;
+
+	if ( valueDescriptor?.get && valueDescriptor.set ) {
+		Object.defineProperty( input, 'value', {
+			...valueDescriptor,
+			configurable: true,
+			set( nextValue: string ) {
+				valueDescriptor.set?.call( this, nextValue );
+				onValueChange( nextValue );
+			},
+		} );
+		hasValueOverride = true;
+	}
+
+	return () => {
+		if ( hasValueOverride ) {
+			delete ( input as { value?: string } ).value;
+		}
+	};
+};
+
+export const getSiblingVisualInput = (
+	input: HTMLInputElement,
+	selector: string
+): HTMLInputElement | null => {
+	const container =
+		input.closest( 'form' ) ||
+		input.closest( '.wc-add-attribute-term-fields' ) ||
+		document;
+	const sibling = container.querySelector( selector );
+
+	return sibling instanceof HTMLInputElement ? sibling : null;
+};
+
+export const clearVisualInput = ( input: HTMLInputElement ) => {
+	if ( ! input.value ) {
+		return;
+	}
+
+	input.value = '';
+	input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
+	input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+};
+
+export const clearSiblingVisualInput = (
+	input: HTMLInputElement,
+	selector: string
+) => {
+	const sibling = getSiblingVisualInput( input, selector );
+
+	if ( sibling ) {
+		clearVisualInput( sibling );
+	}
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/visual-attribute-terms.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/visual-attribute-terms.ts
new file mode 100644
index 00000000000..69b5a5af83f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/visual-attribute-terms.ts
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import type { CSSProperties } from 'react';
+
+export type VisualAttributeTerm = {
+	type: 'color' | 'image' | 'none';
+	value: string;
+};
+
+const getEscapedUrl = ( url: string ) => url.split( "'" ).join( '%27' );
+
+export const isVisualAttributeTermEmpty = ( visual?: VisualAttributeTerm ) =>
+	! visual || visual.type === 'none' || ! visual.value;
+
+export const getVisualAttributeTermStyle = (
+	visual?: VisualAttributeTerm
+): CSSProperties | undefined => {
+	if ( ! visual || visual.type === 'none' || ! visual.value ) {
+		return undefined;
+	}
+
+	if ( visual.type === 'image' ) {
+		return {
+			backgroundImage: `url('${ getEscapedUrl( visual.value ) }')`,
+		};
+	}
+
+	return {
+		backgroundColor: visual.value,
+	};
+};
+
+export const getVisualAttributeTermStyleString = (
+	visual?: VisualAttributeTerm
+): string => {
+	if ( ! visual || visual.type === 'none' || ! visual.value ) {
+		return '';
+	}
+
+	if ( visual.type === 'image' ) {
+		return `background-image: url('${ getEscapedUrl( visual.value ) }');`;
+	}
+
+	return `background-color: ${ visual.value };`;
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
index 20917f21a71..6cd800f2740 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/constants.ts
@@ -4,6 +4,11 @@
 import type { TemplateArray } from '@wordpress/blocks';
 import { __ } from '@wordpress/i18n';

+/**
+ * Internal dependencies
+ */
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';
+
 export const ATTRIBUTE_ITEM_TEMPLATE: TemplateArray = [
 	[
 		'woocommerce/add-to-cart-with-options-variation-selector-attribute',
@@ -66,8 +71,8 @@ export const DEFAULT_ATTRIBUTES = [
 	},
 ] as const;

-export const EMPTY_TERM_COLORS: Record< string, string > = {
-	'-1': '#0000ff',
-	'-2': '#e10000',
-	'-3': '#009b00',
+export const EMPTY_TERM_VISUALS: Record< string, VisualAttributeTerm > = {
+	'-1': { type: 'color', value: '#0000ff' },
+	'-2': { type: 'color', value: '#e10000' },
+	'-3': { type: 'color', value: '#009b00' },
 };
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
index c9e6caaaf21..6c7d5bdbcae 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/attribute/edit.tsx
@@ -12,15 +12,18 @@ import {
 } from '@wordpress/block-editor';
 import type { BlockEditProps, BlockInstance } from '@wordpress/blocks';
 import { useSelect } from '@wordpress/data';
+import { useCollection } from '@woocommerce/base-context/hooks';
 import {
 	CustomDataProvider,
 	useCustomDataContext,
 	useProductDataContext,
 } from '@woocommerce/shared-context';
 import { isProductResponseItem } from '@woocommerce/entities';
-import type { ProductResponseAttributeItem } from '@woocommerce/types';
+import type {
+	AttributeTerm,
+	ProductResponseAttributeItem,
+} from '@woocommerce/types';
 import { __ } from '@wordpress/i18n';
-import { getSetting } from '@woocommerce/settings';
 import {
 	DisplayStyleSwitcher,
 	resetDisplayStyleBlock,
@@ -36,11 +39,12 @@ import {
 /**
  * Internal dependencies
  */
-import { DEFAULT_ATTRIBUTES, EMPTY_TERM_COLORS } from './constants';
+import { DEFAULT_ATTRIBUTES, EMPTY_TERM_VISUALS } from './constants';
 import type {
 	SelectableItem,
 	SelectableItemsContext,
 } from '../../../../types/type-defs/selectable-items';
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';

 const INNER_CHIPS = 'woocommerce/product-filter-chips';

@@ -80,16 +84,38 @@ type AttributeItemProps = {
 function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 	const { data: attribute } =
 		useCustomDataContext< ProductResponseAttributeItem >( 'attribute' );
+	const termIds = useMemo( () => {
+		return attribute?.terms
+			? attribute.terms
+					.map( ( term ) => term.id )
+					.filter( ( termId ) => termId > 0 )
+			: [];
+	}, [ attribute ] );
+	const { results: attributeTerms } = useCollection< AttributeTerm >( {
+		namespace: '/wc/store/v1',
+		resourceName: 'products/attributes/terms',
+		resourceValues: [ attribute?.id || 0 ],
+		shouldSelect: !! attribute?.id && termIds.length > 0,
+		query: { include: termIds, hide_empty: false },
+	} );
+	const visualByTermId = useMemo( () => {
+		return attributeTerms.reduce< Record< number, VisualAttributeTerm > >(
+			( accumulator, term ) => {
+				if ( term.__experimentalVisual ) {
+					accumulator[ term.id ] = term.__experimentalVisual;
+				}

-	const termColors = getSetting< Record< string, string > >(
-		'variationSelectorTermColors',
-		{} as Record< string, string >
-	);
+				return accumulator;
+			},
+			{}
+		);
+	}, [ attributeTerms ] );

 	const selectableContext = useMemo( () => {
 		let items: SelectableItem< {
 			label: string;
 			ariaLabel: string;
+			visual?: VisualAttributeTerm;
 		} >[] = [];
 		if (
 			attribute &&
@@ -97,18 +123,15 @@ function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 			attribute.terms.length > 0
 		) {
 			items = attribute.terms.map( ( term ) => {
-				let color: string | null = null;
-				if ( term.id in termColors ) {
-					color = termColors[ term.id ];
-				} else if ( term.id in EMPTY_TERM_COLORS ) {
-					color = EMPTY_TERM_COLORS[ term.id ];
-				}
+				const visual =
+					visualByTermId[ term.id ] || EMPTY_TERM_VISUALS[ term.id ];
+
 				return {
 					id: `${ attribute.taxonomy }-${ term.slug }`,
 					label: term.name,
 					value: term.slug,
 					ariaLabel: term.name,
-					...( color !== null ? { color } : {} ),
+					...( visual ? { visual } : {} ),
 				};
 			} );
 		}
@@ -121,8 +144,9 @@ function AttributeItem( { blocks, isSelected, onSelect }: AttributeItemProps ) {
 		} satisfies SelectableItemsContext< {
 			label: string;
 			ariaLabel: string;
+			visual?: VisualAttributeTerm;
 		} >;
-	}, [ attribute, termColors ] );
+	}, [ attribute, visualByTermId ] );

 	const blockPreviewProps = useBlockPreview( {
 		blocks,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index e187dfa1982..8dca45a7d54 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -25,13 +25,14 @@ import type {
 	Context as AddToCartWithOptionsStoreContext,
 } from '../frontend';
 import type { SelectableItem } from '../../../types/type-defs/selectable-items';
+import type { VisualAttributeTerm } from '../../../base/utils/visual-attribute-terms';

 type VariationOptionItem = {
 	id: string;
 	label: string;
 	value: string;
 	ariaLabel?: string;
-	color?: string;
+	visual?: VisualAttributeTerm;
 };

 type Context = AddToCartWithOptionsStoreContext & {
@@ -43,7 +44,7 @@ type Context = AddToCartWithOptionsStoreContext & {
 };

 type ToggleContext = Context & {
-	item?: SelectableItem;
+	item?: SelectableItem< { visual?: VisualAttributeTerm } >;
 };

 const universalLock =
@@ -182,12 +183,16 @@ export type VariableProductAddToCartWithOptionsStore =
 	AddToCartWithOptionsStore & {
 		state: {
 			selectedAttributes: SelectedAttributes[];
-			selectableItems: readonly SelectableItem[];
+			selectableItems: readonly SelectableItem< {
+				visual?: VisualAttributeTerm;
+			} >[];
 		};
 		actions: {
 			setAttribute: ( attribute: string, value: string ) => void;
 			removeAttribute: ( attribute: string ) => void;
-			toggle: ( item?: SelectableItem ) => void;
+			toggle: (
+				item?: SelectableItem< { visual?: VisualAttributeTerm } >
+			) => void;
 			autoselectAttributes: ( args: {
 				includedAttributes?: string[];
 				excludedAttributes?: string[];
@@ -212,7 +217,9 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 				}
 				return context.selectedAttributes || [];
 			},
-			get selectableItems(): readonly SelectableItem[] {
+			get selectableItems(): readonly SelectableItem< {
+				visual?: VisualAttributeTerm;
+			} >[] {
 				const context = getContext< Context >();
 				if ( ! context ) {
 					return [];
@@ -249,7 +256,9 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 						selected,
 						disabled,
 						hidden: hideInvalid && disabled,
-						...( row.color !== undefined && { color: row.color } ),
+						...( row.visual !== undefined && {
+							visual: row.visual,
+						} ),
 					};
 				} );
 			},
@@ -297,7 +306,11 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
 					selectedAttributes.splice( index, 1 );
 				}
 			},
-			toggle( itemArg?: SelectableItem | Event ) {
+			toggle(
+				itemArg?:
+					| SelectableItem< { visual?: VisualAttributeTerm } >
+					| Event
+			) {
 				const context = getContext< ToggleContext >();
 				const item =
 					itemArg && ! ( itemArg instanceof Event )
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
index 8ffe4a6cf7d..745d5d045e4 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/attribute-filter/edit.tsx
@@ -37,7 +37,6 @@ import { Notice } from '../../components/notice';
 import { sortFilterOptions } from '../../utils/sort-filter-options';

 const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
-const EMPTY_TERM_COLORS: Record< string, string > = {};

 const Edit = ( props: EditProps ) => {
 	const { attributes: blockAttributes } = props;
@@ -53,10 +52,6 @@ const Edit = ( props: EditProps ) => {
 	} = blockAttributes;

 	const attributeObject = getAttributeFromId( attributeId );
-	const termColors = getSetting< Record< string, string > >(
-		'productFilterTermColors',
-		EMPTY_TERM_COLORS
-	);

 	const [ attributeOptions, setAttributeOptions ] = useState<
 		FilterOptionItem[]
@@ -107,8 +102,8 @@ const Edit = ( props: EditProps ) => {
 					value: term.id.toString(),
 					selected: index === 0,
 					...( showCounts && { count: term.count } ),
-					...( term.id in termColors && {
-						color: termColors[ term.id ],
+					...( term.__experimentalVisual && {
+						visual: term.__experimentalVisual,
 					} ),
 				} ) );

@@ -127,7 +122,6 @@ const Edit = ( props: EditProps ) => {
 		isTermsLoading,
 		isFilterCountsLoading,
 		attributeObject,
-		termColors,
 	] );

 	const { children, ...innerBlocksProps } = useInnerBlocksProps(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
index 5f6415965e6..cbc06929129 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/edit.tsx
@@ -26,6 +26,10 @@ import './style.scss';
 import './editor.scss';
 import { EditProps } from './types';
 import { getColorClasses, getColorVars } from './utils';
+import {
+	getVisualAttributeTermStyle,
+	isVisualAttributeTermEmpty,
+} from '../../../../base/utils/visual-attribute-terms';

 const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
 	const {
@@ -122,23 +126,20 @@ const CheckboxListEdit = ( props: EditProps ): JSX.Element => {
 											/>
 										</span>
 										<span className="wc-block-product-filter-checkbox-list__text-wrapper">
-											{ item.color !== undefined && (
+											{ item.visual !== undefined && (
 												<span
 													className={ clsx(
 														'wc-block-product-filter-checkbox-list__color-swatch',
 														{
 															'is-empty':
-																! item.color,
+																isVisualAttributeTermEmpty(
+																	item.visual
+																),
 														}
 													) }
-													style={
-														item.color
-															? {
-																	backgroundColor:
-																		item.color,
-															  }
-															: undefined
-													}
+													style={ getVisualAttributeTermStyle(
+														item.visual
+													) }
 													aria-hidden="true"
 												/>
 											) }
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
index bf77a032050..8e702ae4363 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/frontend.ts
@@ -10,9 +10,14 @@ import type {
 	SelectableItem,
 	SelectableItemsParentStore,
 } from '../../../../types/type-defs/selectable-items';
+import {
+	getVisualAttributeTermStyleString,
+	isVisualAttributeTermEmpty,
+} from '../../../../base/utils/visual-attribute-terms';
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';

 type CheckboxListItem = SelectableItem< {
-	color?: string;
+	visual?: VisualAttributeTerm;
 	index?: number;
 } >;

@@ -39,9 +44,9 @@ type CheckboxListStore = {

 function getParentStore( storeNamespace?: string ) {
 	if ( ! storeNamespace ) return undefined;
-	return store< SelectableItemsParentStore< { color?: string } > >(
-		storeNamespace
-	);
+	return store<
+		SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
+	>( storeNamespace );
 }

 function normalizeDisplayLimit( displayLimit: number ): number {
@@ -86,12 +91,11 @@ const { state }: CheckboxListStore = store< CheckboxListStore >(
 			},
 			get colorSwatchStyle(): string {
 				const item = getCurrentItem();
-				if ( ! item?.color ) return '';
-				return `background-color: ${ item.color }`;
+				return getVisualAttributeTermStyleString( item?.visual );
 			},
 			get isColorSwatchEmpty(): boolean {
 				const item = getCurrentItem();
-				return ! item?.color;
+				return isVisualAttributeTermEmpty( item?.visual );
 			},
 		},
 		actions: {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
index aebd84a8656..d4e00cb47ab 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss
@@ -130,6 +130,10 @@ input[type="checkbox"].wc-block-product-filter-checkbox-list__input:checked {
 	flex-shrink: 0;
 	height: 1em;
 	margin-right: 0.25em;
+	background-size: cover;
+	background-position: center;
+	background-repeat: no-repeat;
+	overflow: hidden;
 	width: 1em;

 	&.is-empty {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
index 73d937dd493..8fa9485778c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/types.ts
@@ -7,6 +7,7 @@ import { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';

 export type Color = {
 	slug?: string;
@@ -30,7 +31,7 @@ export type BlockAttributes = {
 export type EditProps = BlockEditProps< BlockAttributes > & {
 	context: SelectableItemsBlockContext< {
 		count?: number;
-		color?: string;
+		visual?: VisualAttributeTerm;
 		depth?: number;
 	} >;
 	optionElementBorder: Color;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
index 167673a3050..f071dc82596 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx
@@ -25,6 +25,10 @@ import {
 import { EditProps } from './types';
 import './editor.scss';
 import { getColorClasses, getColorVars } from './utils';
+import {
+	getVisualAttributeTermStyle,
+	isVisualAttributeTermEmpty,
+} from '../../../../base/utils/visual-attribute-terms';

 const Edit = ( props: EditProps ): JSX.Element => {
 	const colorGradientSettings = useMultipleOriginColorsAndGradients();
@@ -57,7 +61,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
 	const { isLoading = false, items = [] } =
 		context?.[ 'woocommerce/selectableItems' ] ?? {};

-	const hasColorSwatches = items.some( ( item ) => 'color' in item );
+	const hasVisualSwatches = items.some( ( item ) => 'visual' in item );

 	const globalColors = getSetting< { background?: string; text?: string } >(
 		'globalStylesColors',
@@ -68,10 +72,11 @@ const Edit = ( props: EditProps ): JSX.Element => {
 	const blockProps = useBlockProps( {
 		className: clsx( 'wc-block-product-filter-chips', {
 			'is-loading': isLoading,
-			'is-style-swatch': hasColorSwatches,
+			'is-style-swatch': hasVisualSwatches,
 			...getColorClasses( attributes ),
 		} ),
 		style: {
+			...colorVars,
 			'--wc-product-filter-chips-text':
 				colorVars[ '--wc-product-filter-chips-text' ] ||
 				globalColors.text ||
@@ -127,17 +132,14 @@ const Edit = ( props: EditProps ): JSX.Element => {
 												'wc-block-product-filter-chips__swatch',
 												{
 													'wc-block-product-filter-chips__swatch--no-color':
-														! item.color,
+														isVisualAttributeTermEmpty(
+															item.visual
+														),
 												}
 											) }
-											style={
-												item.color
-													? {
-															backgroundColor:
-																item.color,
-													  }
-													: undefined
-											}
+											style={ getVisualAttributeTermStyle(
+												item.visual
+											) }
 											aria-hidden="true"
 										/>
 										<span className="wc-block-product-filter-chips__text">
@@ -168,7 +170,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
 					<ColorGradientSettingsDropdown
 						__experimentalIsRenderedInSidebar
 						settings={ [
-							...( ! hasColorSwatches
+							...( ! hasVisualSwatches
 								? [
 										{
 											label: __(
@@ -215,7 +217,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
 									} );
 								},
 							},
-							...( ! hasColorSwatches
+							...( ! hasVisualSwatches
 								? [
 										{
 											label: __(
@@ -290,7 +292,7 @@ const Edit = ( props: EditProps ): JSX.Element => {
 									} );
 								},
 							},
-							...( ! hasColorSwatches
+							...( ! hasVisualSwatches
 								? [
 										{
 											label: __(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
index f3522a7b051..309910ccfde 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts
@@ -10,10 +10,15 @@ import type {
 	SelectableItem,
 	SelectableItemsParentStore,
 } from '../../../../types/type-defs/selectable-items';
+import {
+	getVisualAttributeTermStyleString,
+	isVisualAttributeTermEmpty,
+} from '../../../../base/utils/visual-attribute-terms';
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';
 import { getClosestColor } from '../../utils/get-closest-color';

 type ChipsItem = SelectableItem< {
-	color?: string;
+	visual?: VisualAttributeTerm;
 	index?: number;
 } >;

@@ -44,9 +49,9 @@ type ChipsStore = {

 function getParentStore( storeNamespace?: string ) {
 	if ( ! storeNamespace ) return undefined;
-	return store< SelectableItemsParentStore< { color?: string } > >(
-		storeNamespace
-	);
+	return store<
+		SelectableItemsParentStore< { visual?: VisualAttributeTerm } >
+	>( storeNamespace );
 }

 function normalizeDisplayLimit( displayLimit: number ): number {
@@ -111,12 +116,11 @@ const { state }: ChipsStore = store< ChipsStore >(
 			},
 			get swatchHidden(): boolean {
 				const item = getCurrentItem();
-				return ! item?.color;
+				return isVisualAttributeTermEmpty( item?.visual );
 			},
 			get swatchStyle(): string {
 				const item = getCurrentItem();
-				if ( ! item?.color ) return '';
-				return `background-color: ${ item.color }`;
+				return getVisualAttributeTermStyleString( item?.visual );
 			},
 		},
 		actions: {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
index 27b9aa22f04..da9121432c0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss
@@ -138,9 +138,12 @@
 	}

 	:where(.wc-block-product-filter-chips__swatch) {
+		background-size: cover;
+		background-position: center;
+		background-repeat: no-repeat;
 		display: inline-block;
-		width: 1.25em;
-		height: 1.25em;
+		width: 1.8em;
+		height: 1.8em;
 		border-radius: 50%;
 		border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
 		flex-shrink: 0;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
index fbdcdc15cd5..a75452274a0 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts
@@ -7,6 +7,7 @@ import { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { SelectableItemsBlockContext } from '../../../../types/type-defs/selectable-items';
+import type { VisualAttributeTerm } from '../../../../base/utils/visual-attribute-terms';

 export type Color = {
 	slug?: string;
@@ -35,7 +36,7 @@ export type EditProps = BlockEditProps< BlockAttributes > & {
 	style: Record< string, string >;
 	context: SelectableItemsBlockContext< {
 		count?: number;
-		color?: string;
+		visual?: VisualAttributeTerm;
 	} >;
 	chipText: Color;
 	setChipText: ( value: string ) => void;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
index 2432179baa6..88a11dc90b5 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-filters/types.ts
@@ -7,6 +7,7 @@ import type { BlockEditProps } from '@wordpress/blocks';
  * Internal dependencies
  */
 import type { SelectableItem } from '../../types/type-defs/selectable-items';
+import type { VisualAttributeTerm } from '../../base/utils/visual-attribute-terms';

 // ----------------------------------------
 // Filter-specific item fields
@@ -18,7 +19,7 @@ export type FilterItemFields = {
 	depth?: number;
 	menuOrder?: number;
 	attributeQueryType?: 'and' | 'or';
-	color?: string;
+	visual?: VisualAttributeTerm;
 };

 export type FilterOptionItem = SelectableItem< FilterItemFields >;
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/attributes.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/attributes.ts
index 6cf314041b7..1e34df128ab 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/attributes.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/attributes.ts
@@ -1,3 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import type { VisualAttributeTerm } from '../../base/utils/visual-attribute-terms';
+
 export interface AttributeSetting {
 	attribute_id: string;
 	attribute_name: string;
@@ -38,6 +43,8 @@ export interface AttributeTerm {
 	name: string;
 	parent: number;
 	slug: string;
+	// eslint-disable-next-line @typescript-eslint/naming-convention
+	__experimentalVisual?: VisualAttributeTerm | null;
 }

 export interface AttributeMetadata {
diff --git a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
index c8b2febccba..305e2c3f4c0 100644
--- a/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
+++ b/plugins/woocommerce/client/blocks/docs/internal-developers/blocks/inner-block-protocols.md
@@ -128,14 +128,17 @@ export type FilterItemFields = {
 	depth?: number;
 	menuOrder?: number;
 	attributeQueryType?: 'and' | 'or';
-	color?: string;
+	visual?: {
+		type: 'color' | 'image' | 'none';
+		value: string;
+	};
 };
 ```

 | Consumer | Optional fields read | Fallback |
 | --- | --- | --- |
-| `checkbox-list` | `count`, `color`, `depth`, `filterType === 'rating'` | Text label, no count, no swatch, no indent |
-| `chips` | `count`, `color` | Text label, no count, no swatch |
+| `checkbox-list` | `count`, `visual`, `depth`, `filterType === 'rating'` | Text label, no count, no swatch, no indent |
+| `chips` | `count`, `visual` | Text label, no count, no swatch |

 Checkbox-list and chips mirror parent items into child `state.items`, adding local `index` for show-more and setting `hidden` when an item should be hidden. Their templates bind overflow visibility with `context.item.hidden`.

diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index d07a0faad77..b277a1e2554 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -1006,7 +1006,16 @@ $font-sf-pro-display: "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe U
 		width: 100%;
 	}

-	.wc-admin-visual-attribute-color-picker-trigger {
+	.wc-admin-visual-attribute-type {
+		margin: 1.5em 0 1em;
+
+		label {
+			margin-top: 0;
+		}
+	}
+
+	.wc-admin-visual-attribute-color-picker-trigger,
+	.wc-admin-visual-attribute-image-picker-trigger {
 		width: 100%;
 	}
 }
@@ -9730,7 +9739,13 @@ body.woocommerce-settings-payments-section_legacy {
 	}
 }

-.wc-admin-visual-attribute-color-picker-trigger {
+.wc-admin-visual-attribute-type fieldset {
+	display: flex;
+	gap: 1.5em;
+}
+
+.wc-admin-visual-attribute-color-picker-trigger,
+.wc-admin-visual-attribute-image-picker-trigger {
 	width: 95%;
 	display: flex;
 	align-items: center;
@@ -9745,7 +9760,8 @@ body.woocommerce-settings-payments-section_legacy {
 	line-height: 1.4;
 	cursor: pointer;

-	+ .wc-admin-visual-attribute-color-picker-clear {
+	+ .wc-admin-visual-attribute-color-picker-clear,
+	+ .wc-admin-visual-attribute-image-picker-clear {
 		display: block;
 		margin: 2px 0 5px;
 	}
@@ -9753,4 +9769,30 @@ body.woocommerce-settings-payments-section_legacy {
 	.wc-admin-color-swatch {
 		margin-right: 0;
 	}
+
+	.wc-admin-image-swatch {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 1em;
+		min-width: 24px;
+		height: 1em;
+		min-height: 24px;
+		flex-shrink: 0;
+		border: 1px solid #c3c4c7;
+		border-radius: 2px;
+		overflow: hidden;
+		background: #f0f0f1;
+
+		&.is-empty {
+			background: #f0f0f1;
+		}
+
+		img {
+			display: block;
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+		}
+	}
 }
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
index 567210ad918..afff111b241 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product.js
@@ -994,10 +994,20 @@ jQuery( function ( $ ) {

 			if (
 				currentAttributeTermCreationContext.isVisualAttribute &&
-				postedData &&
-				postedData.term_color
+				postedData
 			) {
-				data.term_color = postedData.term_color;
+				if ( postedData.wc_visual_attribute_type ) {
+					data.wc_visual_attribute_type =
+						postedData.wc_visual_attribute_type;
+				}
+
+				if ( postedData.term_color ) {
+					data.term_color = postedData.term_color;
+				}
+
+				if ( postedData.term_image ) {
+					data.term_image = postedData.term_image;
+				}
 			}

 			$.post(
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php b/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
index 33655b92c4a..c13542b746e 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-taxonomies.php
@@ -11,7 +11,6 @@ if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
 }

-use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
 use Automattic\WooCommerce\Internal\AssignDefaultCategory;

 /**
@@ -83,22 +82,6 @@ class WC_Admin_Taxonomies {
 			foreach ( $attribute_taxonomies as $attribute ) {
 				$taxonomy = 'pa_' . $attribute->attribute_name;
 				add_action( $taxonomy . '_pre_add_form', array( $this, 'product_attribute_description' ) );
-				add_action( $taxonomy . '_add_form_fields', array( $this, 'add_product_attribute_term_fields' ) );
-				add_action( $taxonomy . '_edit_form_fields', array( $this, 'edit_product_attribute_term_fields' ), 10, 1 );
-				add_filter(
-					"manage_edit-{$taxonomy}_columns",
-					function ( $columns ) use ( $taxonomy ) {
-						return $this->add_term_color_columns( $columns, $taxonomy );
-					}
-				);
-				add_filter(
-					"manage_{$taxonomy}_custom_column",
-					function ( $content, $column, $term_id ) use ( $taxonomy ) {
-						return $this->render_term_color_column( $content, $column, $term_id, $taxonomy );
-					},
-					10,
-					3
-				);
 			}
 		}

@@ -106,9 +89,7 @@ class WC_Admin_Taxonomies {
 		add_filter( 'wp_terms_checklist_args', array( $this, 'disable_checked_ontop' ) );

 		// Admin footer scripts for taxonomy screens.
-		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_visual_attribute_color_picker_script' ) );
 		add_action( 'admin_footer', array( $this, 'scripts_at_product_cat_screen_footer' ) );
-		add_action( 'admin_footer', array( $this, 'scripts_at_visual_attribute_screen_footer' ) );
 	}

 	/**
@@ -325,101 +306,6 @@ class WC_Admin_Taxonomies {
 		<?php
 	}

-	/**
-	 * Check if the current taxonomy should show visual swatch controls.
-	 *
-	 * @param string $taxonomy Taxonomy slug.
-	 * @return bool
-	 *
-	 * @internal
-	 */
-	private function is_visual_product_attribute_taxonomy( $taxonomy ) {
-		if ( ! taxonomy_is_product_attribute( $taxonomy ) ) {
-			return false;
-		}
-
-		if ( ! array_key_exists( 'wc-visual', wc_get_attribute_types() ) ) {
-			return false;
-		}
-
-		$attribute_id = wc_attribute_taxonomy_id_by_name( $taxonomy );
-		$attribute    = $attribute_id ? wc_get_attribute( $attribute_id ) : null;
-
-		return $attribute && 'wc-visual' === $attribute->type;
-	}
-
-	/**
-	 * Add custom fields for product attribute terms.
-	 *
-	 * @param string $taxonomy Taxonomy slug.
-	 * @return void
-	 *
-	 * @internal
-	 */
-	public function add_product_attribute_term_fields( $taxonomy ) {
-		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			return;
-		}
-		?>
-		<div class="form-field term-color-wrap">
-			<label for="term_color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
-			<input name="term_color" id="term_color" class="wc-admin-visual-attribute-color-input" type="text" value="" />
-		</div>
-		<?php
-	}
-
-	/**
-	 * Edit custom fields for product attribute terms.
-	 *
-	 * @param WP_Term $term Current term.
-	 * @return void
-	 *
-	 * @internal
-	 */
-	public function edit_product_attribute_term_fields( $term ) {
-		if ( ! $this->is_visual_product_attribute_taxonomy( $term->taxonomy ) ) {
-			return;
-		}
-
-		$color_value = get_term_meta( $term->term_id, 'color', true );
-		?>
-		<tr class="form-field term-color-wrap">
-			<th scope="row" valign="top"><label for="term_color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label></th>
-			<td>
-				<input name="term_color" id="term_color" class="wc-admin-visual-attribute-color-input" type="text" value="<?php echo esc_attr( $color_value ); ?>" />
-			</td>
-		</tr>
-		<?php
-	}
-
-	/**
-	 * Enqueue Gutenberg color picker script for visual attribute forms.
-	 *
-	 * @return void
-	 */
-	public function enqueue_visual_attribute_color_picker_script() {
-		$screen = get_current_screen();
-
-		if ( ! $screen ) {
-			return;
-		}
-
-		$is_product_editor_screen = 'product' === $screen->id;
-
-		if ( $is_product_editor_screen && array_key_exists( 'wc-visual', wc_get_attribute_types() ) ) {
-			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
-			return;
-		}
-
-		$is_attribute_term_screen = 0 === strpos( $screen->id, 'edit-pa_' );
-		$taxonomy                 = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-
-		if ( $is_attribute_term_screen && $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
-			return;
-		}
-	}
-
 	/**
 	 * Save category fields
 	 *
@@ -434,15 +320,6 @@ class WC_Admin_Taxonomies {
 		if ( isset( $_POST['product_cat_thumbnail_id'] ) && 'product_cat' === $taxonomy ) { // WPCS: CSRF ok, input var ok.
 			update_term_meta( $term_id, 'thumbnail_id', absint( $_POST['product_cat_thumbnail_id'] ) ); // WPCS: CSRF ok, input var ok.
 		}
-		if ( $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			$color_value = isset( $_POST['term_color'] ) ? sanitize_hex_color( wp_unslash( $_POST['term_color'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
-
-			if ( $color_value ) {
-				update_term_meta( $term_id, 'color', $color_value );
-			} elseif ( '' === $color_value ) {
-				delete_term_meta( $term_id, 'color' );
-			}
-		}
 	}

 	/**
@@ -488,75 +365,6 @@ class WC_Admin_Taxonomies {
 		);
 	}

-	/**
-	 * Add custom columns for product attribute terms.
-	 *
-	 * @param array  $columns  Existing columns.
-	 * @param string $taxonomy Taxonomy slug (bound when the filter is registered).
-	 * @return array
-	 *
-	 * @internal
-	 */
-	public function add_term_color_columns( $columns, $taxonomy ) {
-		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			return $columns;
-		}
-
-		$new_columns = array();
-		foreach ( $columns as $key => $label ) {
-			if ( 'slug' === $key ) {
-				$new_columns['color'] = __( 'Color value', 'woocommerce' );
-			}
-			$new_columns[ $key ] = $label;
-		}
-
-		if ( ! isset( $new_columns['color'] ) ) {
-			$new_columns['color'] = __( 'Color value', 'woocommerce' );
-		}
-
-		return $new_columns;
-	}
-
-	/**
-	 * Render color column for product attribute terms.
-	 *
-	 * @param string $content  Column output so far (often empty string).
-	 * @param string $column   Current column key.
-	 * @param int    $term_id  Term ID.
-	 * @param string $taxonomy Taxonomy slug (bound when the filter is registered).
-	 * @return string
-	 *
-	 * @internal
-	 */
-	public function render_term_color_column( $content, $column, $term_id, $taxonomy ) {
-		if ( 'color' !== $column ) {
-			return $content;
-		}
-
-		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			return $content;
-		}
-
-		$color_value = get_term_meta( $term_id, 'color', true );
-
-		if ( ! $color_value ) {
-			return '&ndash;';
-		}
-
-		$color_value = sanitize_hex_color( $color_value );
-
-		if ( ! $color_value ) {
-			return '&ndash;';
-		}
-
-		$swatch = sprintf(
-			'<span class="wc-admin-color-swatch" style="background-color:%s;" aria-hidden="true"></span>',
-			esc_attr( $color_value )
-		);
-
-		return $swatch . esc_html( strtoupper( $color_value ) );
-	}
-
 	/**
 	 * Thumbnail column added to category admin.
 	 *
@@ -699,41 +507,6 @@ class WC_Admin_Taxonomies {
 			)
 		);
 	}
-
-	/**
-	 * Admin footer scripts for visual attribute taxonomy screens.
-	 *
-	 * @return void
-	 *
-	 * @internal
-	 */
-	public function scripts_at_visual_attribute_screen_footer() {
-		$taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
-		if ( ! $this->is_visual_product_attribute_taxonomy( $taxonomy ) ) {
-			return;
-		}
-
-		$handle = 'wc-admin-visual-attribute';
-		wp_register_script( $handle, '', array(), WC_VERSION, array( 'in_footer' => true ) );
-		wp_enqueue_script( $handle );
-		wp_add_inline_script(
-			$handle,
-			"(function() {
-				'use strict';
-				const addFormColor = document.querySelector('.form-field.term-color-wrap');
-				const addFormSlug = document.querySelector('.form-field.term-slug-wrap');
-				if (addFormColor && addFormSlug) {
-					addFormSlug.parentNode.insertBefore(addFormColor, addFormSlug);
-				}
-
-				const editFormColor = document.querySelector('tr.form-field.term-color-wrap');
-				const editFormSlug = document.querySelector('tr.form-field.term-slug-wrap');
-				if (editFormColor && editFormSlug) {
-					editFormSlug.parentNode.insertBefore(editFormColor, editFormSlug);
-				}
-			})();"
-		);
-	}
 }

 $wc_admin_taxonomies = WC_Admin_Taxonomies::get_instance();
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
index 9534c2236e0..3a6ccd62c7f 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-attributes.php
@@ -7,6 +7,7 @@

 use Automattic\WooCommerce\Admin\Features\Features;
 use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -90,8 +91,7 @@ $product_attributes = $product_object->get_attributes( 'edit' );
 						<label for="wc-modal-add-attribute-term-input"><?php esc_html_e( 'Name', 'woocommerce' ); ?></label>
 						<input id="wc-modal-add-attribute-term-input" type="text" name="term" value="" />
 						<# if ( data.isVisualAttribute ) { #>
-							<label for="wc-modal-add-attribute-term-color"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
-							<input id="wc-modal-add-attribute-term-color" class="wc-admin-visual-attribute-color-input" type="text" name="term_color" value="" />
+							<?php VisualAttributeTermAdmin::render_add_attribute_term_modal_fields(); ?>
 						<# } #>
 					</form>
 				</article>
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 0212f03848c..a30cfe596cf 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
 use Automattic\WooCommerce\Internal\Orders\CouponsController;
 use Automattic\WooCommerce\Internal\Orders\TaxesController;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
@@ -740,29 +741,6 @@ class WC_AJAX {
 		wp_die();
 	}

-	/**
-	 * Check if a product attribute taxonomy supports visual term colors.
-	 *
-	 * @param string $taxonomy Taxonomy slug.
-	 * @return bool
-	 *
-	 * @internal
-	 */
-	private static function is_visual_product_attribute_taxonomy( $taxonomy ) {
-		if ( ! taxonomy_exists( $taxonomy ) || ! taxonomy_is_product_attribute( $taxonomy ) ) {
-			return false;
-		}
-
-		if ( ! array_key_exists( 'wc-visual', wc_get_attribute_types() ) ) {
-			return false;
-		}
-
-		$attribute_id = wc_attribute_taxonomy_id_by_name( $taxonomy );
-		$attribute    = $attribute_id ? wc_get_attribute( $attribute_id ) : null;
-
-		return $attribute && 'wc-visual' === $attribute->type;
-	}
-
 	/**
 	 * Add a new attribute via ajax function.
 	 *
@@ -786,13 +764,7 @@ class WC_AJAX {
 						)
 					);
 				} else {
-					if ( self::is_visual_product_attribute_taxonomy( $taxonomy ) && isset( $_POST['term_color'] ) ) {
-						$color_value = sanitize_hex_color( wp_unslash( $_POST['term_color'] ) );
-
-						if ( $color_value ) {
-							update_term_meta( $result['term_id'], 'color', $color_value );
-						}
-					}
+					VisualAttributeTermMeta::save_term_visual_from_request( (int) $result['term_id'], $taxonomy, $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing

 					$term = get_term_by( 'id', $result['term_id'], $taxonomy );
 					wp_send_json(
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 02c2cfb6258..655007b9c62 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
 use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as ProductDownloadDirectories;
@@ -400,6 +401,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\VariationGallery\Telemetry::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Email\EmailStyleSync::class )->register();
 		$container->get( EmailLogger::class )->register();
+		$container->get( VisualAttributeTermAdmin::class )->register();
 		$container->get( Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\Admin\Agentic\AgenticController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ProductFeed\ProductFeed::class )->register();
diff --git a/plugins/woocommerce/includes/wc-attribute-functions.php b/plugins/woocommerce/includes/wc-attribute-functions.php
index a4b4229a317..d76cd077dda 100644
--- a/plugins/woocommerce/includes/wc-attribute-functions.php
+++ b/plugins/woocommerce/includes/wc-attribute-functions.php
@@ -274,7 +274,7 @@ function wc_get_attribute_types() {
 	}

 	if ( $allow_visual_attribute_type ) {
-		$attribute_types['wc-visual'] = __( 'Color', 'woocommerce' );
+		$attribute_types['wc-visual'] = __( 'Color / image', 'woocommerce' );
 	}

 	return (array) apply_filters(
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 68315b40a13..d3fdb39ad53 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -53301,12 +53301,6 @@ parameters:
 			count: 1
 			path: src/Blocks/BlockTypes/ProductFilterAttribute.php

-		-
-			message: '#^Parameter \#2 \$array of function array_map expects array, array\<int, WP_Term\>\|WP_Error given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Blocks/BlockTypes/ProductFilterAttribute.php
-
 		-
 			message: '#^Parameter \$block of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\ProductFilterAttribute\:\:get_attribute_counts\(\) has invalid type Automattic\\WooCommerce\\Blocks\\BlockTypes\\WP_Block\.$#'
 			identifier: class.notFound
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
index d44bfefc528..26497a66d1f 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/VariationSelectorAttribute.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions;

 use Automattic\WooCommerce\Blocks\BlockTypes\AbstractBlock;
 use Automattic\WooCommerce\Blocks\BlockTypes\EnableBlockJsonAssetsTrait;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
 use WP_Block;

 /**
@@ -21,60 +22,6 @@ class VariationSelectorAttribute extends AbstractBlock {
 	 */
 	protected $block_name = 'add-to-cart-with-options-variation-selector-attribute';

-	/**
-	 * Extra data passed through from server to client for block.
-	 *
-	 * @param array $attributes Any attributes that currently are available from the block.
-	 * @return void
-	 */
-	protected function enqueue_data( array $attributes = array() ) {
-		parent::enqueue_data( $attributes );
-
-		if ( is_admin() ) {
-			$this->asset_data_registry->add( 'variationSelectorTermColors', $this->get_visual_attribute_term_colors() );
-		}
-	}
-
-	/**
-	 * Get color values for all wc-visual attribute terms.
-	 *
-	 * @param string|null $attribute_name Optional product attribute taxonomy name (e.g. `pa_color`). When omitted, colors for every wc-visual attribute are loaded.
-	 * @return array<int, string|null> Map of term ID to hex color.
-	 */
-	private function get_visual_attribute_term_colors( ?string $attribute_name = null ): array {
-		$colors     = array();
-		$attributes = wc_get_attribute_taxonomies();
-
-		foreach ( $attributes as $attribute ) {
-			if ( 'wc-visual' !== $attribute->attribute_type ) {
-				continue;
-			}
-			if ( $attribute_name && 'pa_' . $attribute->attribute_name !== $attribute_name ) {
-				continue;
-			}
-
-			$terms = get_terms(
-				array(
-					'taxonomy'   => 'pa_' . $attribute->attribute_name,
-					'hide_empty' => false,
-				)
-			);
-
-			if ( is_wp_error( $terms ) ) {
-				continue;
-			}
-
-			foreach ( $terms as $term ) {
-				$color = sanitize_hex_color( get_term_meta( $term->term_id, 'color', true ) );
-				if ( ! empty( $color ) ) {
-					$colors[ $term->term_id ] = $color;
-				}
-			}
-		}
-
-		return $colors;
-	}
-
 	/**
 	 * Render the block.
 	 *
@@ -308,9 +255,11 @@ class VariationSelectorAttribute extends AbstractBlock {
 	 * @return array
 	 */
 	private function build_variation_selectable_items( string $attribute_name, string $attribute_slug, array $attribute_terms, ?string $default_selected ): array {
-		$id_prefix   = sanitize_title( $attribute_slug );
-		$items       = array();
-		$term_colors = $this->get_visual_attribute_term_colors( $attribute_name );
+		$id_prefix    = sanitize_title( $attribute_slug );
+		$items        = array();
+		$term_visuals = VisualAttributeTermMeta::is_visual_attribute_taxonomy( $attribute_name )
+			? VisualAttributeTermMeta::get_term_visuals( wp_list_pluck( $attribute_terms, 'term_id' ) )
+			: array();

 		foreach ( $attribute_terms as $attribute_term ) {
 			if ( ! is_array( $attribute_term ) || ! isset( $attribute_term['value'], $attribute_term['label'] ) ) {
@@ -326,8 +275,8 @@ class VariationSelectorAttribute extends AbstractBlock {
 				'selected'  => $default_selected === $value,
 			);

-			if ( ! empty( $term_colors ) && isset( $attribute_term['term_id'], $term_colors[ $attribute_term['term_id'] ] ) ) {
-				$item['color'] = $term_colors[ $attribute_term['term_id'] ];
+			if ( isset( $attribute_term['term_id'], $term_visuals[ $attribute_term['term_id'] ] ) ) {
+				$item['visual'] = $term_visuals[ $attribute_term['term_id'] ];
 			}

 			$items[] = $item;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
index 4613b625bb9..8c1d36e7ee1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Blocks\BlockTypes;

 use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Utils as ProductCollectionUtils;
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
 use Automattic\WooCommerce\Internal\ProductFilters\FilterDataProvider;
 use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;

@@ -19,13 +20,6 @@ final class ProductFilterAttribute extends AbstractBlock {
 	 */
 	protected $block_name = 'product-filter-attribute';

-	/**
-	 * Cached map of term ID to color value for all wc-visual attribute terms.
-	 *
-	 * @var array<int, string>|null
-	 */
-	private $term_colors = null;
-
 	/**
 	 * Initialize this block type.
 	 *
@@ -52,50 +46,9 @@ final class ProductFilterAttribute extends AbstractBlock {

 		if ( is_admin() ) {
 			$this->asset_data_registry->add( 'defaultProductFilterAttribute', $this->get_default_product_attribute() );
-			$this->asset_data_registry->add( 'productFilterTermColors', $this->get_visual_attribute_term_colors() );
 		}
 	}

-	/**
-	 * Get color values for all wc-visual attribute terms.
-	 *
-	 * @return array<int, string> Map of term ID to hex color.
-	 */
-	private function get_visual_attribute_term_colors(): array {
-		if ( null !== $this->term_colors ) {
-			return $this->term_colors;
-		}
-
-		$colors     = array();
-		$attributes = wc_get_attribute_taxonomies();
-
-		foreach ( $attributes as $attribute ) {
-			if ( 'wc-visual' !== $attribute->attribute_type ) {
-				continue;
-			}
-
-			$terms = get_terms(
-				array(
-					'taxonomy'   => 'pa_' . $attribute->attribute_name,
-					'hide_empty' => false,
-				)
-			);
-
-			if ( is_wp_error( $terms ) ) {
-				continue;
-			}
-
-			foreach ( $terms as $term ) {
-				$color                    = sanitize_hex_color( get_term_meta( $term->term_id, 'color', true ) );
-				$colors[ $term->term_id ] = $color ? $color : '';
-			}
-		}
-
-		$this->term_colors = $colors;
-
-		return $this->term_colors;
-	}
-
 	/**
 	 * Delete the default attribute id transient when the attribute taxonomies are deleted.
 	 *
@@ -107,8 +60,6 @@ final class ProductFilterAttribute extends AbstractBlock {
 		}
 	}

-
-
 	/**
 	 * Prepare the active filter items.
 	 *
@@ -224,6 +175,10 @@ final class ProductFilterAttribute extends AbstractBlock {

 		$attribute_terms = get_terms( $args );

+		if ( is_wp_error( $attribute_terms ) ) {
+			$attribute_terms = array();
+		}
+
 		$filter_param_key = 'filter_' . str_replace( 'pa_', '', $product_attribute->slug );
 		$filter_params    = $block->context['filterParams'] ?? array();
 		$selected_terms   = array();
@@ -240,9 +195,16 @@ final class ProductFilterAttribute extends AbstractBlock {
 		);

 		if ( ! empty( $attribute_counts ) ) {
-			$show_counts       = $block_attributes['showCounts'] ?? false;
+			$show_counts         = $block_attributes['showCounts'] ?? false;
+			$is_visual_attribute = VisualAttributeTermMeta::is_visual_attribute_taxonomy( $product_attribute->slug );
+			$visual_values       = array();
+
+			if ( $is_visual_attribute ) {
+				$visual_values = VisualAttributeTermMeta::get_term_visuals( wp_list_pluck( $attribute_terms, 'term_id' ) );
+			}
+
 			$attribute_options = array_map(
-				function ( $term ) use ( $block_attributes, $attribute_counts, $selected_terms, $product_attribute, $show_counts ) {
+				function ( $term ) use ( $block_attributes, $attribute_counts, $selected_terms, $product_attribute, $show_counts, $is_visual_attribute, $visual_values ) {
 					$term          = (array) $term;
 					$term['count'] = $attribute_counts[ $term['term_id'] ] ?? 0;

@@ -261,9 +223,8 @@ final class ProductFilterAttribute extends AbstractBlock {
 						$item['count'] = $term['count'];
 					}

-					if ( 'wc-visual' === $product_attribute->type ) {
-						$colors        = $this->get_visual_attribute_term_colors();
-						$item['color'] = $colors[ $term['term_id'] ] ?? '';
+					if ( $is_visual_attribute ) {
+						$item['visual'] = $visual_values[ $term['term_id'] ] ?? VisualAttributeTermMeta::get_empty_visual();
 					}

 					return $item;
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
index d739edc81e2..bc221166f3c 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php
@@ -3,6 +3,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Blocks\BlockTypes;

+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
+
 /**
  * Product Filter: Checkbox List Block.
  */
@@ -83,9 +85,9 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 		$visible_items           = array_merge( $first_items, $overflow_selected_items );
 		$hidden_count            = count( $items ) - count( $visible_items );

-		$first_item  = reset( $items );
-		$show_counts = is_array( $first_item ) && array_key_exists( 'count', $first_item );
-		$has_colors  = is_array( $first_item ) && array_key_exists( 'color', $first_item );
+		$first_item          = reset( $items );
+		$show_counts         = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+		$has_visual_swatches = self::has_visual_swatches( $items );

 		ob_start();
 		?>
@@ -137,11 +139,15 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 											<?php echo $stars_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
 										</span>
 									<?php else : ?>
-										<?php if ( $has_colors ) : ?>
+										<?php if ( $has_visual_swatches ) : ?>
+											<?php
+											$swatch_style = $this->get_item_swatch_style( $item );
+											$has_visual   = '' !== $swatch_style;
+											?>
 											<span
-												class="wc-block-product-filter-checkbox-list__color-swatch<?php echo empty( $item['color'] ) ? ' is-empty' : ''; ?>"
-												<?php if ( ! empty( $item['color'] ) ) : ?>
-													style="background-color: <?php echo esc_attr( $item['color'] ); ?>"
+												class="wc-block-product-filter-checkbox-list__color-swatch<?php echo ! $has_visual ? ' is-empty' : ''; ?>"
+												<?php if ( $has_visual ) : ?>
+													style="<?php echo esc_attr( $swatch_style ); ?>"
 												<?php endif; ?>
 												aria-hidden="true"
 											></span>
@@ -194,7 +200,7 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 											<?php echo $stars_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
 										</span>
 									<?php else : ?>
-										<?php if ( $has_colors ) : ?>
+										<?php if ( $has_visual_swatches ) : ?>
 											<span
 												class="wc-block-product-filter-checkbox-list__color-swatch"
 												data-wp-class--is-empty="woocommerce/product-filter-checkbox-list::state.isColorSwatchEmpty"
@@ -238,6 +244,34 @@ final class ProductFilterCheckboxList extends AbstractBlock {
 		return ob_get_clean();
 	}

+	/**
+	 * Check whether any item has visual swatch data.
+	 *
+	 * @param array $items Selectable items.
+	 * @return bool
+	 */
+	private static function has_visual_swatches( array $items ): bool {
+		foreach ( $items as $item ) {
+			if ( is_array( $item ) && array_key_exists( 'visual', $item ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Build inline swatch style from item visual data.
+	 *
+	 * @param array $item Selectable item data.
+	 * @return string
+	 */
+	private function get_item_swatch_style( array $item ): string {
+		$visual = isset( $item['visual'] ) && is_array( $item['visual'] ) ? $item['visual'] : array();
+
+		return VisualAttributeTermMeta::get_swatch_style( $visual );
+	}
+
 	/**
 	 * Disable the style handle for this block type. We use block.json to load the style.
 	 *
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
index 694e1bef539..c45119fa9b9 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterChips.php
@@ -3,6 +3,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Blocks\BlockTypes;

+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
+
 /**
  * Product Filter: Chips Block.
  */
@@ -92,12 +94,12 @@ final class ProductFilterChips extends AbstractBlock {
 		$visible_items           = array_merge( $first_items, $overflow_selected_items );
 		$hidden_count            = count( $items ) - count( $visible_items );

-		$first_item         = reset( $items );
-		$show_counts        = is_array( $first_item ) && array_key_exists( 'count', $first_item );
-		$has_color_swatches = is_array( $first_item ) && array_key_exists( 'color', $first_item );
-		$button_role        = 'single' === $block_context['selectionMode'] ? 'radio' : 'checkbox';
+		$first_item          = reset( $items );
+		$show_counts         = is_array( $first_item ) && array_key_exists( 'count', $first_item );
+		$has_visual_swatches = self::has_visual_swatches( $items );
+		$button_role         = 'single' === $block_context['selectionMode'] ? 'radio' : 'checkbox';

-		if ( $has_color_swatches && is_string( $classes ) && ! str_contains( $classes, 'is-style-swatch' ) ) {
+		if ( $has_visual_swatches && is_string( $classes ) && ! str_contains( $classes, 'is-style-swatch' ) ) {
 			$classes                    .= ' is-style-swatch';
 			$wrapper_attributes['class'] = esc_attr( $classes );
 		}
@@ -121,7 +123,7 @@ final class ProductFilterChips extends AbstractBlock {
 							<?php if ( ! empty( $item['ariaLabel'] ) ) : ?>
 								aria-label="<?php echo esc_attr( $item['ariaLabel'] ); ?>"
 							<?php endif; ?>
-							<?php if ( $has_color_swatches ) : ?>
+							<?php if ( $has_visual_swatches ) : ?>
 								title="<?php echo esc_attr( $item['label'] ); ?>"
 							<?php endif; ?>
 							value="<?php echo esc_attr( $item['value'] ); ?>"
@@ -135,11 +137,15 @@ final class ProductFilterChips extends AbstractBlock {
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">
-								<?php if ( $has_color_swatches ) : ?>
+								<?php if ( $has_visual_swatches ) : ?>
+									<?php
+									$swatch_style = $this->get_item_swatch_style( $item );
+									$has_visual   = '' !== $swatch_style;
+									?>
 									<span
-										class="wc-block-product-filter-chips__swatch<?php echo empty( $item['color'] ) ? ' wc-block-product-filter-chips__swatch--no-color' : ''; ?>"
-										<?php if ( ! empty( $item['color'] ) ) : ?>
-											style="background-color: <?php echo esc_attr( $item['color'] ); ?>;"
+										class="wc-block-product-filter-chips__swatch<?php echo ! $has_visual ? ' wc-block-product-filter-chips__swatch--no-color' : ''; ?>"
+										<?php if ( $has_visual ) : ?>
+											style="<?php echo esc_attr( $swatch_style ); ?>"
 										<?php endif; ?>
 										aria-hidden="true"
 									></span>
@@ -165,7 +171,7 @@ final class ProductFilterChips extends AbstractBlock {
 							role="<?php echo esc_attr( $button_role ); ?>"
 							data-wp-bind--id="context.item.id"
 							data-wp-bind--aria-label="context.item.ariaLabel"
-							<?php if ( $has_color_swatches ) : ?>
+							<?php if ( $has_visual_swatches ) : ?>
 								data-wp-bind--title="context.item.label"
 							<?php endif; ?>
 							data-wp-bind--value="context.item.value"
@@ -175,7 +181,7 @@ final class ProductFilterChips extends AbstractBlock {
 							data-wp-on--click="actions.toggle"
 						>
 							<span class="wc-block-product-filter-chips__label">
-								<?php if ( $has_color_swatches ) : ?>
+								<?php if ( $has_visual_swatches ) : ?>
 									<span
 										class="wc-block-product-filter-chips__swatch"
 										data-wp-class--wc-block-product-filter-chips__swatch--no-color="woocommerce/product-filter-chips::state.swatchHidden"
@@ -214,4 +220,32 @@ final class ProductFilterChips extends AbstractBlock {
 		<?php
 		return ob_get_clean();
 	}
+
+	/**
+	 * Check whether any item has visual swatch data.
+	 *
+	 * @param array $items Selectable items.
+	 * @return bool
+	 */
+	private static function has_visual_swatches( array $items ): bool {
+		foreach ( $items as $item ) {
+			if ( is_array( $item ) && array_key_exists( 'visual', $item ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Build inline swatch style from item visual data.
+	 *
+	 * @param array $item Selectable item data.
+	 * @return string
+	 */
+	private function get_item_swatch_style( array $item ): string {
+		$visual = isset( $item['visual'] ) && is_array( $item['visual'] ) ? $item['visual'] : array();
+
+		return VisualAttributeTermMeta::get_swatch_style( $visual );
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermAdmin.php b/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermAdmin.php
new file mode 100644
index 00000000000..a5662a9878d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermAdmin.php
@@ -0,0 +1,403 @@
+<?php
+/**
+ * Visual attribute term admin fields.
+ *
+ * @package WooCommerce\Classes
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductAttributes;
+
+use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+/**
+ * Admin UI for wc-visual attribute term metadata.
+ *
+ * @internal
+ *
+ * @since 10.9.0
+ */
+class VisualAttributeTermAdmin implements RegisterHooksInterface {
+
+	/**
+	 * Register hooks.
+	 *
+	 * @return void
+	 */
+	public function register(): void {
+		if ( ! is_admin() ) {
+			return;
+		}
+		add_action( 'created_term', array( $this, 'save_product_attribute_term_fields' ), 10, 3 );
+		add_action( 'edit_term', array( $this, 'save_product_attribute_term_fields' ), 10, 3 );
+
+		foreach ( wc_get_attribute_taxonomies() as $attribute ) {
+			$taxonomy = 'pa_' . $attribute->attribute_name;
+
+			add_action( $taxonomy . '_add_form_fields', array( $this, 'add_product_attribute_term_fields' ) );
+			add_action( $taxonomy . '_edit_form_fields', array( $this, 'edit_product_attribute_term_fields' ), 10, 1 );
+			add_filter(
+				"manage_edit-{$taxonomy}_columns",
+				function ( $columns ) use ( $taxonomy ) {
+					return $this->add_term_visual_column( $columns, $taxonomy );
+				}
+			);
+			add_filter(
+				"manage_{$taxonomy}_custom_column",
+				function ( $content, $column, $term_id ) use ( $taxonomy ) {
+					return $this->render_term_visual_column( $content, $column, $term_id, $taxonomy );
+				},
+				10,
+				3
+			);
+		}
+
+		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_visual_attribute_script' ) );
+	}
+
+	/**
+	 * Add custom fields for product attribute terms.
+	 *
+	 * @internal
+	 *
+	 * @param string $taxonomy Taxonomy slug.
+	 * @return void
+	 */
+	public function add_product_attribute_term_fields( $taxonomy ): void {
+		if ( ! VisualAttributeTermMeta::is_visual_attribute_taxonomy( $taxonomy ) ) {
+			return;
+		}
+
+		self::render_div_visual_attribute_fields( 'term-' );
+	}
+
+	/**
+	 * Edit custom fields for product attribute terms.
+	 *
+	 * @internal
+	 *
+	 * @param \WP_Term $term Current term.
+	 * @return void
+	 */
+	public function edit_product_attribute_term_fields( $term ): void {
+		if ( ! VisualAttributeTermMeta::is_visual_attribute_taxonomy( $term->taxonomy ) ) {
+			return;
+		}
+
+		self::render_table_visual_attribute_fields( $term );
+	}
+
+	/**
+	 * Render visual fields for the add attribute term modal.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public static function render_add_attribute_term_modal_fields(): void {
+		self::render_div_visual_attribute_fields( 'wc-modal-add-attribute-term-' );
+	}
+
+	/**
+	 * Render visual attribute fields for add forms.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @return void
+	 */
+	private static function render_div_visual_attribute_fields( string $field_id_prefix ): void {
+		?>
+		<div class="form-field wc-admin-visual-attribute-type">
+			<label><?php esc_html_e( 'Swatch type', 'woocommerce' ); ?></label>
+			<?php self::render_visual_type_inputs( $field_id_prefix, VisualAttributeTermMeta::TYPE_COLOR ); ?>
+		</div>
+		<div class="form-field wc-admin-visual-attribute-color">
+			<?php self::render_color_input( $field_id_prefix, '' ); ?>
+		</div>
+		<div class="form-field wc-admin-visual-attribute-image">
+			<?php self::render_image_input( $field_id_prefix, 0 ); ?>
+		</div>
+		<?php
+	}
+
+	/**
+	 * Render visual attribute fields for edit forms.
+	 *
+	 * @param \WP_Term $term Current term.
+	 * @return void
+	 */
+	private static function render_table_visual_attribute_fields( \WP_Term $term ): void {
+		$color_value  = get_term_meta( $term->term_id, 'color', true );
+		$color_value  = is_string( $color_value ) ? $color_value : '';
+		$image_value  = absint( get_term_meta( $term->term_id, 'image', true ) );
+		$visual_type  = $image_value > 0 ? VisualAttributeTermMeta::TYPE_IMAGE : VisualAttributeTermMeta::TYPE_COLOR;
+		$field_prefix = 'term-';
+		?>
+		<tr class="form-field wc-admin-visual-attribute-type">
+			<th scope="row" valign="top">
+				<label><?php esc_html_e( 'Swatch type', 'woocommerce' ); ?></label>
+			</th>
+			<td><?php self::render_visual_type_inputs( $field_prefix, $visual_type ); ?></td>
+		</tr>
+		<tr class="form-field wc-admin-visual-attribute-color">
+			<th scope="row" valign="top">
+				<label for="<?php echo esc_attr( self::get_color_input_id( $field_prefix ) ); ?>"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
+			</th>
+			<td><?php self::render_color_input_control( $field_prefix, $color_value ); ?></td>
+		</tr>
+		<tr class="form-field wc-admin-visual-attribute-image">
+			<th scope="row" valign="top">
+				<label for="<?php echo esc_attr( self::get_image_input_id( $field_prefix ) ); ?>"><?php esc_html_e( 'Image value', 'woocommerce' ); ?></label>
+			</th>
+			<td><?php self::render_image_input_control( $field_prefix, $image_value ); ?></td>
+		</tr>
+		<?php
+	}
+
+	/**
+	 * Render visual type radio inputs.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @param string $selected_type Selected visual type.
+	 * @return void
+	 */
+	private static function render_visual_type_inputs( string $field_id_prefix, string $selected_type ): void {
+		$color_id = $field_id_prefix . 'visual-type-color';
+		$image_id = $field_id_prefix . 'visual-type-image';
+		?>
+		<fieldset>
+			<label for="<?php echo esc_attr( $color_id ); ?>">
+				<input
+					type="radio"
+					id="<?php echo esc_attr( $color_id ); ?>"
+					name="wc_visual_attribute_type"
+					value="<?php echo esc_attr( VisualAttributeTermMeta::TYPE_COLOR ); ?>"
+					<?php checked( VisualAttributeTermMeta::TYPE_COLOR, $selected_type ); ?>
+				/>
+				<?php esc_html_e( 'Color', 'woocommerce' ); ?>
+			</label>
+			<label for="<?php echo esc_attr( $image_id ); ?>">
+				<input
+					type="radio"
+					id="<?php echo esc_attr( $image_id ); ?>"
+					name="wc_visual_attribute_type"
+					value="<?php echo esc_attr( VisualAttributeTermMeta::TYPE_IMAGE ); ?>"
+					<?php checked( VisualAttributeTermMeta::TYPE_IMAGE, $selected_type ); ?>
+				/>
+				<?php esc_html_e( 'Image', 'woocommerce' ); ?>
+			</label>
+		</fieldset>
+		<?php
+	}
+
+	/**
+	 * Render the color input and label.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @param string $color_value Color value.
+	 * @return void
+	 */
+	private static function render_color_input( string $field_id_prefix, string $color_value ): void {
+		?>
+		<label for="<?php echo esc_attr( self::get_color_input_id( $field_id_prefix ) ); ?>"><?php esc_html_e( 'Color value', 'woocommerce' ); ?></label>
+		<?php self::render_color_input_control( $field_id_prefix, $color_value ); ?>
+		<?php
+	}
+
+	/**
+	 * Render the color input control.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @param string $color_value Color value.
+	 * @return void
+	 */
+	private static function render_color_input_control( string $field_id_prefix, string $color_value ): void {
+		?>
+		<input name="term_color" id="<?php echo esc_attr( self::get_color_input_id( $field_id_prefix ) ); ?>" class="wc-admin-visual-attribute-color-input" type="text" value="<?php echo esc_attr( $color_value ); ?>" />
+		<?php
+	}
+
+	/**
+	 * Render the image input and label.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @param int    $image_value Image attachment ID.
+	 * @return void
+	 */
+	private static function render_image_input( string $field_id_prefix, int $image_value ): void {
+		?>
+		<label for="<?php echo esc_attr( self::get_image_input_id( $field_id_prefix ) ); ?>"><?php esc_html_e( 'Image value', 'woocommerce' ); ?></label>
+		<?php self::render_image_input_control( $field_id_prefix, $image_value ); ?>
+		<?php
+	}
+
+	/**
+	 * Render the image input control.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @param int    $image_value Image attachment ID.
+	 * @return void
+	 */
+	private static function render_image_input_control( string $field_id_prefix, int $image_value ): void {
+		?>
+		<input name="term_image" id="<?php echo esc_attr( self::get_image_input_id( $field_id_prefix ) ); ?>" class="wc-admin-visual-attribute-image-input" type="hidden" value="<?php echo absint( $image_value ); ?>" />
+		<?php
+	}
+
+	/**
+	 * Get color input ID.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @return string
+	 */
+	private static function get_color_input_id( string $field_id_prefix ): string {
+		return $field_id_prefix . 'color';
+	}
+
+	/**
+	 * Get image input ID.
+	 *
+	 * @param string $field_id_prefix Field ID prefix.
+	 * @return string
+	 */
+	private static function get_image_input_id( string $field_id_prefix ): string {
+		return $field_id_prefix . 'image';
+	}
+
+	/**
+	 * Enqueue the visual attribute script.
+	 *
+	 * @internal
+	 *
+	 * @return void
+	 */
+	public function enqueue_visual_attribute_script(): void {
+		$screen = get_current_screen();
+
+		if ( ! $screen ) {
+			return;
+		}
+
+		$is_product_editor_screen = 'product' === $screen->id;
+
+		if ( $is_product_editor_screen && array_key_exists( 'wc-visual', wc_get_attribute_types() ) ) {
+			wp_enqueue_media();
+			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
+			return;
+		}
+
+		$is_attribute_term_screen = 0 === strpos( $screen->id, 'edit-pa_' );
+		$taxonomy                 = $this->get_current_taxonomy();
+
+		if ( $is_attribute_term_screen && VisualAttributeTermMeta::is_visual_attribute_taxonomy( $taxonomy ) ) {
+			wp_enqueue_media();
+			WCAdminAssets::register_script( 'wp-admin-scripts', 'visual-attribute-color-picker', true, array( 'wp-components' ) );
+		}
+	}
+
+	/**
+	 * Save product attribute term fields.
+	 *
+	 * @internal
+	 *
+	 * @param mixed  $term_id Term ID being saved.
+	 * @param mixed  $tt_id Term taxonomy ID.
+	 * @param string $taxonomy Taxonomy slug.
+	 * @return void
+	 */
+	public function save_product_attribute_term_fields( $term_id, $tt_id = '', $taxonomy = '' ): void {
+		if ( $this->is_ajax_add_attribute_request() ) {
+			return;
+		}
+
+		VisualAttributeTermMeta::save_term_visual_from_request( (int) $term_id, $taxonomy, $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
+	}
+
+	/**
+	 * Add visual column for product attribute terms.
+	 *
+	 * @internal
+	 *
+	 * @param array  $columns  Existing columns.
+	 * @param string $taxonomy Taxonomy slug.
+	 * @return array
+	 */
+	public function add_term_visual_column( $columns, $taxonomy ): array {
+		if ( ! VisualAttributeTermMeta::is_visual_attribute_taxonomy( $taxonomy ) ) {
+			return $columns;
+		}
+
+		$new_columns = array();
+		foreach ( $columns as $key => $label ) {
+			if ( 'slug' === $key ) {
+				$new_columns['visual'] = __( 'Visual', 'woocommerce' );
+			}
+			$new_columns[ $key ] = $label;
+		}
+
+		if ( ! isset( $new_columns['visual'] ) ) {
+			$new_columns['visual'] = __( 'Visual', 'woocommerce' );
+		}
+
+		return $new_columns;
+	}
+
+	/**
+	 * Render visual column for product attribute terms.
+	 *
+	 * @internal
+	 *
+	 * @param string $content  Column output so far.
+	 * @param string $column   Current column key.
+	 * @param int    $term_id  Term ID.
+	 * @param string $taxonomy Taxonomy slug.
+	 * @return string
+	 */
+	public function render_term_visual_column( $content, $column, $term_id, $taxonomy ): string {
+		if ( 'visual' !== $column || ! VisualAttributeTermMeta::is_visual_attribute_taxonomy( $taxonomy ) ) {
+			return $content;
+		}
+
+		$image_id = absint( get_term_meta( $term_id, 'image', true ) );
+
+		if ( $image_id && wp_attachment_is_image( $image_id ) ) {
+			$thumbnail = wp_get_attachment_image( $image_id, array( 32, 32 ) );
+
+			return $thumbnail ? $thumbnail : '&ndash;';
+		}
+
+		$color_value = sanitize_hex_color( get_term_meta( $term_id, 'color', true ) );
+
+		if ( ! $color_value ) {
+			return '&ndash;';
+		}
+
+		$swatch = sprintf(
+			'<span class="wc-admin-color-swatch" style="background-color:%s;" aria-hidden="true"></span>',
+			esc_attr( $color_value )
+		);
+
+		return $swatch . esc_html( strtoupper( $color_value ) );
+	}
+
+	/**
+	 * Check whether the current request is the add attribute AJAX action.
+	 *
+	 * @return bool
+	 */
+	private function is_ajax_add_attribute_request(): bool {
+		$action = isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+		return wp_doing_ajax() && 'woocommerce_add_new_attribute' === $action;
+	}
+
+	/**
+	 * Get current taxonomy from request.
+	 *
+	 * @return string
+	 */
+	private function get_current_taxonomy(): string {
+		return isset( $_GET['taxonomy'] ) ? sanitize_text_field( wp_unslash( $_GET['taxonomy'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermMeta.php b/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermMeta.php
new file mode 100644
index 00000000000..2cba7ed1602
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductAttributes/VisualAttributeTermMeta.php
@@ -0,0 +1,315 @@
+<?php
+/**
+ * Visual attribute term meta utilities.
+ *
+ * @package WooCommerce\Classes
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductAttributes;
+
+/**
+ * Utilities for wc-visual attribute term metadata.
+ *
+ * @internal
+ *
+ * @since 10.9.0
+ */
+class VisualAttributeTermMeta {
+
+	/**
+	 * Color visual type.
+	 */
+	public const TYPE_COLOR = 'color';
+
+	/**
+	 * Image visual type.
+	 */
+	public const TYPE_IMAGE = 'image';
+
+	/**
+	 * Empty visual type.
+	 */
+	public const TYPE_NONE = 'none';
+
+	/**
+	 * Get an empty visual term value.
+	 *
+	 * @return array{type: string, value: string}
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_empty_visual(): array {
+		return array(
+			'type'  => self::TYPE_NONE,
+			'value' => '',
+		);
+	}
+
+	/**
+	 * Get the normalized visual value for a term.
+	 *
+	 * @param int    $term_id Term ID.
+	 * @param string $image_size Image size for image visual URLs.
+	 * @return array{type: string, value: string}
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_term_visual( int $term_id, string $image_size = 'thumbnail' ): array {
+		return self::build_term_visual( $term_id, $image_size );
+	}
+
+	/**
+	 * Get normalized visual values for the given terms.
+	 *
+	 * @param array  $term_ids Term IDs.
+	 * @param string $image_size Image size for image visual URLs.
+	 * @return array<int, array{type: string, value: string}> Map of term ID to visual values.
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_term_visuals( array $term_ids, string $image_size = 'thumbnail' ): array {
+		$visuals  = array();
+		$term_ids = self::prime_term_visual_caches( $term_ids );
+
+		foreach ( $term_ids as $term_id ) {
+			$visuals[ $term_id ] = self::build_term_visual( $term_id, $image_size );
+		}
+
+		return $visuals;
+	}
+
+	/**
+	 * Prime caches needed to build visual values for terms.
+	 *
+	 * @param array $term_ids Term IDs.
+	 * @return array<int> Normalized term IDs.
+	 *
+	 * @since 10.9.0
+	 */
+	public static function prime_term_visual_caches( array $term_ids ): array {
+		$term_ids = array_values( array_unique( array_filter( array_map( 'absint', $term_ids ) ) ) );
+
+		if ( empty( $term_ids ) ) {
+			return array();
+		}
+
+		update_meta_cache( 'term', $term_ids );
+
+		$image_ids = array();
+		foreach ( $term_ids as $term_id ) {
+			$image_id = absint( get_term_meta( $term_id, 'image', true ) );
+
+			if ( $image_id ) {
+				$image_ids[] = $image_id;
+			}
+		}
+
+		$image_ids = array_values( array_unique( $image_ids ) );
+		if ( ! empty( $image_ids ) ) {
+			_prime_post_caches( $image_ids, false, true );
+		}
+
+		return $term_ids;
+	}
+
+	/**
+	 * Build a normalized visual value for a term.
+	 *
+	 * @param int    $term_id Term ID.
+	 * @param string $image_size Image size for image visual URLs.
+	 * @return array{type: string, value: string}
+	 */
+	private static function build_term_visual( int $term_id, string $image_size ): array {
+		$image_id = absint( get_term_meta( $term_id, 'image', true ) );
+
+		if ( $image_id && wp_attachment_is_image( $image_id ) ) {
+			$image_url = wp_get_attachment_image_url( $image_id, $image_size );
+
+			if ( $image_url ) {
+				return array(
+					'type'  => self::TYPE_IMAGE,
+					'value' => $image_url,
+				);
+			}
+		}
+
+		$color = sanitize_hex_color( get_term_meta( $term_id, 'color', true ) );
+
+		if ( $color ) {
+			return array(
+				'type'  => self::TYPE_COLOR,
+				'value' => $color,
+			);
+		}
+
+		return self::get_empty_visual();
+	}
+
+	/**
+	 * Check whether a taxonomy is a wc-visual product attribute taxonomy.
+	 *
+	 * @param string $taxonomy Taxonomy name.
+	 * @return bool
+	 *
+	 * @since 10.9.0
+	 */
+	public static function is_visual_attribute_taxonomy( string $taxonomy ): bool {
+		static $visual_attribute_taxonomies = array();
+		static $cache_prefix                = '';
+
+		$current_cache_prefix = \WC_Cache_Helper::get_cache_prefix( 'woocommerce-attributes' );
+		if ( $cache_prefix !== $current_cache_prefix ) {
+			$cache_prefix                = $current_cache_prefix;
+			$visual_attribute_taxonomies = array();
+
+			foreach ( wc_get_attribute_taxonomies() as $attribute ) {
+				if ( 'wc-visual' === $attribute->attribute_type ) {
+					$visual_attribute_taxonomies[ wc_attribute_taxonomy_name( $attribute->attribute_name ) ] = true;
+				}
+			}
+		}
+
+		return isset( $visual_attribute_taxonomies[ $taxonomy ] );
+	}
+
+	/**
+	 * Save visual attribute term meta from request data.
+	 *
+	 * @param int    $term_id Term ID.
+	 * @param string $taxonomy Taxonomy slug.
+	 * @param array  $request_data Request data.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public static function save_term_visual_from_request( int $term_id, string $taxonomy, array $request_data ): void {
+		if ( ! self::is_visual_attribute_taxonomy( $taxonomy ) || ! self::has_visual_request_data( $request_data ) ) {
+			return;
+		}
+
+		$visual_type = isset( $request_data['wc_visual_attribute_type'] ) ? self::sanitize_visual_type( $request_data['wc_visual_attribute_type'] ) : '';
+		$color_value = isset( $request_data['term_color'] ) ? sanitize_hex_color( self::sanitize_request_string( $request_data['term_color'] ) ) : '';
+		$image_id    = isset( $request_data['term_image'] ) ? absint( self::sanitize_request_string( $request_data['term_image'] ) ) : 0;
+
+		if ( '' === $visual_type ) {
+			$visual_type = $image_id ? self::TYPE_IMAGE : self::TYPE_COLOR;
+		}
+
+		self::save_term_visual_by_type( $term_id, $visual_type, $color_value ? $color_value : '', $image_id );
+	}
+
+	/**
+	 * Check whether request data contains visual fields.
+	 *
+	 * @param array $request_data Request data.
+	 * @return bool
+	 */
+	private static function has_visual_request_data( array $request_data ): bool {
+		return isset( $request_data['wc_visual_attribute_type'] ) || isset( $request_data['term_color'] ) || isset( $request_data['term_image'] );
+	}
+
+	/**
+	 * Sanitize a request value to a string.
+	 *
+	 * @param mixed $value Request value.
+	 * @return string
+	 */
+	private static function sanitize_request_string( $value ): string {
+		$value = wp_unslash( $value );
+
+		return is_scalar( $value ) ? (string) $value : '';
+	}
+
+	/**
+	 * Sanitize the visual type request value.
+	 *
+	 * @param mixed $type Visual type value.
+	 * @return string
+	 */
+	private static function sanitize_visual_type( $type ): string {
+		$type = self::sanitize_request_string( $type );
+
+		return in_array( $type, array( self::TYPE_COLOR, self::TYPE_IMAGE ), true ) ? $type : '';
+	}
+
+	/**
+	 * Save mutually exclusive visual attribute term meta.
+	 *
+	 * @param int    $term_id Term ID.
+	 * @param string $color Hex color value.
+	 * @param int    $image_id Attachment ID for the term image.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public static function save_term_visual( int $term_id, string $color = '', int $image_id = 0 ): void {
+		if ( $image_id && wp_attachment_is_image( $image_id ) ) {
+			update_term_meta( $term_id, 'image', absint( $image_id ) );
+			delete_term_meta( $term_id, 'color' );
+			return;
+		}
+
+		$sanitized_color = sanitize_hex_color( $color );
+		if ( $sanitized_color ) {
+			update_term_meta( $term_id, 'color', $sanitized_color );
+			delete_term_meta( $term_id, 'image' );
+			return;
+		}
+
+		delete_term_meta( $term_id, 'color' );
+		delete_term_meta( $term_id, 'image' );
+	}
+
+	/**
+	 * Save visual attribute term meta using the selected visual type.
+	 *
+	 * @param int    $term_id Term ID.
+	 * @param string $type Selected visual type.
+	 * @param string $color Hex color value.
+	 * @param int    $image_id Attachment ID for the term image.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	public static function save_term_visual_by_type( int $term_id, string $type, string $color = '', int $image_id = 0 ): void {
+		if ( self::TYPE_IMAGE === $type ) {
+			self::save_term_visual( $term_id, '', $image_id );
+			return;
+		}
+
+		self::save_term_visual( $term_id, $color, 0 );
+	}
+
+	/**
+	 * Build an inline swatch style from a normalized visual value.
+	 *
+	 * @param array{type?: string, value?: string} $visual Normalized visual value.
+	 * @return string
+	 *
+	 * @since 10.9.0
+	 */
+	public static function get_swatch_style( array $visual ): string {
+		$type  = isset( $visual['type'] ) ? (string) $visual['type'] : self::TYPE_NONE;
+		$value = isset( $visual['value'] ) ? (string) $visual['value'] : '';
+
+		if ( self::TYPE_IMAGE === $type ) {
+			$image = esc_url_raw( $value );
+
+			if ( $image ) {
+				return sprintf( "background-image:url('%s')", str_replace( "'", '%27', $image ) );
+			}
+		}
+
+		if ( self::TYPE_COLOR === $type ) {
+			$color = sanitize_hex_color( $value );
+
+			if ( $color ) {
+				return sprintf( 'background-color:%s', $color );
+			}
+		}
+
+		return '';
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
index 75628ec0407..bcdb2cf271b 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductAttributeTerms.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\StoreApi\Routes\V1;

 use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductAttributeTermSchema;

 /**
  * ProductAttributeTerms class.
@@ -14,6 +15,13 @@ class ProductAttributeTerms extends AbstractTermsRoute {
 	 */
 	const IDENTIFIER = 'product-attribute-terms';

+	/**
+	 * The routes schema.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = ProductAttributeTermSchema::IDENTIFIER;
+
 	/**
 	 * Get the path of this REST route.
 	 *
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 7aa16ca21a3..bac2e6cf1bf 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -51,6 +51,7 @@ class SchemaController {
 				Schemas\V1\OrderSchema::IDENTIFIER         => Schemas\V1\OrderSchema::class,
 				Schemas\V1\ProductSchema::IDENTIFIER       => Schemas\V1\ProductSchema::class,
 				Schemas\V1\ProductAttributeSchema::IDENTIFIER => Schemas\V1\ProductAttributeSchema::class,
+				Schemas\V1\ProductAttributeTermSchema::IDENTIFIER => Schemas\V1\ProductAttributeTermSchema::class,
 				Schemas\V1\ProductCategorySchema::IDENTIFIER => Schemas\V1\ProductCategorySchema::class,
 				Schemas\V1\ProductBrandSchema::IDENTIFIER  => Schemas\V1\ProductBrandSchema::class,
 				Schemas\V1\ProductCollectionDataSchema::IDENTIFIER => Schemas\V1\ProductCollectionDataSchema::class,
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php
new file mode 100644
index 00000000000..c85b1f52cce
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductAttributeTermSchema.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
+
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
+
+/**
+ * ProductAttributeTermSchema class.
+ */
+class ProductAttributeTermSchema extends TermSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'product-attribute-term';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'product-attribute-term';
+
+	/**
+	 * Visual data property name.
+	 *
+	 * @var string
+	 */
+	const VISUAL_PROPERTY_NAME = '__experimentalVisual';
+
+	/**
+	 * Term properties.
+	 *
+	 * @return array
+	 */
+	public function get_properties() {
+		$schema = parent::get_properties();
+
+		$schema[ self::VISUAL_PROPERTY_NAME ] = $this->get_visual_property_schema();
+
+		return $schema;
+	}
+
+	/**
+	 * Convert a product attribute term object into an object suitable for the response.
+	 *
+	 * @param \WP_Term $term Term object.
+	 * @return array
+	 */
+	public function get_item_response( $term ) {
+		$response = parent::get_item_response( $term );
+
+		$response[ self::VISUAL_PROPERTY_NAME ] = VisualAttributeTermMeta::is_visual_attribute_taxonomy( $term->taxonomy )
+			? VisualAttributeTermMeta::get_term_visual( (int) $term->term_id )
+			: null;
+
+		return $response;
+	}
+
+	/**
+	 * Get the visual data property schema.
+	 *
+	 * @return array
+	 */
+	private function get_visual_property_schema(): array {
+		return array(
+			'description' => __( 'Experimental visual swatch data for wc-visual attribute terms.', 'woocommerce' ),
+			'type'        => array( 'object', 'null' ),
+			'context'     => array( 'view', 'edit' ),
+			'readonly'    => true,
+			'properties'  => array(
+				'type'  => array(
+					'type' => 'string',
+					'enum' => array( VisualAttributeTermMeta::TYPE_COLOR, VisualAttributeTermMeta::TYPE_IMAGE, VisualAttributeTermMeta::TYPE_NONE ),
+				),
+				'value' => array(
+					'type' => 'string',
+				),
+			),
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index ddb16c9c167..d365b6e6ab1 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -135,14 +135,17 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 	 *
 	 * @testdox Should save term color only when adding visual attribute terms via AJAX.
 	 */
-	public function test_add_new_attribute_saves_color_only_for_visual_attributes(): void {
+	public function test_add_new_attribute_saves_color_and_image_only_for_visual_attributes(): void {
 		$original_theme      = wp_get_theme()->get_stylesheet();
 		$visual_attribute_id = null;
 		$text_attribute_id   = null;
 		$visual_taxonomy     = null;
 		$text_taxonomy       = null;
 		$visual_term_id      = 0;
+		$image_term_id       = 0;
+		$color_type_term_id  = 0;
 		$text_term_id        = 0;
+		$image_id            = 0;
 		$suffix              = (string) wp_rand( 1000, 9999 );

 		try {
@@ -174,16 +177,57 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {

 			$this->_setRole( 'administrator' );

-			$_POST['security']   = wp_create_nonce( 'add-attribute' );
-			$_POST['taxonomy']   = $visual_taxonomy;
-			$_POST['term']       = 'Cerulean ' . $suffix;
-			$_POST['term_color'] = '#336699';
+			$_POST['security']                 = wp_create_nonce( 'add-attribute' );
+			$_POST['taxonomy']                 = $visual_taxonomy;
+			$_POST['term']                     = 'Cerulean ' . $suffix;
+			$_POST['wc_visual_attribute_type'] = 'color';
+			$_POST['term_color']               = '#336699';

 			$visual_response = $this->do_ajax( 'woocommerce_add_new_attribute' );
 			$visual_term_id  = isset( $visual_response['term_id'] ) ? absint( $visual_response['term_id'] ) : 0;

 			$this->assertNotEmpty( $visual_term_id, 'The visual attribute term should be created.' );
 			$this->assertSame( '#336699', get_term_meta( $visual_term_id, 'color', true ), 'Visual attribute terms should store the posted color.' );
+			$this->assertSame( '', get_term_meta( $visual_term_id, 'image', true ), 'Visual attribute terms should not store image meta when only color is posted.' );
+
+			$image_id = wp_insert_attachment(
+				array(
+					'post_title'     => 'Visual AJAX term image',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			);
+			$this->assertIsInt( $image_id, 'The image should be created.' );
+
+			update_post_meta( $image_id, '_wp_attached_file', 'visual-ajax-term-image.jpg' );
+
+			$_POST['security']                 = wp_create_nonce( 'add-attribute' );
+			$_POST['taxonomy']                 = $visual_taxonomy;
+			$_POST['term']                     = 'Color selected ' . $suffix;
+			$_POST['wc_visual_attribute_type'] = 'color';
+			$_POST['term_color']               = '#445566';
+			$_POST['term_image']               = (string) $image_id;
+
+			$color_type_response = $this->do_ajax( 'woocommerce_add_new_attribute' );
+			$color_type_term_id  = isset( $color_type_response['term_id'] ) ? absint( $color_type_response['term_id'] ) : 0;
+
+			$this->assertNotEmpty( $color_type_term_id, 'The visual attribute term with selected color type should be created.' );
+			$this->assertSame( '#445566', get_term_meta( $color_type_term_id, 'color', true ), 'Selected color type should store color even when image is posted.' );
+			$this->assertSame( '', get_term_meta( $color_type_term_id, 'image', true ), 'Selected color type should ignore stale image values.' );
+
+			$_POST['security']                 = wp_create_nonce( 'add-attribute' );
+			$_POST['taxonomy']                 = $visual_taxonomy;
+			$_POST['term']                     = 'Pattern ' . $suffix;
+			$_POST['wc_visual_attribute_type'] = 'image';
+			$_POST['term_color']               = '#abcdef';
+			$_POST['term_image']               = (string) $image_id;
+
+			$image_response = $this->do_ajax( 'woocommerce_add_new_attribute' );
+			$image_term_id  = isset( $image_response['term_id'] ) ? absint( $image_response['term_id'] ) : 0;
+
+			$this->assertNotEmpty( $image_term_id, 'The visual attribute term with image should be created.' );
+			$this->assertSame( (string) $image_id, get_term_meta( $image_term_id, 'image', true ), 'Selected image type should store image even when color is posted.' );
+			$this->assertSame( '', get_term_meta( $image_term_id, 'color', true ), 'Selected image type should ignore stale color values.' );

 			$_POST['security']   = wp_create_nonce( 'add-attribute' );
 			$_POST['taxonomy']   = $text_taxonomy;
@@ -196,12 +240,24 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 			$this->assertNotEmpty( $text_term_id, 'The text attribute term should be created.' );
 			$this->assertSame( '', get_term_meta( $text_term_id, 'color', true ), 'Text attribute terms should ignore posted colors.' );
 		} finally {
-			unset( $_POST['security'], $_POST['taxonomy'], $_POST['term'], $_POST['term_color'] );
+			unset( $_POST['security'], $_POST['taxonomy'], $_POST['term'], $_POST['wc_visual_attribute_type'], $_POST['term_color'], $_POST['term_image'] );
+
+			if ( $image_id ) {
+				wp_delete_attachment( $image_id, true );
+			}

 			if ( $visual_term_id && taxonomy_exists( $visual_taxonomy ) ) {
 				wp_delete_term( $visual_term_id, $visual_taxonomy );
 			}

+			if ( $image_term_id && taxonomy_exists( $visual_taxonomy ) ) {
+				wp_delete_term( $image_term_id, $visual_taxonomy );
+			}
+
+			if ( $color_type_term_id && taxonomy_exists( $visual_taxonomy ) ) {
+				wp_delete_term( $color_type_term_id, $visual_taxonomy );
+			}
+
 			if ( $text_term_id && taxonomy_exists( $text_taxonomy ) ) {
 				wp_delete_term( $text_term_id, $text_taxonomy );
 			}
diff --git a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
index aaa3df29c91..d1150ed54cc 100644
--- a/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-attribute-functions-test.php
@@ -291,6 +291,11 @@ class WC_Attribute_Functions_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * Data provider for test_wc_get_attribute_taxonomy_slug().
+	 *
+	 * @return array
+	 */
 	public function get_attribute_names_and_slugs() {
 		return array(
 			array( 'Dash Me', 'dash-me' ),
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
index 51dafe8b1aa..d540e79d158 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/VariationSelectorAttribute.php
@@ -146,6 +146,22 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( '"disabledAttributesAction":"hide"', $markup, 'Legacy disabledAttributesAction should be applied to the interactivity context.' );
 	}

+	/**
+	 * Tests that non-visual attribute terms do not render swatch markup.
+	 *
+	 * @testdox VariationSelectorAttribute does not render swatches for non-visual attributes.
+	 * @covers \Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\VariationSelectorAttribute::build_variation_selectable_items
+	 */
+	public function test_does_not_render_swatches_for_non_visual_attribute(): void {
+		$variable_product = $this->create_variable_product_with_variations();
+		$inner_blocks     = $this->get_attribute_name_block_markup() . $this->get_chips_block_markup();
+
+		$markup = $this->render_variation_selector_attribute( $variable_product, $inner_blocks );
+
+		$this->assertStringNotContainsString( 'is-style-swatch', $markup, 'Chips wrapper should not use swatch style for non-visual attributes.' );
+		$this->assertStringNotContainsString( 'wc-block-product-filter-chips__swatch', $markup, 'Swatch elements should not be rendered for non-visual attributes.' );
+	}
+
 	/**
 	 * Tests that wc-visual attribute terms render chips with swatch markup and classes.
 	 *
@@ -178,7 +194,15 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {
 		delete_transient( 'wc_attribute_taxonomies' );
 		\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );

-		update_term_meta( $term_a->term_id, 'color', '#aa0000' );
+		$image_id = wp_insert_attachment(
+			array(
+				'post_title'     => 'Variation selector swatch image',
+				'post_type'      => 'attachment',
+				'post_mime_type' => 'image/jpeg',
+			)
+		);
+		update_post_meta( $image_id, '_wp_attached_file', 'variation-selector-swatch-image.jpg' );
+		update_term_meta( $term_a->term_id, 'image', $image_id );
 		update_term_meta( $term_b->term_id, 'color', '#0000aa' );

 		try {
@@ -217,11 +241,14 @@ class VariationSelectorAttribute extends WC_Unit_Test_Case {

 			$this->assertStringContainsString( 'is-style-swatch', $markup, 'Chips wrapper should use swatch style when colors are present.' );
 			$this->assertStringContainsString( 'wc-block-product-filter-chips__swatch', $markup, 'Swatch elements should be rendered for wc-visual terms.' );
-			$this->assertStringContainsString( 'background-color: #aa0000', $markup, 'First term swatch should use its term color meta.' );
-			$this->assertStringContainsString( 'background-color: #0000aa', $markup, 'Second term swatch should use its term color meta.' );
+			$this->assertStringContainsString( 'background-image:url(', $markup, 'First term swatch should use its term image meta.' );
+			$this->assertStringContainsString( 'background-color:#0000aa', $markup, 'Second term swatch should use its term color meta.' );
 		} finally {
-			delete_term_meta( $term_a->term_id, 'color' );
+			delete_term_meta( $term_a->term_id, 'image' );
 			delete_term_meta( $term_b->term_id, 'color' );
+			if ( $image_id ) {
+				wp_delete_attachment( $image_id, true );
+			}
 			$wpdb->update(
 				$wpdb->prefix . 'woocommerce_attribute_taxonomies',
 				array( 'attribute_type' => 'select' ),
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
index 9d891d55210..2e98bcee218 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductAttributeTerms.php
@@ -19,6 +19,7 @@ class ProductAttributeTerms extends ControllerTestCase {
 	 */
 	protected function setUp(): void {
 		parent::setUp();
+		global $wpdb;

 		$fixtures = new FixtureData();

@@ -26,6 +27,42 @@ class ProductAttributeTerms extends ControllerTestCase {
 			$fixtures->get_product_attribute( 'color', array( 'red', 'green', 'blue' ) ),
 			$fixtures->get_product_attribute( 'size', array( 'small', 'medium', 'large' ) ),
 		);
+
+		$wpdb->update(
+			$wpdb->prefix . 'woocommerce_attribute_taxonomies',
+			array( 'attribute_type' => 'wc-visual' ),
+			array( 'attribute_id' => $this->attributes[0]['attribute_id'] ),
+			array( '%s' ),
+			array( '%d' )
+		);
+
+		delete_transient( 'wc_attribute_taxonomies' );
+		\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
+	}
+
+	/**
+	 * Cleanup test product data. Called after every test.
+	 */
+	protected function tearDown(): void {
+		global $wpdb;
+
+		if ( isset( $this->attributes[0]['attribute_id'] ) ) {
+			$wpdb->update(
+				$wpdb->prefix . 'woocommerce_attribute_taxonomies',
+				array( 'attribute_type' => 'select' ),
+				array( 'attribute_id' => $this->attributes[0]['attribute_id'] ),
+				array( '%s' ),
+				array( '%d' )
+			);
+
+			$term = get_term_by( 'name', 'red', $this->attributes[0]['attribute_taxonomy'] );
+			delete_term_meta( $term->term_id, 'color' );
+
+			delete_transient( 'wc_attribute_taxonomies' );
+			\WC_Cache_Helper::invalidate_cache_group( 'woocommerce-attributes' );
+		}
+
+		parent::tearDown();
 	}

 	/**
@@ -83,9 +120,38 @@ class ProductAttributeTerms extends ControllerTestCase {
 		$controller = $routes->get( 'product-attribute-terms' );
 		$schema     = $controller->get_item_schema();
 		$response   = $controller->prepare_item_for_response( get_term_by( 'name', 'small', 'pa_size' ), new \WP_REST_Request() );
+		$data       = $response->get_data();
 		$validate   = new ValidateSchema( $schema );

-		$diff = $validate->get_diff_from_object( $response->get_data() );
-		$this->assertEmpty( $diff, print_r( $diff, true ) );
+		$this->assertArrayHasKey( '__experimentalVisual', $data );
+		$this->assertNull( $data['__experimentalVisual'] );
+
+		$diff = $validate->get_diff_from_object( $data );
+		$this->assertEmpty( $diff, print_r( $diff, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
+	}
+
+	/**
+	 * Test visual attribute terms include experimental visual data.
+	 */
+	public function test_prepare_item_includes_visual_data_for_wc_visual_attributes() {
+		$routes     = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+		$controller = $routes->get( 'product-attribute-terms' );
+		$schema     = $controller->get_item_schema();
+
+		$term = get_term_by( 'name', 'red', 'pa_color' );
+		update_term_meta( $term->term_id, 'color', '#00ff00' );
+
+		$response = $controller->prepare_item_for_response( $term, new \WP_REST_Request() );
+		$data     = $response->get_data();
+
+		$validate = new ValidateSchema( $schema );
+		$this->assertArrayHasKey( '__experimentalVisual', $data );
+		$this->assertSame( 'color', $data['__experimentalVisual']['type'] );
+		$this->assertSame( '#00ff00', $data['__experimentalVisual']['value'] );
+
+		$data['__experimentalVisual'] = (object) $data['__experimentalVisual'];
+
+		$diff = $validate->get_diff_from_object( $data );
+		$this->assertEmpty( $diff, print_r( $diff, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/VisualAttributeTermMetaTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/VisualAttributeTermMetaTest.php
new file mode 100644
index 00000000000..6da572c097d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductAttributes/VisualAttributeTermMetaTest.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Visual attribute term meta tests.
+ *
+ * @package WooCommerce\Tests\Internal\ProductAttributes
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductAttributes;
+
+use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermMeta;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the visual attribute term meta utility.
+ */
+class VisualAttributeTermMetaTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Should save visual attribute term color or image meta exclusively.
+	 */
+	public function test_saves_exclusive_values(): void {
+		$term_name = 'visual-meta-test-' . wp_rand();
+		$term      = wp_insert_term( $term_name, 'product_cat' );
+		$term_id   = is_array( $term ) ? (int) $term['term_id'] : 0;
+		$image_id  = 0;
+
+		$this->assertNotEmpty( $term_id, 'A test term should be created.' );
+
+		try {
+			$image_id = wp_insert_attachment(
+				array(
+					'post_title'     => 'Visual attribute term image',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			);
+			$this->assertIsInt( $image_id, 'The image should be created.' );
+
+			update_post_meta( $image_id, '_wp_attached_file', 'visual-attribute-term-image.jpg' );
+
+			update_term_meta( $term_id, 'image', $image_id );
+			update_term_meta( $term_id, 'color', '#112233' );
+
+			VisualAttributeTermMeta::save_term_visual( $term_id, '#aabbcc', 0 );
+
+			$this->assertSame( '#aabbcc', get_term_meta( $term_id, 'color', true ), 'Color meta should be saved.' );
+			$this->assertSame( '', get_term_meta( $term_id, 'image', true ), 'Image meta should be removed when color is saved.' );
+			$this->assertSame(
+				array(
+					'type'  => VisualAttributeTermMeta::TYPE_COLOR,
+					'value' => '#aabbcc',
+				),
+				VisualAttributeTermMeta::get_term_visual( $term_id ),
+				'Canonical visual meta should expose saved colors as a typed value.'
+			);
+
+			VisualAttributeTermMeta::save_term_visual( $term_id, '', $image_id );
+
+			$this->assertSame( (string) $image_id, get_term_meta( $term_id, 'image', true ), 'Image meta should be saved.' );
+			$this->assertSame( '', get_term_meta( $term_id, 'color', true ), 'Color meta should be removed when image is saved.' );
+			$saved_image_visual = VisualAttributeTermMeta::get_term_visual( $term_id );
+			$this->assertSame( VisualAttributeTermMeta::TYPE_IMAGE, $saved_image_visual['type'], 'Canonical visual meta should expose saved images as a typed value.' );
+			$this->assertStringContainsString( 'visual-attribute-term-image.jpg', $saved_image_visual['value'], 'Canonical image values should use the image URL.' );
+
+			VisualAttributeTermMeta::save_term_visual( $term_id, '', 999999 );
+
+			$this->assertSame( '', get_term_meta( $term_id, 'image', true ), 'Invalid image IDs should be ignored.' );
+			$this->assertSame( '', get_term_meta( $term_id, 'color', true ), 'Invalid image IDs should clear existing visual meta.' );
+			$this->assertSame( VisualAttributeTermMeta::get_empty_visual(), VisualAttributeTermMeta::get_term_visual( $term_id ), 'Canonical visual meta should expose invalid image IDs as empty values.' );
+
+			update_term_meta( $term_id, 'color', '#112233' );
+			update_term_meta( $term_id, 'image', $image_id );
+
+			VisualAttributeTermMeta::save_term_visual( $term_id, '#ff00aa', $image_id );
+
+			$this->assertSame( '', get_term_meta( $term_id, 'color', true ), 'Color meta should be removed when image takes precedence.' );
+			$this->assertSame( (string) $image_id, get_term_meta( $term_id, 'image', true ), 'Image should take precedence when both values are provided.' );
+
+			VisualAttributeTermMeta::save_term_visual_by_type( $term_id, VisualAttributeTermMeta::TYPE_COLOR, '#445566', $image_id );
+
+			$this->assertSame( '#445566', get_term_meta( $term_id, 'color', true ), 'Selected color type should save color even when image is posted.' );
+			$this->assertSame( '', get_term_meta( $term_id, 'image', true ), 'Selected color type should remove stale image meta.' );
+
+			VisualAttributeTermMeta::save_term_visual_by_type( $term_id, VisualAttributeTermMeta::TYPE_IMAGE, '#778899', $image_id );
+
+			$this->assertSame( '', get_term_meta( $term_id, 'color', true ), 'Selected image type should remove stale color meta.' );
+			$this->assertSame( (string) $image_id, get_term_meta( $term_id, 'image', true ), 'Selected image type should save image even when color is posted.' );
+		} finally {
+			if ( $term_id ) {
+				wp_delete_term( $term_id, 'product_cat' );
+			}
+
+			if ( $image_id ) {
+				wp_delete_attachment( $image_id, true );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Should get visual values for multiple terms.
+	 */
+	public function test_gets_visual_values_for_multiple_terms(): void {
+		$suffix        = (string) wp_rand();
+		$color_term    = wp_insert_term( 'visual-color-' . $suffix, 'product_cat' );
+		$image_term    = wp_insert_term( 'visual-image-' . $suffix, 'product_cat' );
+		$empty_term    = wp_insert_term( 'visual-empty-' . $suffix, 'product_cat' );
+		$color_term_id = is_array( $color_term ) ? (int) $color_term['term_id'] : 0;
+		$image_term_id = is_array( $image_term ) ? (int) $image_term['term_id'] : 0;
+		$empty_term_id = is_array( $empty_term ) ? (int) $empty_term['term_id'] : 0;
+		$image_id      = 0;
+
+		$this->assertNotEmpty( $color_term_id, 'A color term should be created.' );
+		$this->assertNotEmpty( $image_term_id, 'An image term should be created.' );
+		$this->assertNotEmpty( $empty_term_id, 'An empty term should be created.' );
+
+		try {
+			$image_id = wp_insert_attachment(
+				array(
+					'post_title'     => 'Visual attribute term batch image',
+					'post_type'      => 'attachment',
+					'post_mime_type' => 'image/jpeg',
+				)
+			);
+			$this->assertIsInt( $image_id, 'The image should be created.' );
+
+			update_post_meta( $image_id, '_wp_attached_file', 'visual-attribute-term-batch-image.jpg' );
+			update_term_meta( $color_term_id, 'color', '#123456' );
+			update_term_meta( $image_term_id, 'image', $image_id );
+
+			$visuals = VisualAttributeTermMeta::get_term_visuals( array( $color_term_id, $image_term_id, $empty_term_id, $color_term_id, 0 ) );
+
+			$this->assertCount( 3, $visuals, 'Duplicate and empty term IDs should be ignored.' );
+			$this->assertSame(
+				array(
+					'type'  => VisualAttributeTermMeta::TYPE_COLOR,
+					'value' => '#123456',
+				),
+				$visuals[ $color_term_id ],
+				'Color terms should return a color visual value.'
+			);
+			$this->assertSame( VisualAttributeTermMeta::TYPE_IMAGE, $visuals[ $image_term_id ]['type'], 'Image terms should return an image visual value.' );
+			$this->assertStringContainsString( 'visual-attribute-term-batch-image.jpg', $visuals[ $image_term_id ]['value'], 'Image terms should return the image URL.' );
+			$this->assertSame( VisualAttributeTermMeta::get_empty_visual(), $visuals[ $empty_term_id ], 'Terms without visual meta should return an empty visual value.' );
+		} finally {
+			foreach ( array( $color_term_id, $image_term_id, $empty_term_id ) as $term_id ) {
+				if ( $term_id ) {
+					wp_delete_term( $term_id, 'product_cat' );
+				}
+			}
+
+			if ( $image_id ) {
+				wp_delete_attachment( $image_id, true );
+			}
+		}
+	}
+}