Commit 4318636ecf6 for woocommerce

commit 4318636ecf60f90f9b81bcd825a427ac46e90764
Author: James Kemp <me@jckemp.com>
Date:   Fri May 22 11:22:51 2026 +0100

    Add read-only attribute tables to variation refresh (#65255)

    * Add shared product attribute row helpers

    * Add read-only attribute tables to the variations view

    * Mount the read-only attributes table in the attributes tab

    * Match variation attribute separators to DataViews rows

    * Point global attribute links to the terms screen

    * Refactor variation attributes handling: remove old implementation and introduce new structure

    * improve comment

    * rename php class

    * improve message

    * fix pagination

    * refactor: update product entity types and improve attribute handling

    * add changelog

    ---------

    Co-authored-by: Luigi Teschio <gigitux@gmail.com>

diff --git a/packages/js/experimental-products-app/src/fields/types.ts b/packages/js/experimental-products-app/src/fields/types.ts
index 8e3388a0874..13dd0ed1ee9 100644
--- a/packages/js/experimental-products-app/src/fields/types.ts
+++ b/packages/js/experimental-products-app/src/fields/types.ts
@@ -6,7 +6,28 @@ import type {
 	ProductStatus as WooProductStatus,
 } from '@woocommerce/data';

-export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+export type ProductEntityAttribute = {
+	id: number;
+	name: string;
+	slug: string;
+	position: number;
+	visible: boolean;
+	variation: boolean;
+	options: string[];
+};
+
+export type ProductEntityDefaultAttribute = {
+	id: number;
+	name: string;
+	option: string;
+};
+
+export type ProductEntityRecord = Omit<
+	Product,
+	'attributes' | 'categories' | 'default_attributes' | 'tags'
+> & {
+	attributes: ProductEntityAttribute[];
+	default_attributes: ProductEntityDefaultAttribute[];
 	cost_of_goods_sold?: {
 		values?: Array< {
 			defined_value?: number | string | null;
diff --git a/packages/js/experimental-products-app/src/index.ts b/packages/js/experimental-products-app/src/index.ts
index bfe124e06b3..56858c6c385 100644
--- a/packages/js/experimental-products-app/src/index.ts
+++ b/packages/js/experimental-products-app/src/index.ts
@@ -1,3 +1,6 @@
 export * from './products';
 export { ProductsApp } from './app';
-export { initializeVariationView } from './variation-view-app';
+export {
+	initializeProductAttributesView,
+	initializeVariationView,
+} from './variation-view-app';
diff --git a/packages/js/experimental-products-app/src/variation-view-app.tsx b/packages/js/experimental-products-app/src/variation-view-app.tsx
index f6926695f6e..c2363c9418f 100644
--- a/packages/js/experimental-products-app/src/variation-view-app.tsx
+++ b/packages/js/experimental-products-app/src/variation-view-app.tsx
@@ -22,15 +22,20 @@ const VariationView = lazy( () =>
 	} ) )
 );

-/**
- * Initializes the classic product editor variation view.
- *
- * @param {string} containerId DOM element ID.
- * @param {number} productId   Parent product ID.
- */
-export function initializeVariationView(
+const ProductAttributes = lazy( () =>
+	import(
+		/* webpackChunkName: "experimental-products-app-variation-view-main" */
+		'./variation-view'
+	).then( ( module ) => ( {
+		default: module.ProductAttributes,
+	} ) )
+);
+
+// The variations redesign mounts into separate PHP-provided metabox roots,
+// but each root needs the same app providers.
+function renderWithProviders(
 	containerId: string,
-	productId: number
+	children: JSX.Element
 ): void {
 	const target = document.getElementById( containerId );

@@ -43,11 +48,43 @@ export function initializeVariationView(
 		<StrictMode>
 			<Suspense fallback={ null }>
 				<RouterProvider>
-					<ThemeProvider>
-						<VariationView productId={ productId } />
-					</ThemeProvider>
+					<ThemeProvider>{ children }</ThemeProvider>
 				</RouterProvider>
 			</Suspense>
 		</StrictMode>
 	);
 }
+
+/**
+ * Initializes the classic product editor variation view.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId   Parent product ID.
+ */
+export function initializeVariationView(
+	containerId: string,
+	productId: number
+): void {
+	renderWithProviders(
+		containerId,
+		<VariationView productId={ productId } />
+	);
+}
+
+/**
+ * Initializes the product data attributes panel for the variations redesign.
+ * This is mounted separately from the variations view because the attributes
+ * panel has its own metabox DOM root.
+ *
+ * @param {string} containerId DOM element ID.
+ * @param {number} productId   Parent product ID.
+ */
+export function initializeProductAttributesView(
+	containerId: string,
+	productId: number
+): void {
+	renderWithProviders(
+		containerId,
+		<ProductAttributes productId={ productId } />
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/index.tsx b/packages/js/experimental-products-app/src/variation-view/index.tsx
index 5927f307caf..580c0d8f7de 100644
--- a/packages/js/experimental-products-app/src/variation-view/index.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -2,8 +2,10 @@
  * External dependencies
  */
 import { DataViews, type Action, type View } from '@wordpress/dataviews';
-import { Button, Stack } from '@wordpress/ui';
+import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
+import { Stack } from '@wordpress/ui';
 import { __ } from '@wordpress/i18n';
+import { moreVertical } from '@wordpress/icons';
 import { useMemo, useState, useCallback, useEffect } from '@wordpress/element';
 import { useSelect } from '@wordpress/data';
 import { store as coreStore } from '@wordpress/core-data';
@@ -21,11 +23,14 @@ import ProductEdit from '../product-edit';
 import { getProductWithUpdatedVariation } from '../product-edit/utils';
 import type { ProductEntityRecord } from '../fields/types';
 import { unlock } from '../lock-unlock';
+import { VariationAttributes } from './variation-attributes';
 import {
 	getProductListNavigationPath,
 	getSelectionFromPostId,
 } from '../product-list/utils';

+export { ProductAttributes } from './variation-attributes';
+
 const EMPTY_ARRAY: VariationEntityRecord[] = [];
 const EMPTY_PRODUCT_RECORDS: ProductEntityRecord[] = [];
 const { useHistory, useLocation } = unlock( routerPrivateApis );
@@ -34,6 +39,11 @@ type VariationViewProps = {
 	productId: number;
 };

+type VariationMoreActionsProps = {
+	disabled: boolean;
+	onEditSelected: () => void;
+};
+
 function variationMatchesSearch(
 	variation: VariationEntityRecord,
 	search: string
@@ -77,6 +87,46 @@ function sortVariations( variations: VariationEntityRecord[], view: View ) {
 	} );
 }

+function VariationMoreActions( {
+	disabled,
+	onEditSelected,
+}: VariationMoreActionsProps ) {
+	return (
+		<DropdownMenu
+			icon={ moreVertical }
+			label={ __( 'More actions', 'woocommerce' ) }
+			className="woocommerce-variation-view__more-actions"
+			popoverProps={ {
+				placement: 'bottom-end',
+			} }
+			toggleProps={ {
+				size: 'compact',
+			} }
+		>
+			{ ( { onClose } ) => (
+				<MenuGroup>
+					<MenuItem
+						disabled={ disabled }
+						onClick={ () => {
+							onEditSelected();
+							onClose();
+						} }
+					>
+						{ __( 'Edit', 'woocommerce' ) }
+					</MenuItem>
+					<MenuItem
+						disabled={ disabled }
+						isDestructive
+						onClick={ onClose }
+					>
+						{ __( 'Delete variation', 'woocommerce' ) }
+					</MenuItem>
+				</MenuGroup>
+			) }
+		</DropdownMenu>
+	);
+}
+
 export function VariationView( { productId }: VariationViewProps ) {
 	const { navigate } = useHistory();
 	const location = useLocation();
@@ -122,7 +172,7 @@ export function VariationView( { productId }: VariationViewProps ) {
 					: undefined,
 			};
 		},
-		[ productId, query ]
+		[ query ]
 	);

 	const allVariations = useMemo< VariationEntityRecord[] >(
@@ -240,6 +290,10 @@ export function VariationView( { productId }: VariationViewProps ) {

 	return (
 		<div className="woocommerce-variation-view">
+			<VariationAttributes productId={ productId } />
+			<h3 className="woocommerce-variation-view__title">
+				{ __( 'Variations', 'woocommerce' ) }
+			</h3>
 			<DataViews
 				data={ variations }
 				fields={ variationFields }
@@ -260,23 +314,36 @@ export function VariationView( { productId }: VariationViewProps ) {
 					direction="row"
 					align="center"
 					justify="space-between"
+					gap="sm"
 					className="woocommerce-variation-view__toolbar"
 				>
-					<DataViews.Search
-						label={ __( 'Search variations', 'woocommerce' ) }
-					/>
-					<Stack direction="row" gap="xs">
+					<Stack
+						direction="row"
+						align="center"
+						gap="xs"
+						className="woocommerce-variation-view__toolbar-search"
+					>
+						<DataViews.Search
+							label={ __( 'Search variations', 'woocommerce' ) }
+						/>
+						<DataViews.FiltersToggle />
+					</Stack>
+					<Stack
+						direction="row"
+						align="center"
+						gap="xs"
+						className="woocommerce-variation-view__toolbar-actions"
+					>
 						<DataViews.ViewConfig />
-						<Button
+						<VariationMoreActions
 							disabled={ selection.length === 0 }
-							onClick={ () =>
+							onEditSelected={ () =>
 								handleEditSelectedVariations( selection )
 							}
-						>
-							{ __( 'Edit options', 'woocommerce' ) }
-						</Button>
+						/>
 					</Stack>
 				</Stack>
+				<DataViews.FiltersToggled className="woocommerce-variation-view__filters" />
 				<DataViews.Layout />
 				<DataViews.Footer />
 			</DataViews>
diff --git a/packages/js/experimental-products-app/src/variation-view/style.scss b/packages/js/experimental-products-app/src/variation-view/style.scss
index 8eac9afc249..0aee17eeded 100644
--- a/packages/js/experimental-products-app/src/variation-view/style.scss
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -1,32 +1,146 @@
+@use "@wordpress/base-styles/colors";
+
 // overflow: hidden on .panel-wrap creates a scroll container that prevents
 // position: sticky on .dataviews-footer from working relative to the viewport.
 // overflow: clip preserves the float-clearfix BFC without creating a scroll
 // container, so the footer can stick to the browser bottom.
-#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root) {
+#woocommerce-product-data .panel-wrap:has(#woocommerce-variations-classic-root),
+#woocommerce-product-data .panel-wrap:has(#woocommerce-product-attributes-classic-root) {
 	overflow: clip;
 }

+.woocommerce-variation-attributes {
+	&--has-separator {
+		border-bottom: 1px solid colors.$gray-100;
+	}
+
+	&__notice.components-notice {
+		margin: 32px 16px 16px;
+		border-left-color: var(--wpds-color-stroke-surface-info-strong);
+		background: linear-gradient(
+			90deg,
+			var(--wpds-color-bg-surface-info) 0%,
+			var(--wpds-color-bg-surface-info-weak) 100%
+		);
+		color: var(--wpds-color-fg-content-neutral);
+
+		a {
+			color: var(--wpds-color-fg-interactive-brand);
+		}
+
+		.components-notice__dismiss {
+			color: var(--wpds-color-fg-content-neutral-weak);
+		}
+	}
+
+	&__header {
+		padding: 16px 24px;
+	}
+
+	&__title {
+		margin: 0;
+		color: var(--wpds-color-fg-content-neutral);
+		font-size: 15px;
+		font-weight: 600;
+		line-height: 20px;
+	}
+
+	&__title-group {
+		min-width: 0;
+	}
+
+	&__help {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		color: var(--wpds-color-fg-content-neutral-weak);
+		cursor: help;
+		outline: none;
+
+		&:hover,
+		&:focus-visible {
+			color: var(--wpds-color-fg-content-neutral);
+		}
+
+		svg {
+			fill: currentColor;
+		}
+	}
+
+	&__body {
+		padding: 0;
+	}
+
+	&__pill-list {
+		min-width: 0;
+	}
+
+	&__pill {
+		border: 1px solid var(--wpds-color-stroke-surface-neutral);
+		border-radius: 8px;
+		background: var(--wpds-color-bg-surface-neutral-strong);
+		color: var(--wpds-color-fg-content-neutral);
+		font-size: 13px;
+		line-height: 20px;
+	}
+
+	&__muted {
+		color: var(--wpds-color-fg-content-neutral-weak);
+	}
+
+	&__global-link {
+		display: inline-flex;
+		align-items: center;
+		gap: 3px;
+		color: inherit;
+		text-decoration: none;
+
+		&:hover {
+			color: var(--wpds-color-fg-interactive-brand);
+			text-decoration: underline;
+		}
+
+		svg {
+			flex-shrink: 0;
+			opacity: 0.6;
+		}
+
+		&:hover svg {
+			opacity: 1;
+		}
+	}
+
+}
+
 .woocommerce-product-list__filters {
 	padding: var(--wpds-dimension-padding-sm, 8px) var(--wpds-dimension-padding-2xl, 24px);
 }

 .woocommerce-variation-view {
-	// DataViews is designed for a constrained editor viewport. Override to let
-	// the table grow to its natural height in the traditional metabox document flow.
-	.dataviews-wrapper {
-		height: auto;
+	&__title {
+		margin: 0;
+		padding: 16px 24px 0;
+		color: var(--wpds-color-fg-content-neutral);
+		font-size: 15px;
+		font-weight: 600;
+		line-height: 20px;
 	}

-	.dataviews-layout__container {
-		overflow: visible;
+	&__toolbar {
+		padding: 16px 24px 24px;
 	}

-	&__toolbar {
-		padding: var(--wpds-dimension-padding-lg, 24px);
+	&__toolbar-search,
+	&__toolbar-actions {
+		min-width: 0;
+	}
+
+	&__filters {
+		padding: 0 24px 16px;
 	}

 	&__variation-options--empty {
-		color: var(--wpds-color-fg-content-neutral-weak, #757575);
+		color: var(--wpds-color-fg-content-neutral-weak);
 	}

 	&__variation-options-cell {
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts
new file mode 100644
index 00000000000..c53a36809fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Internal dependencies
+ */
+import {
+	getProductAttributeRows,
+	getVariationAttributeRows,
+} from './attribute-rows';
+import type { ProductEntityRecord } from '../../fields/types';
+
+function getProduct(
+	overrides: Partial< ProductEntityRecord >
+): ProductEntityRecord {
+	return {
+		attributes: [],
+		default_attributes: [],
+		...overrides,
+	} as ProductEntityRecord;
+}
+
+describe( 'getVariationAttributeRows', () => {
+	it( 'returns only attributes used for variations in product order', () => {
+		const rows = getVariationAttributeRows(
+			getProduct( {
+				attributes: [
+					{
+						id: 0,
+						name: 'Pattern',
+						slug: 'pattern',
+						options: [ 'Dots' ],
+						position: 3,
+						variation: false,
+						visible: true,
+					},
+					{
+						id: 2,
+						name: 'Color',
+						slug: 'pa_color',
+						options: [ 'Yellow', 'Blue' ],
+						position: 2,
+						variation: true,
+						visible: true,
+					},
+					{
+						id: 1,
+						name: 'Theme',
+						slug: 'pa_theme',
+						options: [ 'Unicorn', 'Pirate' ],
+						position: 1,
+						variation: true,
+						visible: true,
+					},
+				],
+			} )
+		);
+
+		expect( rows ).toEqual( [
+			expect.objectContaining( {
+				attributeId: 1,
+				id: 'pa_theme',
+				isGlobal: true,
+				name: 'Theme',
+				slug: 'pa_theme',
+				values: [ 'Unicorn', 'Pirate' ],
+			} ),
+			expect.objectContaining( {
+				attributeId: 2,
+				id: 'pa_color',
+				isGlobal: true,
+				name: 'Color',
+				slug: 'pa_color',
+				values: [ 'Yellow', 'Blue' ],
+			} ),
+		] );
+	} );
+
+	it( 'maps default values by attribute ID, name, and slug', () => {
+		const rows = getVariationAttributeRows(
+			getProduct( {
+				attributes: [
+					{
+						id: 10,
+						name: 'Theme',
+						slug: 'pa_theme',
+						options: [ 'Unicorn' ],
+						position: 1,
+						variation: true,
+						visible: true,
+					},
+					{
+						id: 0,
+						name: 'Material',
+						slug: 'material',
+						options: [ 'Cotton' ],
+						position: 2,
+						variation: true,
+						visible: true,
+					},
+					{
+						id: 11,
+						name: 'Color',
+						slug: 'pa_color',
+						options: [ 'Yellow' ],
+						position: 3,
+						variation: true,
+						visible: true,
+					},
+				],
+				default_attributes: [
+					{ id: 10, name: 'Theme', option: 'Unicorn' },
+					{ id: 0, name: 'Material', option: 'Cotton' },
+					{ id: 0, name: 'pa_color', option: 'Yellow' },
+				],
+			} )
+		);
+
+		expect( rows.map( ( row ) => row.defaultValue ) ).toEqual( [
+			'Unicorn',
+			'Cotton',
+			'Yellow',
+		] );
+	} );
+
+	it( 'marks custom attributes as local', () => {
+		const rows = getVariationAttributeRows(
+			getProduct( {
+				attributes: [
+					{
+						id: 0,
+						name: 'Material',
+						slug: 'material',
+						options: [ 'Cotton' ],
+						position: 1,
+						variation: true,
+						visible: true,
+					},
+				],
+			} )
+		);
+
+		expect( rows[ 0 ] ).toEqual(
+			expect.objectContaining( {
+				attributeId: 0,
+				id: 'material',
+				isGlobal: false,
+				name: 'Material',
+				slug: 'material',
+			} )
+		);
+	} );
+} );
+
+describe( 'getProductAttributeRows', () => {
+	it( 'returns only attributes not used for variations in product order', () => {
+		const rows = getProductAttributeRows(
+			getProduct( {
+				attributes: [
+					{
+						id: 2,
+						name: 'Color',
+						slug: 'pa_color',
+						options: [ 'Yellow', 'Blue' ],
+						position: 2,
+						variation: true,
+						visible: true,
+					},
+					{
+						id: 0,
+						name: 'Pattern',
+						slug: 'pattern',
+						options: [ 'Dots' ],
+						position: 3,
+						variation: false,
+						visible: true,
+					},
+					{
+						id: 1,
+						name: 'Material',
+						slug: 'pa_material',
+						options: [ 'Cotton' ],
+						position: 1,
+						variation: false,
+						visible: false,
+					},
+				],
+			} )
+		);
+
+		expect( rows ).toEqual( [
+			expect.objectContaining( {
+				attributeId: 1,
+				id: 'pa_material',
+				isGlobal: true,
+				isVisible: false,
+				name: 'Material',
+				slug: 'pa_material',
+				values: [ 'Cotton' ],
+			} ),
+			expect.objectContaining( {
+				attributeId: 0,
+				id: 'pattern',
+				isGlobal: false,
+				isVisible: true,
+				name: 'Pattern',
+				slug: 'pattern',
+				values: [ 'Dots' ],
+			} ),
+		] );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts
new file mode 100644
index 00000000000..6b65eb16f23
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/attribute-rows.ts
@@ -0,0 +1,93 @@
+/**
+ * Internal dependencies
+ */
+import type {
+	ProductEntityAttribute,
+	ProductEntityDefaultAttribute,
+	ProductEntityRecord,
+} from '../../fields/types';
+
+export type VariationAttributeRow = {
+	attributeId: number;
+	defaultValue: string;
+	id: string;
+	isGlobal: boolean;
+	isVisible: boolean;
+	name: string;
+	position: number;
+	slug: string;
+	values: string[];
+};
+
+type AttributeFilter = 'product' | 'variation';
+
+function getAttributeDefaultValue(
+	attribute: ProductEntityAttribute,
+	defaultAttributes: ProductEntityDefaultAttribute[]
+): string {
+	const matchingDefault = defaultAttributes.find( ( defaultAttribute ) => {
+		return (
+			( attribute.id > 0 && defaultAttribute.id === attribute.id ) ||
+			defaultAttribute.name === attribute.name ||
+			defaultAttribute.name === attribute.slug
+		);
+	} );
+
+	return matchingDefault?.option ?? '';
+}
+
+function getAttributeRows(
+	product:
+		| Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+		| undefined,
+	filter: AttributeFilter
+): VariationAttributeRow[] {
+	if ( ! product?.attributes ) {
+		return [];
+	}
+
+	const defaultAttributes = product.default_attributes ?? [];
+	const isVariationAttribute = filter === 'variation';
+
+	return product.attributes
+		.map( ( attribute, index ) => ( {
+			attribute,
+			index,
+		} ) )
+		.filter(
+			( { attribute } ) =>
+				Boolean( attribute.variation ) === isVariationAttribute
+		)
+		.sort( ( first, second ) => {
+			return (
+				first.attribute.position - second.attribute.position ||
+				first.index - second.index
+			);
+		} )
+		.map( ( { attribute } ) => ( {
+			attributeId: attribute.id,
+			defaultValue: getAttributeDefaultValue(
+				attribute,
+				defaultAttributes
+			),
+			id: attribute.slug,
+			isGlobal: attribute.id > 0,
+			isVisible: Boolean( attribute.visible ),
+			name: attribute.name,
+			position: attribute.position,
+			slug: attribute.slug,
+			values: attribute.options ?? [],
+		} ) );
+}
+
+export function getProductAttributeRows(
+	product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+): VariationAttributeRow[] {
+	return getAttributeRows( product, 'product' );
+}
+
+export function getVariationAttributeRows(
+	product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+): VariationAttributeRow[] {
+	return getAttributeRows( product, 'variation' );
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx
new file mode 100644
index 00000000000..f665be91ab5
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/dataviews.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { DataViews, type View, type ViewTable } from '@wordpress/dataviews';
+import { Tooltip } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Button, Stack } from '@wordpress/ui';
+import { help, Icon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../../fields/types';
+import { getAttributeTableFields, type AttributeTableColumn } from './fields';
+import type { VariationAttributeRow } from './attribute-rows';
+
+const EMPTY_ARRAY: VariationAttributeRow[] = [];
+const ATTRIBUTE_PAGE_SIZE = 50;
+const noop = () => undefined;
+
+type AttributeTableLayoutStyles = NonNullable<
+	ViewTable[ 'layout' ]
+>[ 'styles' ];
+type AttributeRowGetter = (
+	product?: Pick< ProductEntityRecord, 'attributes' | 'default_attributes' >
+) => VariationAttributeRow[];
+
+function getAttributeTableView(
+	columns: AttributeTableColumn[],
+	styles: AttributeTableLayoutStyles
+): View {
+	return {
+		type: 'table',
+		page: 1,
+		perPage: ATTRIBUTE_PAGE_SIZE,
+		titleField: 'name',
+		fields: columns,
+		layout: {
+			styles,
+		},
+	};
+}
+
+export const DEFAULT_VARIATION_ATTRIBUTE_COLUMNS: AttributeTableColumn[] = [
+	'values',
+	'defaultValue',
+	'isGlobal',
+];
+
+export const DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS: AttributeTableColumn[] = [
+	'values',
+	'isVisible',
+	'isGlobal',
+];
+
+export const DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES = {
+	name: { width: '160px' },
+	values: { width: '100%' },
+	defaultValue: { width: '180px', minWidth: '180px' },
+	isGlobal: { width: '120px', minWidth: '120px' },
+};
+
+export const DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES = {
+	name: { width: '220px' },
+	values: { width: '100%' },
+	isVisible: { width: '240px', minWidth: '240px' },
+	isGlobal: { width: '120px', minWidth: '120px' },
+};
+
+type AttributeTableProps = {
+	columns: AttributeTableColumn[];
+	getRows: AttributeRowGetter;
+	hasSeparator?: boolean;
+	helpText: string;
+	hideWhenEmpty?: boolean;
+	nameLabel: string;
+	notice?: JSX.Element;
+	productId: number;
+	styles: AttributeTableLayoutStyles;
+	title: string;
+};
+
+export function AttributeTable( {
+	columns,
+	getRows,
+	hasSeparator = false,
+	helpText,
+	hideWhenEmpty = false,
+	nameLabel,
+	notice,
+	productId,
+	styles,
+	title,
+}: AttributeTableProps ) {
+	const [ view, setView ] = useState< View >( () =>
+		getAttributeTableView( columns, styles )
+	);
+	const fields = useMemo(
+		() => getAttributeTableFields( nameLabel ),
+		[ nameLabel ]
+	);
+	const { product, hasResolved } = useSelect(
+		( select ) => {
+			const coreSelect = select( coreStore );
+			const resolutionArgs = [ 'root', 'product', productId ];
+
+			return {
+				hasResolved: coreSelect.hasFinishedResolution(
+					'getEntityRecord',
+					resolutionArgs
+				),
+				product: coreSelect.getEditedEntityRecord(
+					'root',
+					'product',
+					productId
+				) as unknown as ProductEntityRecord | undefined,
+			};
+		},
+		[ productId ]
+	);
+
+	const rows = useMemo(
+		() => ( hasResolved ? getRows( product ) : EMPTY_ARRAY ),
+		[ getRows, hasResolved, product ]
+	);
+	const perPage = view.perPage || ATTRIBUTE_PAGE_SIZE;
+	const pageRows = useMemo< VariationAttributeRow[] >( () => {
+		const page = view.page ?? 1;
+		const offset = ( page - 1 ) * perPage;
+
+		return rows.slice( offset, offset + perPage );
+	}, [ perPage, rows, view.page ] );
+	const paginationInfo = useMemo(
+		() => ( {
+			totalItems: rows.length,
+			totalPages: Math.ceil( rows.length / perPage ),
+		} ),
+		[ perPage, rows.length ]
+	);
+
+	if ( hideWhenEmpty && hasResolved && rows.length === 0 ) {
+		return null;
+	}
+
+	return (
+		<section
+			className={
+				hasSeparator
+					? 'woocommerce-variation-attributes woocommerce-variation-attributes--has-separator'
+					: 'woocommerce-variation-attributes'
+			}
+		>
+			{ notice }
+			<Stack
+				direction="row"
+				align="center"
+				justify="space-between"
+				className="woocommerce-variation-attributes__header"
+			>
+				<Stack
+					direction="row"
+					align="center"
+					gap="xs"
+					className="woocommerce-variation-attributes__title-group"
+				>
+					<h3 className="woocommerce-variation-attributes__title">
+						{ title }
+					</h3>
+					<Tooltip text={ helpText }>
+						<span
+							className="woocommerce-variation-attributes__help"
+							tabIndex={ 0 }
+							aria-label={ __( 'Help', 'woocommerce' ) }
+						>
+							<Icon icon={ help } size={ 20 } />
+						</span>
+					</Tooltip>
+				</Stack>
+				<Button variant="outline" onClick={ noop }>
+					{ __( 'Edit', 'woocommerce' ) }
+				</Button>
+			</Stack>
+			<div className="woocommerce-variation-attributes__body">
+				<div className="woocommerce-variation-attributes__dataview">
+					<DataViews
+						data={ pageRows }
+						fields={ fields }
+						view={ view }
+						onChangeView={ setView }
+						paginationInfo={ paginationInfo }
+						defaultLayouts={ { table: {} } }
+						getItemId={ ( item ) => item.id }
+						search={ false }
+						isLoading={ ! hasResolved }
+					>
+						<DataViews.Layout />
+					</DataViews>
+				</div>
+			</div>
+		</section>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx
new file mode 100644
index 00000000000..682ed0eb4fd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/fields.tsx
@@ -0,0 +1,159 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+import { Tooltip } from '@wordpress/components';
+import { addQueryArgs } from '@wordpress/url';
+import { getAdminLink } from '@woocommerce/settings';
+import { __ } from '@wordpress/i18n';
+import { Badge, Stack } from '@wordpress/ui';
+import { Icon, link as linkIcon } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationAttributeRow } from './attribute-rows';
+
+export type AttributeTableColumn =
+	| 'values'
+	| 'defaultValue'
+	| 'isVisible'
+	| 'isGlobal';
+
+function getGlobalAttributeTermsLink( attributeSlug: string ): string {
+	return getAdminLink(
+		addQueryArgs( 'edit-tags.php', {
+			taxonomy: attributeSlug,
+			post_type: 'product',
+		} )
+	);
+}
+
+function getBooleanLabel( value: boolean ) {
+	return value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
+}
+
+function EmptyDefaultValue() {
+	return (
+		<span className="woocommerce-variation-attributes__muted">&mdash;</span>
+	);
+}
+
+function BooleanValue( { value }: { value: boolean } ) {
+	const label = getBooleanLabel( value );
+
+	if ( value ) {
+		return <>{ label }</>;
+	}
+
+	return (
+		<span className="woocommerce-variation-attributes__muted">
+			{ label }
+		</span>
+	);
+}
+
+function AttributeValuePills( { values }: { values: string[] } ) {
+	if ( values.length === 0 ) {
+		return <EmptyDefaultValue />;
+	}
+
+	return (
+		<Stack
+			direction="row"
+			gap="xs"
+			wrap="wrap"
+			className="woocommerce-variation-attributes__pill-list"
+		>
+			{ values.map( ( value ) => (
+				<Badge
+					key={ value }
+					intent="none"
+					className="woocommerce-variation-attributes__pill"
+				>
+					{ value }
+				</Badge>
+			) ) }
+		</Stack>
+	);
+}
+
+export function getAttributeTableFields(
+	nameLabel: string
+): Field< VariationAttributeRow >[] {
+	return [
+		{
+			id: 'name',
+			label: nameLabel,
+			enableHiding: false,
+			enableSorting: false,
+			enableGlobalSearch: false,
+			getValue: ( { item } ) => item.name,
+		},
+		{
+			id: 'values',
+			label: __( 'Values', 'woocommerce' ),
+			enableSorting: false,
+			enableGlobalSearch: false,
+			getValue: ( { item } ) => item.values.join( ' ' ),
+			render: ( { item } ) => (
+				<AttributeValuePills values={ item.values } />
+			),
+		},
+		{
+			id: 'defaultValue',
+			label: __( 'Default value', 'woocommerce' ),
+			enableSorting: false,
+			enableGlobalSearch: false,
+			getValue: ( { item } ) => item.defaultValue,
+			render: ( { item } ) =>
+				item.defaultValue ? (
+					<Badge
+						intent="none"
+						className="woocommerce-variation-attributes__pill"
+					>
+						{ item.defaultValue }
+					</Badge>
+				) : (
+					<EmptyDefaultValue />
+				),
+		},
+		{
+			id: 'isVisible',
+			label: __( 'Visible on product page', 'woocommerce' ),
+			enableSorting: false,
+			enableGlobalSearch: false,
+			getValue: ( { item } ) => getBooleanLabel( item.isVisible ),
+			render: ( { item } ) => <BooleanValue value={ item.isVisible } />,
+		},
+		{
+			id: 'isGlobal',
+			label: __( 'Global', 'woocommerce' ),
+			enableSorting: false,
+			enableGlobalSearch: false,
+			getValue: ( { item } ) => getBooleanLabel( item.isGlobal ),
+			render: ( { item } ) =>
+				item.isGlobal ? (
+					<Tooltip
+						text={ __(
+							'Available across all products. Customers can filter your catalog by this attribute.',
+							'woocommerce'
+						) }
+					>
+						<a
+							className="woocommerce-variation-attributes__global-link"
+							href={ getGlobalAttributeTermsLink( item.slug ) }
+							target="_blank"
+							rel="noreferrer"
+							onClick={ ( event ) => event.stopPropagation() }
+						>
+							{ __( 'Yes', 'woocommerce' ) }
+							<Icon icon={ linkIcon } size={ 16 } />
+						</a>
+					</Tooltip>
+				) : (
+					<BooleanValue value={ item.isGlobal } />
+				),
+		},
+	];
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx b/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx
new file mode 100644
index 00000000000..39aa1c0a8f3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/variation-attributes/index.tsx
@@ -0,0 +1,205 @@
+/**
+ * External dependencies
+ */
+import { Notice } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import {
+	createInterpolateElement,
+	useEffect,
+	useState,
+} from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import {
+	getProductAttributeRows,
+	getVariationAttributeRows,
+} from './attribute-rows';
+import type { ProductEntityRecord } from '../../fields/types';
+import {
+	AttributeTable,
+	DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS,
+	DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES,
+	DEFAULT_VARIATION_ATTRIBUTE_COLUMNS,
+	DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES,
+} from './dataviews';
+
+const VARIATIONS_PANEL_SELECTOR = '#variable_product_options';
+const VARIATIONS_TAB_SELECTOR =
+	'#woocommerce-product-data ul.product_data_tabs a[href="#variable_product_options"]';
+
+function openVariationsTab() {
+	const tabLink = document.querySelector< HTMLAnchorElement >(
+		VARIATIONS_TAB_SELECTOR
+	);
+	const panel = document.querySelector< HTMLElement >(
+		VARIATIONS_PANEL_SELECTOR
+	);
+	const tab = tabLink?.closest< HTMLElement >( 'li' );
+
+	if ( ! tabLink || ! panel || ! tab ) {
+		return;
+	}
+
+	if ( getComputedStyle( tab ).display === 'none' ) {
+		return;
+	}
+
+	tabLink.click();
+
+	if (
+		tab.classList.contains( 'active' ) &&
+		getComputedStyle( panel ).display !== 'none'
+	) {
+		return;
+	}
+
+	const panelWrap = tabLink.closest< HTMLElement >( 'div.panel-wrap' );
+
+	if ( ! panelWrap ) {
+		return;
+	}
+
+	panelWrap
+		.querySelectorAll( 'ul.wc-tabs li' )
+		.forEach( ( item ) => item.classList.remove( 'active' ) );
+	tab.classList.add( 'active' );
+
+	panelWrap
+		.querySelectorAll< HTMLElement >( 'div.panel' )
+		.forEach( ( item ) => {
+			item.style.display = item === panel ? 'block' : 'none';
+		} );
+	panel.dispatchEvent( new Event( 'woocommerce_tab_shown' ) );
+}
+
+function ProductAttributesNotice() {
+	const [ isVisible, setIsVisible ] = useState( true );
+
+	useEffect( () => {
+		function handleVariationsLinkClick( event: MouseEvent ) {
+			const target = event.target;
+
+			if ( ! ( target instanceof Element ) ) {
+				return;
+			}
+
+			const link = target.closest< HTMLAnchorElement >(
+				'.woocommerce-variation-attributes__notice a[href="#variable_product_options"]'
+			);
+
+			if ( ! link ) {
+				return;
+			}
+
+			event.preventDefault();
+			openVariationsTab();
+		}
+
+		document.addEventListener( 'click', handleVariationsLinkClick, true );
+
+		return () => {
+			document.removeEventListener(
+				'click',
+				handleVariationsLinkClick,
+				true
+			);
+		};
+	}, [] );
+
+	if ( ! isVisible ) {
+		return null;
+	}
+
+	return (
+		<Notice
+			status="info"
+			isDismissible
+			className="woocommerce-variation-attributes__notice"
+			onRemove={ () => setIsVisible( false ) }
+		>
+			{ createInterpolateElement(
+				__(
+					'Attributes used for variations have moved to the <variationsLink />.',
+					'woocommerce'
+				),
+				{
+					variationsLink: (
+						<a href="#variable_product_options">
+							{ __( 'Variations tab', 'woocommerce' ) }
+						</a>
+					),
+				}
+			) }
+		</Notice>
+	);
+}
+
+type VariationAttributesProps = {
+	productId: number;
+};
+
+export function ProductAttributes( { productId }: VariationAttributesProps ) {
+	const { product, hasResolved } = useSelect(
+		( select ) => {
+			const coreSelect = select( coreStore );
+			const resolutionArgs = [ 'root', 'product', productId ];
+
+			return {
+				hasResolved: coreSelect.hasFinishedResolution(
+					'getEntityRecord',
+					resolutionArgs
+				),
+				product: coreSelect.getEditedEntityRecord(
+					'root',
+					'product',
+					productId
+				) as unknown as ProductEntityRecord | undefined,
+			};
+		},
+		[ productId ]
+	);
+
+	const notice =
+		hasResolved && product?.type === 'variable' ? (
+			<ProductAttributesNotice />
+		) : undefined;
+
+	return (
+		<AttributeTable
+			columns={ DEFAULT_PRODUCT_ATTRIBUTE_COLUMNS }
+			getRows={ getProductAttributeRows }
+			helpText={ __(
+				'Product attributes describe details customers can use to search, filter, and compare products.',
+				'woocommerce'
+			) }
+			nameLabel={ __( 'Name', 'woocommerce' ) }
+			notice={ notice }
+			productId={ productId }
+			styles={ DEFAULT_PRODUCT_ATTRIBUTE_LAYOUT_STYLES }
+			title={ __( 'Product attributes', 'woocommerce' ) }
+		/>
+	);
+}
+
+export function VariationAttributes( { productId }: VariationAttributesProps ) {
+	return (
+		<AttributeTable
+			columns={ DEFAULT_VARIATION_ATTRIBUTE_COLUMNS }
+			getRows={ getVariationAttributeRows }
+			hasSeparator
+			helpText={ __(
+				'Edit attribute values to update combinations. Customers see attributes in the order shown, with the default value pre-selected on the product page.',
+				'woocommerce'
+			) }
+			hideWhenEmpty
+			nameLabel={ __( 'Attribute', 'woocommerce' ) }
+			productId={ productId }
+			styles={ DEFAULT_VARIATION_ATTRIBUTE_LAYOUT_STYLES }
+			title={ __( 'Variation attributes', 'woocommerce' ) }
+		/>
+	);
+}
diff --git a/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes b/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes
new file mode 100644
index 00000000000..557f4a315f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/jamesckemp-readonly-variation-attributes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add readonly variation attributes
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 f26e4e94f4f..9534c2236e0 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
@@ -5,10 +5,22 @@
  * @package WooCommerce\Admin
  */

+use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+
 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }

+if ( Features::exists( ProductVariationsClassicRedesign::FEATURE_ID ) ) {
+	?>
+	<div id="product_attributes" class="panel wc-metaboxes-wrapper hidden">
+		<div id="<?php echo esc_attr( ProductVariationsClassicRedesign::ATTRIBUTES_ROOT_ID ); ?>"></div>
+	</div>
+	<?php
+	return;
+}
+
 global $wc_product_attributes;
 // Array of defined attribute taxonomies.
 $attribute_taxonomies = wc_get_attribute_taxonomies();
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
index 9e0d6b29297..1131b5b51a3 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
@@ -6,18 +6,18 @@
  */

 use Automattic\WooCommerce\Admin\Features\Features;
-use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign\Init;
+use Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }

-if ( Features::exists( Init::FEATURE_ID ) ) {
+if ( Features::exists( ProductVariationsClassicRedesign::FEATURE_ID ) ) {
 	?>
 	<div id="variable_product_options" class="panel wc-metaboxes-wrapper hidden">
 		<div id="variable_product_options_inner">
-			<div id="<?php echo esc_attr( Init::ROOT_ID ); ?>"></div>
+			<div id="<?php echo esc_attr( ProductVariationsClassicRedesign::ROOT_ID ); ?>"></div>
 		</div>
 	</div>
 	<?php
diff --git a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
similarity index 72%
rename from plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
rename to plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
index 295251798ea..5796b28d34c 100644
--- a/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign/Init.php
+++ b/plugins/woocommerce/src/Admin/Features/ProductVariationsClassicRedesign.php
@@ -5,16 +5,17 @@

 declare( strict_types = 1 );

-namespace Automattic\WooCommerce\Admin\Features\ProductVariationsClassicRedesign;
+namespace Automattic\WooCommerce\Admin\Features;

 /**
  * Loads assets for the product variations classic redesign feature.
  */
-class Init {
-	const FEATURE_ID    = 'product-variations-classic-redesign';
-	const SCRIPT_HANDLE = 'wc-experimental-products-app';
-	const SCRIPT_PATH   = 'experimental-products-app';
-	const ROOT_ID       = 'woocommerce-variations-classic-root';
+class ProductVariationsClassicRedesign {
+	const FEATURE_ID         = 'product-variations-classic-redesign';
+	const SCRIPT_HANDLE      = 'wc-experimental-products-app';
+	const SCRIPT_PATH        = 'experimental-products-app';
+	const ROOT_ID            = 'woocommerce-variations-classic-root';
+	const ATTRIBUTES_ROOT_ID = 'woocommerce-product-attributes-classic-root';

 	/**
 	 * Constructor
@@ -78,14 +79,20 @@ class Init {

 		global $post;
 		$product_id = $post ? $post->ID : 0;
+		$script     = sprintf(
+			'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
+			wp_json_encode( self::ROOT_ID ),
+			$product_id
+		);
+		$script    .= sprintf(
+			' window.wc.experimentalProductsApp.initializeProductAttributesView( %s, %d );',
+			wp_json_encode( self::ATTRIBUTES_ROOT_ID ),
+			$product_id
+		);

 		wp_add_inline_script(
 			self::SCRIPT_HANDLE,
-			sprintf(
-				'window.wc.experimentalProductsApp.initializeVariationView( %s, %d );',
-				wp_json_encode( self::ROOT_ID ),
-				$product_id
-			),
+			$script,
 			'after'
 		);
 	}