Commit 3d781463939 for woocommerce

commit 3d7814639395eeb4c209bafb3d34ce1f63d3cb22
Author: Poli Gilad <83961704+poligilad-auto@users.noreply.github.com>
Date:   Fri May 22 15:40:31 2026 +0200

    Add variation-specific edit panel to new variation experience (#65256)

    * WIP: scaffold variation-view edit panel with forked fields

    Paused mid-work. Fork of the field components into variation-view/fields/
    plus a new variation-view/edit/ drawer + form + form-fields. Build does
    not yet succeed (missing copied .ts/.tsx siblings like validation.ts).
    Coming back to this later.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * refactor: pull shared variation panel fields from main registry

    Remove duplicate field definitions from variation-view/fields/ and rewrite
    registry.tsx to compose from the main registry via createProductFields().
    Shipping fields get an isVisible guard to hide them when virtual=true.
    Add empty stubs for variation-only fields to be implemented in later tasks.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * refactor: rename variation downloadable field to downloadable_files

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * feat: add variation-exclusive fields (tax_class, virtual, backorders, download options)

    Replace 6 stub field files with real implementations: tax_class (SelectControl), virtual (checkbox), allow_backorders (SelectControl, manage_stock-gated), low_stock_amount (TextControl, manage_stock-gated), download_limit (checkbox + TextControl, downloadable-gated), download_expiry (checkbox + TextControl, downloadable-gated).

    * feat: update variation panel form layout to match approved designs

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * dev: add changelog for variation panel field registry

    * feat: hide shipping section header when virtual is enabled

    * feat: update variation panel form layout to match approved designs

    * Fix variations quick edit crashes and save errors

    * Add Active/Inactive status for variations with badge and descriptions

    * Fix prettier formatting in product_status field

    * feat: variation edit panel UI improvements

    - Rewrite downloadable files field with Choose File / trash buttons, separator between entries, 40px inputs
    - Add download limit and expiry fields with checkbox-controlled inputs and days suffix
    - Add images label via BaseControl wrapper; shrink description textarea to 4 rows
    - Move downloadable/virtual to unlabeled checkbox group; move tax_class to Price section
    - Add product_status Active/Inactive badge; fix shipping section hidden when virtual
    - Group download_limit and download_expiry in a sub-group under Downloadable files
    - Various style and layout fixes across the drawer form

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * feat: add Attributes section to variation edit drawer

    Adds a new field that renders one SelectControl per variation attribute
    at the bottom of the edit drawer. Uses a two-column CSS grid (last item
    spans full width when count is odd). Matches by name for local attributes
    (id=0) to avoid all dropdowns updating simultaneously when IDs collide.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * fix: preserve post-save entity record edits so drawer re-opens with correct data

    After a successful save, saveVariation writes fresh server data into the
    parent entity record's edits via editEntityRecord. The previous closeDrawer
    immediately called clearEntityRecordEdits, wiping those edits before the
    async re-fetch could update the base state. On drawer re-open the base
    state was stale, so fields like downloadable files appeared unsaved.

    Split close behaviour: navigateAway (used after save) preserves the edits
    that hold the just-saved state; closeDrawer (Cancel / X) still clears edits
    to discard unsaved changes.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * Add label above images upload area in product edit

    * Redesign downloadable files field to match WooCommerce Core UI

    * Fix images label visibility and downloadable default row

    * fix: move drawer close navigation to VariationView where location.path is valid

    The drawer was calling navigate() with an undefined path because useLocation()
    returns path=undefined in the WP admin classic editor context. Navigation
    called from index.tsx (VariationView) works correctly. Pass onClose from
    VariationView so Cancel/X use the same working navigate call as open does.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * fix: resolve TypeScript errors blocking ESM build in variation edit fields

    - attributes/field.tsx: simplify redundant `parentProduct !== false` check
    - downloadable_files/field.tsx: ensure id defaults to '' when mapping UploadedItem to ProductDownload

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * fix: use variation_active field (Luigi's) instead of product_status in variation drawer

    Replace our own product_status (Published/Draft) in the variation edit panel
    with the variation_active field from the main registry, which shows
    Active/Inactive — the correct statuses for variations.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

    * Fix variation save rejecting COGS with null defined_value

    * Fix save test assertion - saveEditedEntityRecord not called for variation-only saves

    * Fix ESLint errors in variation-view, downloadable, and admin files

    * Revert accidental JSDoc change in confirmation-modal.jsx

    * Fix variation-view field issues from code review

    * Add unit tests for variation-view currency utilities

    * Decouple variation edit panel from main product field registry

    * Revert unrelated product list field changes to trunk state

    * Revert all main product field changes to trunk state

    * restore changes

    * restore changes

    * remove .gitignore

    * fix build

    * add comment

    * lint code

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    Co-authored-by: Luigi Teschio <gigitux@gmail.com>

diff --git a/packages/js/experimental-products-app/changelog/add-variation-panel-fields b/packages/js/experimental-products-app/changelog/add-variation-panel-fields
new file mode 100644
index 00000000000..bc51e6fe12d
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/add-variation-panel-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add variation-exclusive fields (tax class, virtual, backorders, downloadable options) and refactor panel field registry to compose from shared main registry
diff --git a/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display b/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display
new file mode 100644
index 00000000000..d43ebec7da4
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-variation-shipping-tax-class-display
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix shipping class and tax class selects in variation edit panel to show "Same as parent" instead of blank when no override is set.
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx b/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx
new file mode 100644
index 00000000000..dabb8a5a73f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/drawer.tsx
@@ -0,0 +1,454 @@
+/**
+ * External dependencies
+ */
+import { Button, Spinner } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { select as wpSelect, useDispatch, useSelect } from '@wordpress/data';
+import { useCallback, useState } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import { privateApis as routerPrivateApis } from '@wordpress/router';
+import { Drawer } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+import { getSelectionFromPostId } from '../../product-list/utils';
+import {
+	findProductInList,
+	getProductEditRecord,
+	getProductEditFields,
+	getProductWithUpdatedVariation,
+	isProductVariation,
+} from '../../product-edit/utils';
+import { saveSelectedProducts } from '../../product-edit/save';
+import { buildVariationViewQuery } from '../query';
+import type { ProductEntityRecord } from '../fields/types';
+import { variationEditFields } from '../fields/registry';
+import { VariationEditForm } from './form';
+
+const { useLocation } = unlock( routerPrivateApis );
+
+type VariationEditDrawerProps = {
+	products: ProductEntityRecord[];
+	isOpen: boolean;
+	productId: number;
+	onClose: () => void;
+};
+
+function getSaveNoticeMessage( successCount: number, failedCount: number ) {
+	if ( failedCount === 0 ) {
+		if ( successCount === 1 ) {
+			return __( 'Variation saved.', 'woocommerce' );
+		}
+
+		return sprintf(
+			/* translators: %d number of saved variations. */
+			__( '%d variations saved.', 'woocommerce' ),
+			successCount
+		);
+	}
+
+	if ( successCount === 0 ) {
+		if ( failedCount === 1 ) {
+			return __( 'Failed to save variation.', 'woocommerce' );
+		}
+
+		return sprintf(
+			/* translators: %d number of variations that could not be saved. */
+			__( 'Failed to save %d variations.', 'woocommerce' ),
+			failedCount
+		);
+	}
+
+	return sprintf(
+		/* translators: 1: successful variation count, 2: failed variation count. */
+		__(
+			'Saved %1$d variations. %2$d variations could not be saved.',
+			'woocommerce'
+		),
+		successCount,
+		failedCount
+	);
+}
+
+export function VariationEditDrawer( {
+	products,
+	isOpen,
+	productId,
+	onClose,
+}: VariationEditDrawerProps ) {
+	const { query = {} } = useLocation();
+	const requestedProductIdsFromRoute = getSelectionFromPostId( query.postId )
+		.map( ( postId ) => Number( postId ) )
+		.filter( ( postId ) => Number.isSafeInteger( postId ) && postId > 0 );
+	const requestedProductIds = Array.from(
+		new Set( requestedProductIdsFromRoute )
+	);
+
+	const [ isSaving, setIsSaving ] = useState( false );
+
+	const editableFields = getProductEditFields( variationEditFields );
+	const {
+		selectedProducts,
+		isResolving,
+		hasResolved,
+		hasMissingProducts,
+		hasEdits,
+	} = useSelect(
+		( select ) => {
+			if ( requestedProductIds.length === 0 ) {
+				return {
+					selectedProducts: [],
+					isResolving: false,
+					hasResolved: true,
+					hasMissingProducts: false,
+					hasEdits: false,
+				};
+			}
+
+			const coreSelect = select( coreStore );
+			const productResults = requestedProductIds.map(
+				( reqProductId ) => {
+					const resolutionArgs = [ 'root', 'product', reqProductId ];
+					const rootRecord = coreSelect.getEditedEntityRecord(
+						'root',
+						'product',
+						reqProductId
+					) as unknown as ProductEntityRecord | false | undefined;
+					const rootRecordEdits = coreSelect.getEntityRecordEdits(
+						'root',
+						'product',
+						reqProductId
+					) as Partial< ProductEntityRecord > | undefined;
+					const listedProduct = findProductInList(
+						products,
+						reqProductId
+					);
+					const product = getProductEditRecord(
+						listedProduct,
+						rootRecord,
+						rootRecordEdits
+					);
+					let record: ProductEntityRecord | false | undefined =
+						product ?? rootRecord;
+
+					if (
+						product &&
+						isProductVariation( product ) &&
+						product.parent_id
+					) {
+						const parentProduct = coreSelect.getEditedEntityRecord(
+							'root',
+							'product',
+							product.parent_id
+						) as unknown as ProductEntityRecord | false | undefined;
+						const editedParentProduct =
+							parentProduct !== false ? parentProduct : undefined;
+						const editedVariation =
+							editedParentProduct?._embedded?.variations?.find(
+								( variation ) => variation.id === product.id
+							);
+
+						record = editedVariation || product;
+					}
+
+					return {
+						productId: reqProductId,
+						record,
+						isResolving: listedProduct
+							? false
+							: coreSelect.isResolving(
+									'getEditedEntityRecord',
+									resolutionArgs
+							  ),
+						hasFinishedResolution: listedProduct
+							? true
+							: coreSelect.hasFinishedResolution(
+									'getEditedEntityRecord',
+									resolutionArgs
+							  ),
+					};
+				}
+			);
+			const resolvedProducts = productResults
+				.map( ( { record } ) => record )
+				.filter(
+					( product ): product is ProductEntityRecord =>
+						product !== undefined && product !== false
+				);
+			const editedProductIds = Array.from(
+				new Set(
+					resolvedProducts.map( ( product ) =>
+						isProductVariation( product ) && product.parent_id
+							? product.parent_id
+							: product.id
+					)
+				)
+			);
+
+			return {
+				selectedProducts: resolvedProducts,
+				isResolving: productResults.some(
+					( result ) =>
+						result.isResolving || ! result.hasFinishedResolution
+				),
+				hasResolved: productResults.every(
+					( result ) => result.hasFinishedResolution
+				),
+				hasMissingProducts: productResults.some(
+					( result ) =>
+						result.hasFinishedResolution && result.record === false
+				),
+				hasEdits: editedProductIds.some( ( editedProductId ) =>
+					coreSelect.hasEditsForEntityRecord(
+						'root',
+						'product',
+						editedProductId
+					)
+				),
+			};
+		},
+		[ products, requestedProductIds ]
+	);
+
+	const {
+		clearEntityRecordEdits,
+		editEntityRecord,
+		saveEditedEntityRecord,
+		invalidateResolution,
+	} = useDispatch( coreStore );
+
+	const { createSuccessNotice, createErrorNotice } =
+		useDispatch( noticesStore );
+
+	const hasNoRequestedProducts = requestedProductIds.length === 0;
+	const isReady =
+		hasResolved &&
+		! isResolving &&
+		! hasMissingProducts &&
+		selectedProducts.length === requestedProductIds.length &&
+		selectedProducts.length > 0;
+
+	let title = __( 'Edit variation', 'woocommerce' );
+
+	if ( isReady ) {
+		if ( selectedProducts.length === 1 ) {
+			title = selectedProducts[ 0 ]?.name || title;
+		} else {
+			title = sprintf(
+				/* translators: %d number of selected variations. */
+				__( 'Edit %d variations', 'woocommerce' ),
+				selectedProducts.length
+			);
+		}
+	}
+
+	const onChange = useCallback(
+		( changes: Partial< ProductEntityRecord > ) => {
+			const updatedParentProductsById = new Map<
+				number,
+				ProductEntityRecord
+			>();
+
+			selectedProducts.forEach( ( product ) => {
+				if ( ! product.parent_id ) {
+					return;
+				}
+
+				const parentProduct =
+					updatedParentProductsById.get( product.parent_id ) ??
+					( wpSelect( coreStore ).getEditedEntityRecord(
+						'root',
+						'product',
+						product.parent_id
+					) as ProductEntityRecord | false | undefined );
+
+				if ( ! parentProduct ) {
+					return;
+				}
+
+				updatedParentProductsById.set(
+					product.parent_id,
+					getProductWithUpdatedVariation( parentProduct, {
+						...product,
+						...changes,
+					} )
+				);
+			} );
+
+			updatedParentProductsById.forEach( ( parentProduct ) => {
+				editEntityRecord( 'root', 'product', parentProduct.id, {
+					_embedded: parentProduct._embedded,
+				} );
+			} );
+		},
+		[ editEntityRecord, selectedProducts ]
+	);
+
+	// Discard unsaved edits and close (Cancel / X button).
+	const closeDrawer = useCallback( () => {
+		const editedProductIds = new Set(
+			selectedProducts.map( ( product ) =>
+				isProductVariation( product ) && product.parent_id
+					? product.parent_id
+					: product.id
+			)
+		);
+
+		editedProductIds.forEach( ( editedProductId ) => {
+			clearEntityRecordEdits( 'root', 'product', editedProductId );
+		} );
+
+		onClose();
+	}, [ clearEntityRecordEdits, onClose, selectedProducts ] );
+
+	const onSave = useCallback( async () => {
+		if ( selectedProducts.length === 0 || isSaving ) {
+			return;
+		}
+
+		setIsSaving( true );
+
+		try {
+			const results = await saveSelectedProducts( {
+				selectedProducts,
+				editEntityRecord,
+				saveEditedEntityRecord,
+			} );
+
+			const successfulCount = results.filter(
+				( result ) => result.status === 'fulfilled'
+			).length;
+			const failedCount = results.length - successfulCount;
+			const message = getSaveNoticeMessage(
+				successfulCount,
+				failedCount
+			);
+
+			if ( failedCount > 0 ) {
+				createErrorNotice( message, {
+					type: 'snackbar',
+				} );
+				return;
+			}
+
+			if ( successfulCount > 0 ) {
+				createSuccessNotice( message, {
+					type: 'snackbar',
+				} );
+			}
+
+			// Invalidate the entity records cache so that VariationView
+			// re-fetches fresh data (including updated _embedded.variations)
+			// the next time the drawer is opened.
+			invalidateResolution( 'getEntityRecords', [
+				'root',
+				'product',
+				buildVariationViewQuery( productId ),
+			] );
+
+			// Navigate without clearing entity record edits. The edits set
+			// by saveVariation hold the fresh server-saved state. Clearing
+			// them here would revert to the stale base state before the
+			// re-fetch completes, making the next drawer open appear to show
+			// unsaved data (e.g. downloads missing).
+			onClose();
+		} finally {
+			setIsSaving( false );
+		}
+	}, [
+		createErrorNotice,
+		createSuccessNotice,
+		editEntityRecord,
+		invalidateResolution,
+		isSaving,
+		onClose,
+		productId,
+		saveEditedEntityRecord,
+		selectedProducts,
+	] );
+
+	return (
+		<Drawer.Root open={ isOpen } swipeDirection="right">
+			<Drawer.Popup
+				className="woocommerce-product-edit__drawer"
+				portal={
+					<Drawer.Portal className="woocommerce-product-edit__drawer-portal" />
+				}
+				style={ { width: 450 } }
+			>
+				<Drawer.Header className="woocommerce-product-edit__header">
+					<Drawer.Title className="woocommerce-product-edit__title">
+						{ title }
+					</Drawer.Title>
+					<Drawer.CloseIcon
+						onClick={ closeDrawer }
+						label={ __( 'Close', 'woocommerce' ) }
+					/>
+				</Drawer.Header>
+
+				<Drawer.Content className="woocommerce-product-edit">
+					{ hasNoRequestedProducts && (
+						<div className="woocommerce-product-edit__empty-state">
+							<p>
+								{ __(
+									'Select one or more variations to edit them here.',
+									'woocommerce'
+								) }
+							</p>
+						</div>
+					) }
+
+					{ ! hasNoRequestedProducts && isResolving && (
+						<div className="woocommerce-product-edit__loading">
+							<Spinner />
+						</div>
+					) }
+
+					{ ! hasNoRequestedProducts &&
+						! isResolving &&
+						hasMissingProducts && (
+							<div className="woocommerce-product-edit__empty-state">
+								<p>
+									{ __(
+										'Select one or more variations to edit them here.',
+										'woocommerce'
+									) }
+								</p>
+							</div>
+						) }
+
+					{ isReady && (
+						<VariationEditForm
+							editableFields={ editableFields }
+							onChange={ onChange }
+							selectedVariations={ selectedProducts }
+						/>
+					) }
+				</Drawer.Content>
+
+				{ isReady && (
+					<Drawer.Footer className="woocommerce-product-edit__footer">
+						<Button
+							variant="tertiary"
+							onClick={ closeDrawer }
+							disabled={ isSaving }
+						>
+							{ __( 'Cancel', 'woocommerce' ) }
+						</Button>
+						<Button
+							variant="primary"
+							onClick={ onSave }
+							isBusy={ isSaving }
+							disabled={ isSaving || ! hasEdits }
+						>
+							{ __( 'Save', 'woocommerce' ) }
+						</Button>
+					</Drawer.Footer>
+				) }
+			</Drawer.Popup>
+		</Drawer.Root>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts b/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts
new file mode 100644
index 00000000000..653f078c296
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/form-fields.ts
@@ -0,0 +1,90 @@
+/**
+ * External dependencies
+ */
+import type { FormField } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { VariationEditFieldId } from '../fields/registry';
+
+type VariationFormField = VariationEditFieldId | FormField;
+
+function createFormGroup(
+	id: string,
+	label: string,
+	children: VariationFormField[]
+): FormField {
+	return { id, label, children };
+}
+
+const SALE_SCHEDULE_DATES: FormField = {
+	id: 'sale-schedule-dates',
+	layout: { type: 'row' as const },
+	children: [ 'date_on_sale_from', 'date_on_sale_to' ],
+};
+
+const DIMENSIONS: FormField = {
+	id: 'dimensions',
+	layout: { type: 'row' as const },
+	children: [ 'length', 'width', 'height' ],
+};
+
+const BACKORDERS_ROW: FormField = {
+	id: 'backorders-row',
+	layout: { type: 'row' as const },
+	children: [ 'allow_backorders', 'low_stock_amount' ],
+};
+
+// Unlabeled groups render children in a nested wrapper without a section header,
+// allowing CSS to tighten the gap specifically for that inner wrapper.
+const GENERAL_CHECKBOXES: FormField = {
+	id: 'general-checkboxes',
+	children: [ 'downloadable', 'virtual' ],
+};
+
+const DOWNLOAD_OPTIONS: FormField = {
+	id: 'download-options',
+	children: [ 'download_limit', 'download_expiry' ],
+};
+
+export const VARIATION_FORM_FIELDS: VariationFormField[] = [
+	createFormGroup( 'general-fields', __( 'General', 'woocommerce' ), [
+		'variation_active',
+		GENERAL_CHECKBOXES,
+	] ),
+	createFormGroup( 'price-fields', __( 'Price', 'woocommerce' ), [
+		'regular_price',
+		'sale_price',
+		'schedule_sale',
+		SALE_SCHEDULE_DATES,
+		'cost_of_goods_sold',
+		'tax_class',
+	] ),
+	createFormGroup( 'details-fields', __( 'Details', 'woocommerce' ), [
+		'images',
+		'description',
+	] ),
+	createFormGroup(
+		'downloadable-files-fields',
+		__( 'Downloadable files', 'woocommerce' ),
+		[ 'downloadable_files', DOWNLOAD_OPTIONS ]
+	),
+	createFormGroup( 'inventory-fields', __( 'Inventory', 'woocommerce' ), [
+		'sku',
+		'global_unique_id',
+		'manage_stock',
+		'stock_quantity',
+		'stock',
+		BACKORDERS_ROW,
+	] ),
+	createFormGroup( 'shipping-fields', __( 'Shipping', 'woocommerce' ), [
+		'shipping_class',
+		DIMENSIONS,
+		'weight',
+	] ),
+	createFormGroup( 'attributes-fields', __( 'Attributes', 'woocommerce' ), [
+		'attributes',
+	] ),
+];
diff --git a/packages/js/experimental-products-app/src/variation-view/edit/form.tsx b/packages/js/experimental-products-app/src/variation-view/edit/form.tsx
new file mode 100644
index 00000000000..70d16c052e8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/edit/form.tsx
@@ -0,0 +1,93 @@
+/**
+ * External dependencies
+ */
+import { DataForm } from '@wordpress/dataviews';
+import type { FormField } from '@wordpress/dataviews';
+import { getSetting } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import {
+	buildMergedProductEditData,
+	getProductEditFields,
+} from '../../product-edit/utils';
+import { VARIATION_FORM_FIELDS } from './form-fields';
+import type { VariationEditFieldId } from '../fields/registry';
+
+type VariationFormField = VariationEditFieldId | FormField;
+
+type VariationEditFormProps = {
+	editableFields: ReturnType< typeof getProductEditFields >;
+	onChange: ( changes: Partial< ProductEntityRecord > ) => void;
+	selectedVariations: ProductEntityRecord[];
+};
+
+function pruneFormFields(
+	fields: VariationFormField[],
+	visibleIds: Set< string >
+): VariationFormField[] {
+	return fields.reduce< VariationFormField[] >( ( acc, field ) => {
+		if ( typeof field === 'string' ) {
+			if ( visibleIds.has( field ) ) {
+				acc.push( field );
+			}
+			return acc;
+		}
+		const prunedChildren = pruneFormFields(
+			( field.children ?? [] ) as VariationFormField[],
+			visibleIds
+		);
+		if ( prunedChildren.length > 0 ) {
+			acc.push( { ...field, children: prunedChildren } );
+		}
+		return acc;
+	}, [] );
+}
+
+export function VariationEditForm( {
+	editableFields,
+	onChange,
+	selectedVariations,
+}: VariationEditFormProps ) {
+	const mergedData = buildMergedProductEditData( selectedVariations );
+	const isBulkEdit = selectedVariations.length > 1;
+	const adminSettings = getSetting( 'admin', {} );
+	const isCostOfGoodsSoldFeatureEnabled =
+		// @ts-expect-error - This setting is not typed yet.
+		adminSettings?.wcAdminFeatures?.includes( 'cost-of-goods-sold' );
+	const visibleFields = editableFields.filter( ( field ) => {
+		if (
+			field.id === 'cost_of_goods_sold' &&
+			! isCostOfGoodsSoldFeatureEnabled
+		) {
+			return false;
+		}
+		if ( isBulkEdit && field.id === 'sku' ) {
+			return false;
+		}
+		if ( typeof field.isVisible !== 'function' ) {
+			return true;
+		}
+		return selectedVariations.every( ( v ) => field.isVisible!( v ) );
+	} );
+	const visibleFieldIds = new Set( visibleFields.map( ( f ) => f.id ) );
+
+	const form = {
+		type: 'regular' as const,
+		labelPosition: 'top' as const,
+		fields: pruneFormFields( VARIATION_FORM_FIELDS, visibleFieldIds ),
+	};
+
+	return (
+		<div className="woocommerce-product-edit__form">
+			<DataForm
+				data={ mergedData }
+				fields={ visibleFields }
+				form={ form }
+				onChange={ onChange }
+			/>
+		</div>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields.tsx b/packages/js/experimental-products-app/src/variation-view/fields.tsx
index 9757d6d7e97..bf76fd56cea 100644
--- a/packages/js/experimental-products-app/src/variation-view/fields.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/fields.tsx
@@ -1,6 +1,8 @@
 /**
  * External dependencies
  */
+import { __ } from '@wordpress/i18n';
+import { Badge } from '@wordpress/ui';
 import type { Field } from '@wordpress/dataviews';

 /**
@@ -29,6 +31,28 @@ const REUSED_VARIATION_FIELD_IDS = [
 	'tax_status',
 ] as const satisfies readonly ProductFieldId[];

-export const variationFields = REUSED_VARIATION_FIELD_IDS.map(
-	( id ) => createProductField( id ) as Field< VariationEntityRecord >
-);
+export const variationFields = REUSED_VARIATION_FIELD_IDS.map( ( id ) => {
+	const field = createProductField( id ) as Field< VariationEntityRecord >;
+
+	if ( id === 'name' ) {
+		return {
+			...field,
+			render( { item }: { item: VariationEntityRecord } ) {
+				return (
+					<span className="woocommerce-variation-name">
+						<span className="woocommerce-variation-name__label">
+							{ item.name }
+						</span>
+						{ item.status === 'private' && (
+							<Badge intent="none">
+								{ __( 'Inactive', 'woocommerce' ) }
+							</Badge>
+						) }
+					</span>
+				);
+			},
+		};
+	}
+
+	return field;
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx
new file mode 100644
index 00000000000..3c3f493ffa8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/allow_backorders/field.tsx
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'text',
+	label: __( 'Allow backorders', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.manage_stock,
+	getValue: ( { item } ) => item.backorders ?? 'no',
+	Edit: ( { data, onChange, field } ) => {
+		const options = [
+			{ value: 'no', label: __( 'Do not allow', 'woocommerce' ) },
+			{
+				value: 'notify',
+				label: __( 'Allow but notify customer', 'woocommerce' ),
+			},
+			{ value: 'yes', label: __( 'Allow', 'woocommerce' ) },
+		];
+		const selected = options.find(
+			( o ) => o.value === ( data.backorders ?? 'no' )
+		);
+		return (
+			<SelectControl
+				label={ field.label }
+				value={ selected }
+				items={ options }
+				onValueChange={ ( option ) => {
+					if ( option ) {
+						onChange( {
+							backorders: option.value as 'no' | 'notify' | 'yes',
+						} );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx
new file mode 100644
index 00000000000..885c0075c03
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/attributes/field.tsx
@@ -0,0 +1,137 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/components';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type VariationAttribute = { id: number; name: string; option: string };
+type ParentAttribute = {
+	id: number;
+	name: string;
+	options: string[];
+	variation?: boolean;
+};
+
+// Match by ID for taxonomy attributes (id > 0), by name for local attributes (id === 0).
+function findVariationAttr(
+	variationAttrs: VariationAttribute[],
+	parentAttr: ParentAttribute
+): VariationAttribute | undefined {
+	if ( parentAttr.id !== 0 ) {
+		return variationAttrs.find( ( va ) => va.id === parentAttr.id );
+	}
+	return variationAttrs.find(
+		( va ) => va.name.toLowerCase() === parentAttr.name.toLowerCase()
+	);
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	label: __( 'Attributes', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.parent_id,
+	getValue: ( { item } ) => item.attributes,
+	Edit: ( { data, onChange } ) => {
+		const parentProduct = useSelect(
+			( select ) => {
+				if ( ! data.parent_id ) {
+					return null;
+				}
+				return select( coreStore ).getEditedEntityRecord(
+					'root',
+					'product',
+					data.parent_id
+				) as ProductEntityRecord | false | undefined;
+			},
+			[ data.parent_id ]
+		);
+
+		const allParentAttributes = parentProduct
+			? ( ( parentProduct.attributes ??
+					[] ) as unknown as ParentAttribute[] )
+			: [];
+
+		// Only show attributes marked as variation attributes.
+		const parentAttributes = allParentAttributes.filter(
+			( attr ) => attr.variation !== false
+		);
+
+		const variationAttributes = ( data.attributes ??
+			[] ) as unknown as VariationAttribute[];
+
+		if ( parentAttributes.length === 0 ) {
+			return null;
+		}
+
+		const handleChange = (
+			attrId: number,
+			attrName: string,
+			newOption: string
+		) => {
+			const updated = variationAttributes.map( ( attr ) => {
+				const matches =
+					attrId !== 0
+						? attr.id === attrId
+						: attr.name.toLowerCase() === attrName.toLowerCase();
+				return matches ? { ...attr, option: newOption } : attr;
+			} );
+			onChange( {
+				attributes:
+					updated as unknown as ProductEntityRecord[ 'attributes' ],
+			} );
+		};
+
+		const isOdd = parentAttributes.length % 2 !== 0;
+
+		return (
+			<div
+				style={ {
+					display: 'grid',
+					gridTemplateColumns: '1fr 1fr',
+					gap: '16px',
+				} }
+			>
+				{ parentAttributes.map( ( attr, index ) => {
+					const selected = findVariationAttr(
+						variationAttributes,
+						attr
+					);
+					const isLastOdd =
+						isOdd && index === parentAttributes.length - 1;
+					const options = attr.options.map( ( opt ) => ( {
+						label: opt,
+						value: opt,
+					} ) );
+
+					return (
+						<div
+							key={ `${ attr.id }-${ attr.name }` }
+							style={
+								isLastOdd ? { gridColumn: '1 / -1' } : undefined
+							}
+						>
+							<SelectControl
+								__nextHasNoMarginBottom
+								__next40pxDefaultSize
+								label={ attr.name }
+								value={ selected?.option ?? '' }
+								options={ options }
+								onChange={ ( value ) =>
+									handleChange( attr.id, attr.name, value )
+								}
+							/>
+						</div>
+					);
+				} ) }
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx
new file mode 100644
index 00000000000..51be9972f3f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/currency-input.tsx
@@ -0,0 +1,119 @@
+/* eslint-disable @wordpress/no-unsafe-wp-apis -- Currency inputs use wrappers for the private ValidatedInputControl API. */
+/**
+ * External dependencies
+ */
+import {
+	privateApis,
+	__experimentalInputControlPrefixWrapper as InputControlPrefixWrapper,
+	__experimentalInputControlSuffixWrapper as InputControlSuffixWrapper,
+} from '@wordpress/components';
+
+import type { DataFormControlProps } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../../lock-unlock';
+
+import type { ProductEntityRecord } from '../types';
+
+import { getCurrencyObject } from '../utils/currency';
+
+const { ValidatedInputControl } = unlock( privateApis );
+
+const { symbol, symbolPosition, precision } = getCurrencyObject();
+const isCurrencyLeft =
+	symbolPosition === 'left' || symbolPosition === 'left_space';
+// Step matches store decimal precision (e.g. precision=2 → step=0.01).
+const step = Math.pow( 10, -precision );
+
+type CurrencyField = 'regular_price' | 'sale_price';
+
+type CurrencyControlProps = {
+	id: string;
+	label: string;
+	value: string | number;
+	onChange: ( newValue: string ) => void;
+	customValidity?: NonNullable<
+		DataFormControlProps< ProductEntityRecord >[ 'validity' ]
+	>[ 'custom' ];
+	hideLabelFromVision?: boolean;
+	placeholder?: string;
+	disabled?: boolean;
+};
+
+export function CurrencyControl( {
+	id,
+	label,
+	value,
+	onChange,
+	customValidity,
+	hideLabelFromVision,
+	placeholder,
+	disabled,
+}: CurrencyControlProps ) {
+	return (
+		// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- ValidatedInputControl is a private API
+		<ValidatedInputControl
+			id={ id }
+			label={ label }
+			hideLabelFromVision={ hideLabelFromVision }
+			value={ value }
+			placeholder={ placeholder }
+			disabled={ disabled }
+			onChange={ onChange }
+			type="number"
+			min={ 0 }
+			step={ step }
+			customValidity={ customValidity }
+			prefix={
+				isCurrencyLeft ? (
+					<InputControlPrefixWrapper>
+						{ symbol }
+					</InputControlPrefixWrapper>
+				) : undefined
+			}
+			suffix={
+				! isCurrencyLeft ? (
+					<InputControlSuffixWrapper>
+						{ symbol }
+					</InputControlSuffixWrapper>
+				) : undefined
+			}
+		/>
+	);
+}
+
+/**
+ * Shared Edit component for currency fields.
+ * Renders a number input with min=0 and currency prefix/suffix.
+ *
+ * @param root0          Props from DataForm.
+ * @param root0.data     Current product entity record.
+ * @param root0.field    Normalized field definition.
+ * @param root0.onChange Callback to update entity values.
+ * @param root0.validity Per-rule validation state from useFormValidity.
+ */
+export function CurrencyInput( {
+	data,
+	field,
+	onChange,
+	validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	if ( field.id !== 'regular_price' && field.id !== 'sale_price' ) {
+		return null;
+	}
+	const fieldId: CurrencyField = field.id;
+
+	return (
+		<CurrencyControl
+			id={ `currency-input-${ fieldId }` }
+			label={ field.label }
+			value={ data[ fieldId ] ?? '' }
+			onChange={ ( newValue: string ) => {
+				onChange( { [ fieldId ]: newValue } );
+			} }
+			customValidity={ validity?.custom }
+		/>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx
new file mode 100644
index 00000000000..ef2a5896217
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/date-picker.tsx
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { InputControl } from '@wordpress/ui';
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type DateFieldKey = 'date_on_sale_from' | 'date_on_sale_to';
+
+type DatePickerProps = {
+	data: ProductEntityRecord;
+	onChange: ( value: Partial< ProductEntityRecord > ) => void;
+	field: {
+		id?: string;
+		label?: string;
+	};
+	fieldKey: DateFieldKey;
+	min?: Date;
+};
+
+export function parseDateTimeLocal( value: string ) {
+	if ( ! value ) {
+		return null;
+	}
+
+	const parsedDate = new Date( value );
+
+	if ( Number.isNaN( parsedDate.getTime() ) ) {
+		return null;
+	}
+
+	return parsedDate;
+}
+
+export function formatDateTimeLocal( date: Date ) {
+	const year = date.getFullYear();
+	const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
+	const day = String( date.getDate() ).padStart( 2, '0' );
+	const hours = String( date.getHours() ).padStart( 2, '0' );
+	const minutes = String( date.getMinutes() ).padStart( 2, '0' );
+
+	return `${ year }-${ month }-${ day }T${ hours }:${ minutes }`;
+}
+
+export function DatePicker( {
+	data,
+	onChange,
+	field,
+	fieldKey,
+	min,
+}: DatePickerProps ) {
+	return (
+		<InputControl
+			label={ field.label ?? '' }
+			type="datetime-local"
+			value={ data[ fieldKey ] || '' }
+			min={ min ? formatDateTimeLocal( min ) : undefined }
+			onChange={ ( event ) =>
+				onChange( {
+					[ fieldKey ]: event.target.value || null,
+				} as Partial< ProductEntityRecord > )
+			}
+		/>
+	);
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx
new file mode 100644
index 00000000000..23982bbe7a6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/dimension.tsx
@@ -0,0 +1,195 @@
+/**
+ * External dependencies
+ */
+import { useEntityRecord, store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import type { Field } from '@wordpress/dataviews';
+import { InputControl, InputLayout } from '@wordpress/ui';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, SettingsEntityRecord } from '../types';
+
+export type DimensionKey = 'height' | 'width' | 'length';
+
+export function isDimensionVisible( item: ProductEntityRecord ) {
+	const isSellableInstance =
+		( item.type === 'simple' && ! item.parent_id ) ||
+		( item.type === 'variable' && ! item.parent_id ) ||
+		item.type === 'variation' ||
+		Boolean( item.parent_id );
+
+	return ! item.virtual && ( isSellableInstance || item.downloadable );
+}
+
+export const createVariationDimensionField = (
+	key: DimensionKey
+): Partial< Field< ProductEntityRecord > > => {
+	return {
+		Edit: ( { data, onChange, field } ) => {
+			const {
+				record: storeProductsSettings,
+				isResolving: storeProductsSettingsResolving,
+			} = useEntityRecord< SettingsEntityRecord >(
+				'root',
+				'settings',
+				'products'
+			);
+
+			const parentProduct = useSelect(
+				( select ) => {
+					if ( ! data.parent_id ) {
+						return undefined;
+					}
+					return select( coreStore ).getEditedEntityRecord(
+						'root',
+						'product',
+						data.parent_id
+					) as unknown as ProductEntityRecord | undefined;
+				},
+				[ data.parent_id ]
+			);
+
+			if ( storeProductsSettingsResolving ) {
+				return null;
+			}
+
+			const dimensionUnit =
+				storeProductsSettings?.values?.woocommerce_dimension_unit;
+			const parentValue = parentProduct?.dimensions?.[ key ];
+			const placeholder = parentValue ? String( parentValue ) : undefined;
+
+			return (
+				<InputControl
+					label={ field.label }
+					value={ data.dimensions?.[ key ] ?? '' }
+					placeholder={ placeholder }
+					onChange={ ( event ) => {
+						onChange( {
+							dimensions: {
+								...( data.dimensions ?? {} ),
+								[ key ]: event.target.value,
+							},
+						} );
+					} }
+					type="number"
+					min={ 0 }
+					step="any"
+					suffix={
+						<InputLayout.Slot padding="minimal">
+							{ dimensionUnit }
+						</InputLayout.Slot>
+					}
+				/>
+			);
+		},
+	};
+};
+
+export const createVariationWeightField = (): Partial<
+	Field< ProductEntityRecord >
+> => {
+	return {
+		Edit: ( { data, onChange, field } ) => {
+			const {
+				record: storeProductsSettings,
+				isResolving: storeProductsSettingsResolving,
+			} = useEntityRecord< SettingsEntityRecord >(
+				'root',
+				'settings',
+				'products'
+			);
+
+			const parentProduct = useSelect(
+				( select ) => {
+					if ( ! data.parent_id ) {
+						return undefined;
+					}
+					return select( coreStore ).getEditedEntityRecord(
+						'root',
+						'product',
+						data.parent_id
+					) as unknown as ProductEntityRecord | undefined;
+				},
+				[ data.parent_id ]
+			);
+
+			if ( storeProductsSettingsResolving ) {
+				return null;
+			}
+
+			const weightUnit =
+				storeProductsSettings?.values?.woocommerce_weight_unit;
+			const parentValue = parentProduct?.weight;
+			const placeholder = parentValue ? String( parentValue ) : undefined;
+
+			return (
+				<InputControl
+					label={ field.label }
+					value={ data.weight }
+					placeholder={ placeholder }
+					onChange={ ( event ) =>
+						onChange( { weight: event.target.value } )
+					}
+					type="number"
+					min={ 0 }
+					step="any"
+					suffix={
+						<InputLayout.Slot padding="minimal">
+							{ weightUnit }
+						</InputLayout.Slot>
+					}
+				/>
+			);
+		},
+	};
+};
+
+export const createDimensionField = (
+	key: DimensionKey
+): Partial< Field< ProductEntityRecord > > => {
+	return {
+		isVisible: isDimensionVisible,
+		Edit: ( { data, onChange, field } ) => {
+			const {
+				record: storeProductsSettings,
+				isResolving: storeProductsSettingsResolving,
+			} = useEntityRecord< SettingsEntityRecord >(
+				'root',
+				'settings',
+				'products'
+			);
+
+			if ( storeProductsSettingsResolving ) {
+				return null;
+			}
+
+			const dimensionUnit =
+				storeProductsSettings?.values?.woocommerce_dimension_unit;
+
+			return (
+				<InputControl
+					label={ field.label }
+					value={ data.dimensions?.[ key ] ?? '' }
+					onChange={ ( event ) => {
+						onChange( {
+							dimensions: {
+								...( data.dimensions ?? {} ),
+								[ key ]: event.target.value,
+							},
+						} );
+					} }
+					type="number"
+					min={ 0 }
+					step="any"
+					suffix={
+						<InputLayout.Slot padding="minimal">
+							{ dimensionUnit }
+						</InputLayout.Slot>
+					}
+				/>
+			);
+		},
+	};
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx
new file mode 100644
index 00000000000..a21ce73ec11
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/index.tsx
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { Button } from '@wordpress/components';
+import { close } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { isValidElement } from '@wordpress/element';
+import { Stack } from '@wordpress/ui';
+
+type Item = {
+	id: number | string;
+	title: string;
+	thumbnail?: string | JSX.Element;
+	meta?: string;
+	altText?: string;
+};
+
+interface ListItemProps {
+	items: Item[];
+	onRemove?: ( item: Item ) => void;
+	showRemoveButton?: boolean;
+}
+
+export const ListItem = ( {
+	items,
+	onRemove,
+	showRemoveButton = true,
+}: ListItemProps ) => {
+	return (
+		<Stack direction="column">
+			{ items.map( ( item ) => (
+				<Stack
+					key={ item.id }
+					direction="row"
+					align="center"
+					justify="space-between"
+					className="woocommerce-list-item"
+					gap={ 'md' }
+				>
+					{ item.thumbnail && (
+						<div className="woocommerce-list-item__thumbnail">
+							{ typeof item.thumbnail === 'string' && (
+								<img
+									src={ item.thumbnail }
+									alt={ item.altText ?? '' }
+									className="woocommerce-list-item__thumbnail-image"
+								/>
+							) }
+							{ isValidElement( item.thumbnail ) &&
+								item.thumbnail }
+						</div>
+					) }
+					<Stack
+						className="woocommerce-list-item__info"
+						direction="column"
+					>
+						<div className="woocommerce-list-item__title">
+							{ item.title }
+						</div>
+						{ item.meta && (
+							<div className="woocommerce-list-item__meta">
+								{ item.meta }
+							</div>
+						) }
+					</Stack>
+					{ showRemoveButton && onRemove && (
+						<Button
+							icon={ close }
+							variant="tertiary"
+							iconSize={ 16 }
+							onClick={ () => onRemove( item ) }
+							aria-label={ __( 'Remove item', 'woocommerce' ) }
+							className="woocommerce-list-item__remove-button"
+						/>
+					) }
+				</Stack>
+			) ) }
+		</Stack>
+	);
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss
new file mode 100644
index 00000000000..888194ea7fc
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/list-item/style.scss
@@ -0,0 +1,46 @@
+.woocommerce-list-item {
+	padding: 12px 0;
+	border-bottom: 1px solid #e0e0e0;
+	width: 100%;
+
+	&:last-of-type {
+		border-bottom: none;
+	}
+
+	&__thumbnail {
+		width: 48px;
+		height: 48px;
+		border-radius: 4px;
+		flex-shrink: 0;
+
+		&-image {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+			border-radius: 4px;
+		}
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__title {
+		font-size: 14px;
+		font-weight: 500;
+		color: #1e1e1e;
+	}
+
+	&__meta {
+		font-size: 12px;
+		color: #757575;
+		font-weight: 500;
+	}
+
+	&__remove-button {
+		color: #757575;
+		padding: 8px;
+		min-width: auto;
+	}
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx b/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx
new file mode 100644
index 00000000000..4eae8aed9c4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/components/product-status-badge.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge } from '@wordpress/ui';
+import type { ProductStatus } from '@woocommerce/data';
+
+type BadgeStatusConfig = {
+	label: string;
+	intent?: React.ComponentProps< typeof Badge >[ 'intent' ];
+};
+
+const statuses = {
+	draft: {
+		label: __( 'Draft', 'woocommerce' ),
+		intent: 'draft',
+	},
+	publish: {
+		label: __( 'Published', 'woocommerce' ),
+		intent: 'stable',
+	},
+	trash: {
+		label: __( 'Trash', 'woocommerce' ),
+		intent: 'none',
+	},
+	'auto-draft': {
+		label: __( 'Draft', 'woocommerce' ),
+		intent: 'draft',
+	},
+	deleted: {
+		label: __( 'Deleted', 'woocommerce' ),
+		intent: 'none',
+	},
+	pending: {
+		label: __( 'Pending review', 'woocommerce' ),
+		intent: 'informational',
+	},
+	private: {
+		label: __( 'Private', 'woocommerce' ),
+		intent: 'none',
+	},
+	future: {
+		label: __( 'Scheduled', 'woocommerce' ),
+		intent: 'none',
+	},
+	any: {
+		label: __( 'Any', 'woocommerce' ),
+		intent: 'none',
+	},
+} satisfies Record< ProductStatus, BadgeStatusConfig >;
+
+export const ProductStatusBadge = ( { status }: { status: ProductStatus } ) => {
+	const statusData = statuses[ status ];
+
+	if ( ! statusData ) {
+		return <Badge intent="none">{ __( 'Unknown', 'woocommerce' ) }</Badge>;
+	}
+
+	return <Badge intent={ statusData.intent }>{ statusData.label }</Badge>;
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx
new file mode 100644
index 00000000000..d698f2daf1d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/cost_of_goods_sold/field.tsx
@@ -0,0 +1,92 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { validatePrice, toNumberOrNaN } from '../utils/price';
+import { formatCurrency, getCurrencyObject } from '../utils/currency';
+import { CurrencyControl } from '../components/currency-input';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Cost of goods', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	isVisible: ( item: ProductEntityRecord ) =>
+		item.cost_of_goods_sold !== undefined,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+function getDefinedCostValue( item: ProductEntityRecord ) {
+	return item.cost_of_goods_sold?.values?.[ 0 ]?.defined_value;
+}
+
+function CostOfGoodsSoldInput( {
+	data,
+	field,
+	hideLabelFromVision,
+	onChange,
+	validity,
+}: DataFormControlProps< ProductEntityRecord > ) {
+	const costOfGoodsSold = data.cost_of_goods_sold ?? {};
+	const disabled = field.isDisabled( { item: data, field } );
+	const [ firstValue = {}, ...remainingValues ] =
+		costOfGoodsSold.values ?? [];
+
+	return (
+		<CurrencyControl
+			id={ `currency-input-${ field.id }` }
+			label={ field.label }
+			hideLabelFromVision={ hideLabelFromVision }
+			value={ getDefinedCostValue( data ) ?? '' }
+			placeholder={ field.placeholder }
+			onChange={ ( newValue: string ) => {
+				onChange( {
+					cost_of_goods_sold: {
+						...costOfGoodsSold,
+						values:
+							newValue === ''
+								? []
+								: [
+										{
+											...firstValue,
+											defined_value: newValue,
+										},
+										...remainingValues,
+								  ],
+					},
+				} );
+			} }
+			customValidity={ validity?.custom }
+			disabled={ disabled }
+		/>
+	);
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => getDefinedCostValue( item ),
+	getValueFormatted: ( { item } ) => {
+		const value = getDefinedCostValue( item );
+		const numberValue = toNumberOrNaN( value );
+
+		if (
+			value === undefined ||
+			value === null ||
+			Number.isNaN( numberValue )
+		) {
+			return '—';
+		}
+
+		return formatCurrency( numberValue, getCurrencyObject().code );
+	},
+	isValid: {
+		custom: ( item ) => validatePrice( getDefinedCostValue( item ) ),
+	},
+	Edit: CostOfGoodsSoldInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx
new file mode 100644
index 00000000000..342f1eaace4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_from/field.tsx
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import { useCallback, useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+	DatePicker,
+	parseDateTimeLocal,
+	formatDateTimeLocal,
+} from '../components/date-picker';
+
+const fieldDefinition = {
+	type: 'datetime',
+	label: __( 'Start sale on', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isVisible: ( product ) => {
+		return !! product.date_on_sale_to || !! product.date_on_sale_from;
+	},
+	Edit: ( { data, onChange, field } ) => {
+		const today = useMemo( () => {
+			const d = new Date();
+			d.setHours( 0, 0, 0, 0 );
+			return d;
+		}, [] );
+
+		const handleChange = useCallback(
+			( value: { date_on_sale_from?: string | null } ) => {
+				const newStart = value.date_on_sale_from;
+				const currentEnd = data.date_on_sale_to;
+
+				if (
+					typeof newStart !== 'string' ||
+					! newStart ||
+					typeof currentEnd !== 'string' ||
+					! currentEnd
+				) {
+					onChange( value );
+					return;
+				}
+
+				const startDate = parseDateTimeLocal( newStart );
+				const endDate = parseDateTimeLocal( currentEnd );
+
+				if (
+					startDate &&
+					endDate &&
+					startDate.getTime() >= endDate.getTime()
+				) {
+					const newEndDate = new Date( startDate );
+					newEndDate.setDate( newEndDate.getDate() + 1 );
+
+					onChange( {
+						...value,
+						date_on_sale_to: formatDateTimeLocal( newEndDate ),
+					} );
+					return;
+				}
+
+				onChange( value );
+			},
+			[ data.date_on_sale_to, onChange ]
+		);
+
+		return (
+			<DatePicker
+				data={ data }
+				onChange={ handleChange }
+				field={ field }
+				fieldKey="date_on_sale_from"
+				min={ today }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx
new file mode 100644
index 00000000000..2780ace531a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/date_on_sale_to/field.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { DatePicker, parseDateTimeLocal } from '../components/date-picker';
+
+const fieldDefinition = {
+	type: 'datetime',
+	label: __( 'End sale on', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isVisible: ( product ) => {
+		return !! product.date_on_sale_to || !! product.date_on_sale_from;
+	},
+	Edit: ( { data, onChange, field } ) => {
+		const dateOnSaleFrom = useMemo(
+			() =>
+				typeof data?.date_on_sale_from === 'string' &&
+				data.date_on_sale_from
+					? parseDateTimeLocal( data.date_on_sale_from )
+					: null,
+			[ data?.date_on_sale_from ]
+		);
+
+		const minDate = useMemo( () => {
+			if ( dateOnSaleFrom ) {
+				const min = new Date( dateOnSaleFrom );
+				min.setMinutes( min.getMinutes() + 1 );
+				return min;
+			}
+			const today = new Date();
+			today.setHours( 0, 0, 0, 0 );
+			return today;
+		}, [ dateOnSaleFrom ] );
+
+		return (
+			<DatePicker
+				data={ data }
+				onChange={ onChange }
+				field={ field }
+				fieldKey="date_on_sale_to"
+				min={ minDate }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx
new file mode 100644
index 00000000000..823c647f552
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/description/field.tsx
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextareaControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { convertHtmlToPlainText } from '../../../utilites';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Description', 'woocommerce' ),
+	description: __(
+		'Share the full story—include product details, features, and benefits to help customers decide.',
+		'woocommerce'
+	),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => convertHtmlToPlainText( item.description ),
+	Edit: ( { data, onChange, field } ) => {
+		return (
+			<TextareaControl
+				label={ field.label }
+				rows={ 4 }
+				value={ convertHtmlToPlainText( data.description || '' ) }
+				onChange={ ( value ) => onChange( { description: value } ) }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx
new file mode 100644
index 00000000000..83606978633
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/download_expiry/field.tsx
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import { InputControl, InputLayout } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'integer',
+	label: __( 'Download expiry', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.downloadable,
+	getValue: ( { item } ) => item.download_expiry ?? -1,
+	Edit: ( { data, onChange, field } ) => {
+		const hasExpiry = ( data.download_expiry ?? -1 ) !== -1;
+		return (
+			<div
+				className="woocommerce-fields-field__download-expiry"
+				style={ {
+					display: 'flex',
+					flexDirection: 'column',
+					gap: '12px',
+				} }
+			>
+				<CheckboxControl
+					__nextHasNoMarginBottom
+					label={ __( 'Expire download link', 'woocommerce' ) }
+					checked={ hasExpiry }
+					onChange={ ( checked ) => {
+						onChange( {
+							download_expiry: checked ? 1 : -1,
+						} );
+					} }
+				/>
+				{ hasExpiry && (
+					<InputControl
+						label={ field.label }
+						type="number"
+						min={ 1 }
+						value={ String( data.download_expiry ) }
+						onChange={ ( event ) => {
+							const parsed = parseInt( event.target.value, 10 );
+							onChange( {
+								download_expiry:
+									Number.isNaN( parsed ) || parsed < 1
+										? 1
+										: parsed,
+							} );
+						} }
+						suffix={
+							<InputLayout.Slot padding="minimal">
+								{ __( 'days', 'woocommerce' ) }
+							</InputLayout.Slot>
+						}
+					/>
+				) }
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx
new file mode 100644
index 00000000000..55ed074a82b
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/download_limit/field.tsx
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl, TextControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'integer',
+	label: __( 'Download limit', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.downloadable,
+	getValue: ( { item } ) => item.download_limit ?? -1,
+	Edit: ( { data, onChange, field } ) => {
+		const isLimited = ( data.download_limit ?? -1 ) !== -1;
+		return (
+			<div
+				className="woocommerce-fields-field__download-limit"
+				style={ {
+					display: 'flex',
+					flexDirection: 'column',
+					gap: '12px',
+					marginBottom: isLimited ? '8px' : 0,
+				} }
+			>
+				<CheckboxControl
+					__nextHasNoMarginBottom
+					label={ __(
+						'Limit downloads per customer',
+						'woocommerce'
+					) }
+					checked={ isLimited }
+					onChange={ ( checked ) => {
+						onChange( {
+							download_limit: checked ? 1 : -1,
+						} );
+					} }
+				/>
+				{ isLimited && (
+					<TextControl
+						__nextHasNoMarginBottom
+						__next40pxDefaultSize
+						label={ field.label }
+						type="number"
+						min={ 1 }
+						value={ String( data.download_limit ) }
+						onChange={ ( next ) => {
+							const parsed = parseInt( next, 10 );
+							onChange( {
+								download_limit: Number.isNaN( parsed )
+									? 1
+									: parsed,
+							} );
+						} }
+					/>
+				) }
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx
new file mode 100644
index 00000000000..c4e5f929340
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable/field.tsx
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'boolean',
+	label: __( 'Downloadable', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	getValue: ( { item } ) => item.downloadable ?? false,
+	Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx
new file mode 100644
index 00000000000..a265b293731
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/field.tsx
@@ -0,0 +1,207 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Button, TextControl } from '@wordpress/components';
+import { trash } from '@wordpress/icons';
+import { useCallback, useRef, useState } from '@wordpress/element';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import type { UploadedItem } from './utils';
+
+declare global {
+	interface Window {
+		wp?: {
+			media?: ( config: {
+				title?: string;
+				button?: { text?: string };
+				multiple?: boolean;
+			} ) => WPMediaFrame;
+		};
+	}
+}
+
+interface WPMediaFrame {
+	on( event: 'select', callback: () => void ): WPMediaFrame;
+	open(): void;
+	state(): {
+		get( key: 'selection' ): {
+			first(): {
+				toJSON(): {
+					url: string;
+					title: string;
+					filename: string;
+					id: number;
+				};
+			};
+		};
+	};
+}
+
+function openMediaLibrary(
+	onSelect: ( url: string, name: string, id?: string ) => void
+) {
+	if ( ! window.wp?.media ) {
+		return;
+	}
+	const frame = window.wp.media( {
+		title: __( 'Choose a file', 'woocommerce' ),
+		button: { text: __( 'Choose file', 'woocommerce' ) },
+		multiple: false,
+	} );
+	frame.on( 'select', () => {
+		const attachment = frame.state().get( 'selection' ).first().toJSON();
+		onSelect(
+			attachment.url,
+			attachment.title || attachment.filename || '',
+			attachment.id ? String( attachment.id ) : undefined
+		);
+	} );
+	frame.open();
+}
+
+function DownloadableFilesEdit( {
+	data,
+	onChange,
+}: {
+	data: ProductEntityRecord;
+	onChange: ( changes: Partial< ProductEntityRecord > ) => void;
+} ) {
+	type KeyedItem = UploadedItem & { _key: number };
+
+	const savedDownloads = ( data.downloads ?? [] ) as UploadedItem[];
+	const keyCounter = useRef( Math.max( savedDownloads.length, 1 ) );
+
+	// State is initialised from saved data once per mount.
+	// The drawer unmounts on close/cancel, so re-opening always starts fresh.
+	const [ downloads, setDownloads ] = useState< KeyedItem[] >(
+		savedDownloads.length > 0
+			? savedDownloads.map( ( d, i ) => ( { ...d, _key: i } ) )
+			: [ { file: '', name: '', _key: 0 } ]
+	);
+
+	const commit = useCallback(
+		( next: KeyedItem[] ) => {
+			setDownloads( next );
+			// Only persist entries that have a URL.
+			onChange( {
+				downloads: next
+					.filter( ( d ) => d.file.trim() !== '' )
+					.map( ( { _key: _, ...d } ) => ( {
+						...d,
+						id: d.id ?? '',
+					} ) ),
+			} );
+		},
+		[ onChange ]
+	);
+
+	const updateEntry = ( index: number, changes: Partial< KeyedItem > ) => {
+		commit(
+			downloads.map( ( d, i ) =>
+				i === index ? { ...d, ...changes } : d
+			)
+		);
+	};
+
+	const removeEntry = ( index: number ) => {
+		commit( downloads.filter( ( _, i ) => i !== index ) );
+	};
+
+	const canDelete = downloads.length > 1;
+
+	return (
+		<div className="woocommerce-fields-downloadable-files">
+			{ downloads.map( ( download, index ) => (
+				<div
+					key={ download._key }
+					className="woocommerce-fields-downloadable-files__entry"
+				>
+					{ index > 0 && (
+						<hr className="woocommerce-fields-downloadable-files__separator" />
+					) }
+
+					<div className="woocommerce-fields-downloadable-files__url-row">
+						<TextControl
+							__nextHasNoMarginBottom
+							__next40pxDefaultSize
+							label={ __( 'URL', 'woocommerce' ) }
+							type="url"
+							placeholder="https://"
+							value={ download.file }
+							onChange={ ( val ) =>
+								updateEntry( index, { file: val } )
+							}
+						/>
+						<Button
+							className="woocommerce-fields-downloadable-files__choose-button"
+							variant="secondary"
+							__next40pxDefaultSize
+							onClick={ () =>
+								openMediaLibrary( ( url, name, id ) => {
+									updateEntry( index, {
+										file: url,
+										name: download.name || name,
+										...( id ? { id } : {} ),
+									} );
+								} )
+							}
+						>
+							{ __( 'Choose file', 'woocommerce' ) }
+						</Button>
+					</div>
+
+					<div className="woocommerce-fields-downloadable-files__name-row">
+						<TextControl
+							__nextHasNoMarginBottom
+							__next40pxDefaultSize
+							label={ __( 'Name', 'woocommerce' ) }
+							value={ download.name }
+							onChange={ ( val ) =>
+								updateEntry( index, { name: val } )
+							}
+						/>
+						{ canDelete && (
+							<Button
+								className="woocommerce-fields-downloadable-files__delete-button"
+								variant="secondary"
+								__next40pxDefaultSize
+								icon={ trash }
+								label={ __( 'Remove file', 'woocommerce' ) }
+								onClick={ () => removeEntry( index ) }
+							/>
+						) }
+					</div>
+				</div>
+			) ) }
+
+			<Button
+				className="woocommerce-fields-downloadable-files__add-button"
+				variant="tertiary"
+				__next40pxDefaultSize
+				onClick={ () =>
+					commit( [
+						...downloads,
+						{ file: '', name: '', _key: keyCounter.current++ },
+					] )
+				}
+			>
+				{ __( '+ Add file', 'woocommerce' ) }
+			</Button>
+		</div>
+	);
+}
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	label: __( 'Downloadable files', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.downloadable,
+	getValue: ( { item } ) => item.downloads ?? [],
+	Edit: DownloadableFilesEdit,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss
new file mode 100644
index 00000000000..de47013e48e
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/style.scss
@@ -0,0 +1,56 @@
+.woocommerce-fields-field__download-limit,
+.woocommerce-fields-field__download-expiry {
+	display: flex;
+	flex-direction: column;
+	gap: 8px;
+}
+
+.woocommerce-fields-downloadable-files {
+	display: flex;
+	flex-direction: column;
+
+	&__entry {
+		display: flex;
+		flex-direction: column;
+		gap: 16px;
+	}
+
+	&__separator {
+		border: none;
+		border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #ddd);
+		margin: 24px 0 8px;
+	}
+
+	&__url-row,
+	&__name-row {
+		display: flex;
+		align-items: flex-end;
+		gap: var(--wpds-dimension-gap-lg, 16px);
+
+		// The TextControl wrapper takes all available space.
+		> *:first-child {
+			flex: 1;
+			min-width: 0;
+		}
+	}
+
+	&__choose-button.components-button {
+		flex-shrink: 0;
+		align-self: flex-end;
+	}
+
+	&__delete-button.components-button {
+		flex-shrink: 0;
+		align-self: flex-end;
+		width: 40px;
+		justify-content: center;
+		// Override the accent color so the secondary button uses black instead of blue.
+		--wp-components-color-accent: var(--wp-components-color-foreground, #1e1e1e);
+		--wp-components-color-accent-darker-10: #000;
+	}
+
+	&__add-button.components-button {
+		align-self: flex-start;
+		margin-top: 8px;
+	}
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx
new file mode 100644
index 00000000000..c278ee5bbfd
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/downloadable_files/utils.tsx
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { Icon } from '@wordpress/ui';
+import { file as FileIcon } from '@wordpress/icons';
+/**
+ * Internal dependencies
+ */
+import { ProductEntityRecord } from '../types';
+
+export type UploadedItem = Omit<
+	ProductEntityRecord[ 'downloads' ][ 0 ],
+	'id'
+> & {
+	id?: string;
+};
+
+export type ItemToUpload = UploadedItem & { type: string };
+
+export const getLastPathFromUrl = ( url: string ) => {
+	try {
+		const urlObj = new URL( url );
+		return urlObj.pathname.split( '/' ).pop() ?? url;
+	} catch {
+		const parts = url.split( '/' );
+		return parts[ parts.length - 1 ];
+	}
+};
+
+export const GenericThumbnail = () => (
+	<Icon style={ { width: '100%', height: '100%' } } icon={ FileIcon } />
+);
+
+export const isImageFromLink = ( link: string ) => {
+	const normalizedLink = link.toLowerCase();
+	return [ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg' ].some(
+		( extension ) => normalizedLink.endsWith( extension )
+	);
+};
+
+export const isItemToUpload = (
+	download: UploadedItem | ItemToUpload
+): download is ItemToUpload => {
+	return 'type' in download;
+};
+
+export const isUploadedItem = (
+	download: UploadedItem | ItemToUpload
+): download is UploadedItem => {
+	return ! ( 'type' in download );
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx
new file mode 100644
index 00000000000..1aea8192671
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/global_unique_id/field.tsx
@@ -0,0 +1,31 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'GTIN, UPC, EAN, ISBN', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => item.global_unique_id ?? '',
+	render: ( { item } ) => {
+		const value = item.global_unique_id;
+
+		if ( ! value ) {
+			return <span>{ '—' }</span>;
+		}
+
+		return <span>{ value }</span>;
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx
new file mode 100644
index 00000000000..cefcb7f0090
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/height/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Height', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	...createVariationDimensionField( 'height' ),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx
new file mode 100644
index 00000000000..b2068b9ba46
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/images/field.tsx
@@ -0,0 +1,299 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useMemo, useCallback, useEffect, useState } from '@wordpress/element';
+import { BaseControl } from '@wordpress/components';
+import { IconButton } from '@wordpress/ui';
+import clsx from 'clsx';
+import type { Field } from '@wordpress/dataviews';
+import { upload, closeSmall, dragHandle } from '@wordpress/icons';
+import { MediaUpload } from '@wordpress/media-utils';
+import { DragDropProvider, type DragEndEvent } from '@dnd-kit/react';
+import { isSortable, useSortable } from '@dnd-kit/react/sortable';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type Attachment = {
+	id: number;
+	url?: string;
+	alt?: string;
+	title?: string;
+	date?: string;
+	date_gmt?: string;
+	modified?: string;
+	modified_gmt?: string;
+	sizes?: Record< string, { source_url?: string; url?: string } >;
+	media_details?: {
+		sizes?: Record< string, { source_url?: string; url?: string } >;
+	};
+};
+
+const toProductImage = (
+	att: Attachment
+): ProductEntityRecord[ 'images' ][ number ] => {
+	const sizes = att.media_details?.sizes || att.sizes;
+	const thumbnailUrl =
+		sizes?.woocommerce_thumbnail?.source_url ||
+		sizes?.woocommerce_thumbnail?.url ||
+		sizes?.thumbnail?.source_url ||
+		sizes?.thumbnail?.url ||
+		'';
+
+	return {
+		id: att.id,
+		src: att.url || '',
+		alt: att.alt || '',
+		name: att.title || '',
+		thumbnail: thumbnailUrl,
+		date_created: att.date || '',
+		date_created_gmt: att.date_gmt || '',
+		date_modified: att.modified || '',
+		date_modified_gmt: att.modified_gmt || '',
+	};
+};
+
+interface SortableImageProps {
+	image: ProductEntityRecord[ 'images' ][ number ];
+	index: number;
+	alt: string;
+	onRemove: () => void;
+	showDragHandle: boolean;
+}
+
+function SortableImage( {
+	image,
+	index,
+	alt,
+	onRemove,
+	showDragHandle,
+}: SortableImageProps ) {
+	const previewSrc = image.thumbnail || image.src;
+	const { ref, handleRef, isDragging } = useSortable( {
+		id: image.id,
+		index,
+		disabled: ! showDragHandle,
+	} );
+
+	const stopPropagation = useCallback( ( event: React.SyntheticEvent ) => {
+		event.stopPropagation();
+	}, [] );
+
+	return (
+		<div
+			ref={ ref }
+			role="group"
+			aria-label={ image.name }
+			className={ clsx( 'woocommerce-fields-controls__image-wrapper', {
+				'is-dragging': isDragging,
+			} ) }
+		>
+			<img className="product-image" src={ previewSrc } alt={ alt } />
+			<div className="woocommerce-fields-controls__image-overlay" />
+			{ showDragHandle && (
+				<div className="woocommerce-fields-controls__image-drag-handle-container">
+					<IconButton
+						ref={ handleRef }
+						icon={ dragHandle }
+						label={ __( 'Drag to reorder', 'woocommerce' ) }
+						className="woocommerce-fields-controls__image-drag-handle"
+						variant="minimal"
+						size="small"
+						tone="neutral"
+					/>
+				</div>
+			) }
+			{ ! isDragging && (
+				<IconButton
+					icon={ closeSmall }
+					label={ __( 'Remove image', 'woocommerce' ) }
+					onClick={ onRemove }
+					onPointerDown={ stopPropagation }
+					onKeyDown={ stopPropagation }
+					className="woocommerce-fields-controls__image-remove-button"
+					variant="minimal"
+					size="small"
+					tone="neutral"
+				/>
+			) }
+		</div>
+	);
+}
+
+const fieldDefinition = {
+	label: __( 'Images', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => {
+		const featuredImage = item.images?.at( 0 );
+
+		if ( ! featuredImage ) {
+			return null;
+		}
+
+		return (
+			<img
+				className="product-image"
+				src={ featuredImage.src }
+				alt={ featuredImage.alt || featuredImage.name || item.name }
+				style={ {
+					objectFit: 'cover',
+					borderRadius: 8,
+				} }
+			/>
+		);
+	},
+	Edit: ( { data, onChange, field } ) => {
+		const isVariation = data.type === 'variation';
+		const dataImages = useMemo( () => {
+			const nextImages = data.images ?? [];
+
+			return isVariation ? nextImages.slice( 0, 1 ) : nextImages;
+		}, [ data.images, isVariation ] );
+		const [ images, setImages ] = useState( dataImages );
+		const uploadLabel = isVariation
+			? __( 'Add image', 'woocommerce' )
+			: __( 'Add images', 'woocommerce' );
+
+		useEffect( () => {
+			setImages( dataImages );
+		}, [ dataImages ] );
+
+		const commitImages = useCallback(
+			( nextImages: ProductEntityRecord[ 'images' ] ) => {
+				setImages( nextImages );
+				onChange( {
+					images: nextImages,
+				} );
+			},
+			[ onChange ]
+		);
+
+		const handleSelect = useCallback(
+			( selection: Attachment | Attachment[] ) => {
+				const attachments = Array.isArray( selection )
+					? selection
+					: [ selection ];
+				const mappedImages = attachments.map( toProductImage );
+
+				commitImages(
+					isVariation ? mappedImages.slice( 0, 1 ) : mappedImages
+				);
+			},
+			[ commitImages, isVariation ]
+		);
+
+		const handleRemoveImage = useCallback(
+			( imageToRemove: ProductEntityRecord[ 'images' ][ number ] ) => {
+				commitImages(
+					images.filter( ( image ) => image.id !== imageToRemove.id )
+				);
+			},
+			[ commitImages, images ]
+		);
+
+		const handleDragEnd = useCallback(
+			( event: DragEndEvent ) => {
+				if ( event.canceled ) {
+					return;
+				}
+
+				const { source } = event.operation;
+
+				if ( ! isSortable( source ) ) {
+					return;
+				}
+
+				const { initialIndex, index } = source;
+
+				if (
+					initialIndex === index ||
+					initialIndex < 0 ||
+					index < 0 ||
+					initialIndex >= images.length ||
+					index >= images.length
+				) {
+					return;
+				}
+
+				const reorderedImages = [ ...images ];
+				const [ movedImage ] = reorderedImages.splice(
+					initialIndex,
+					1
+				);
+				reorderedImages.splice( index, 0, movedImage );
+
+				commitImages( reorderedImages );
+			},
+			[ commitImages, images ]
+		);
+
+		const removeCallbacks = useMemo( () => {
+			const callbacks = new Map< number | string, () => void >();
+			images.forEach( ( image ) => {
+				callbacks.set( image.id, () => handleRemoveImage( image ) );
+			} );
+			return callbacks;
+		}, [ images, handleRemoveImage ] );
+
+		return (
+			<div className="woocommerce-fields-control__images">
+				<BaseControl.VisualLabel>
+					{ field.label }
+				</BaseControl.VisualLabel>
+				<DragDropProvider onDragEnd={ handleDragEnd }>
+					<div className="woocommerce-fields-control__featured-image">
+						<div className="woocommerce-fields-controls__featured-image-uploaded-images">
+							{ images.map( ( image, index ) => {
+								const onRemove = removeCallbacks.get(
+									image.id
+								);
+
+								if ( ! onRemove ) {
+									return null;
+								}
+
+								return (
+									<SortableImage
+										key={ image.id }
+										image={ image }
+										index={ index }
+										alt={ image.alt || data.name }
+										onRemove={ onRemove }
+										showDragHandle={
+											! isVariation && images.length > 1
+										}
+									/>
+								);
+							} ) }
+						</div>
+						<div className="woocommerce-fields-control__featured-image-actions">
+							<MediaUpload
+								allowedTypes={ [ 'image' ] }
+								multiple={ isVariation ? false : 'add' }
+								onSelect={ handleSelect }
+								title={ uploadLabel }
+								value={ images.map( ( image ) => image.id ) }
+								render={ ( { open }: { open: () => void } ) => (
+									<IconButton
+										variant="minimal"
+										icon={ upload }
+										label={ uploadLabel }
+										onClick={ open }
+									/>
+								) }
+							/>
+						</div>
+					</div>
+				</DragDropProvider>
+			</div>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx
new file mode 100644
index 00000000000..31b86967db3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/length/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Length', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	...createVariationDimensionField( 'length' ),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx
new file mode 100644
index 00000000000..84f848fd612
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/low_stock_amount/field.tsx
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { TextControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'integer',
+	label: __( 'Low stock threshold', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	isVisible: ( item ) => !! item.manage_stock,
+	getValue: ( { item } ) => item.low_stock_amount ?? '',
+	Edit: ( { data, onChange, field } ) => {
+		const value = data.low_stock_amount;
+		return (
+			<TextControl
+				__nextHasNoMarginBottom
+				label={ field.label }
+				type="number"
+				min={ 0 }
+				value={
+					value === null || value === undefined ? '' : String( value )
+				}
+				placeholder={ __( 'Store wide threshold', 'woocommerce' ) }
+				onChange={ ( next ) => {
+					const parsed =
+						next === '' ? undefined : parseInt( next, 10 );
+					onChange( {
+						low_stock_amount: Number.isNaN( parsed )
+							? undefined
+							: parsed,
+					} );
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx
new file mode 100644
index 00000000000..d4766052054
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/manage_stock/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'boolean',
+	label: __( 'Track inventory', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	type: 'boolean',
+	Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx
new file mode 100644
index 00000000000..e449a717379
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/product_status/field.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { ProductStatusBadge } from '../components/product-status-badge';
+
+function isValidStatus( value: string ) {
+	return (
+		value === 'draft' ||
+		value === 'pending' ||
+		value === 'publish' ||
+		value === 'trash'
+	);
+}
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Status', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	elements: [
+		{ value: 'publish', label: __( 'Published', 'woocommerce' ) },
+		{ value: 'draft', label: __( 'Draft', 'woocommerce' ) },
+		{ value: 'pending', label: __( 'Pending review', 'woocommerce' ) },
+		{ value: 'trash', label: __( 'Trash', 'woocommerce' ) },
+	],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => item.status,
+	render: ( { item }: { item: ProductEntityRecord } ) => (
+		<ProductStatusBadge status={ item.status } />
+	),
+	Edit: ( { data, onChange, field } ) => {
+		const options =
+			field.elements?.filter(
+				( element: { label: string; value: string } ) =>
+					element.value !== 'trash'
+			) ?? [];
+		const selectedOption = options.find(
+			( option ) => option.value === data.status
+		);
+
+		return (
+			<SelectControl
+				label={ field.label }
+				value={ selectedOption }
+				items={ options }
+				onValueChange={ ( option ) => {
+					const value = option?.value;
+
+					if ( typeof value === 'string' && isValidStatus( value ) ) {
+						onChange( {
+							status: value,
+						} );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx b/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx
new file mode 100644
index 00000000000..eed3a7f9a4f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/registry.tsx
@@ -0,0 +1,126 @@
+/**
+ * External dependencies
+ */
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from './types';
+import { fieldExtensions as allowBackordersFieldExtensions } from './allow_backorders/field';
+import { fieldExtensions as attributesFieldExtensions } from './attributes/field';
+import { fieldExtensions as costOfGoodsSoldFieldExtensions } from './cost_of_goods_sold/field';
+import { fieldExtensions as dateOnSaleFromFieldExtensions } from './date_on_sale_from/field';
+import { fieldExtensions as dateOnSaleToFieldExtensions } from './date_on_sale_to/field';
+import { fieldExtensions as descriptionFieldExtensions } from './description/field';
+import { fieldExtensions as downloadableFieldExtensions } from './downloadable/field';
+import { fieldExtensions as downloadableFilesFieldExtensions } from './downloadable_files/field';
+import { fieldExtensions as downloadExpiryFieldExtensions } from './download_expiry/field';
+import { fieldExtensions as downloadLimitFieldExtensions } from './download_limit/field';
+import { fieldExtensions as globalUniqueIdFieldExtensions } from './global_unique_id/field';
+import { fieldExtensions as heightFieldExtensions } from './height/field';
+import { fieldExtensions as imagesFieldExtensions } from './images/field';
+import { fieldExtensions as lengthFieldExtensions } from './length/field';
+import { fieldExtensions as lowStockAmountFieldExtensions } from './low_stock_amount/field';
+import { fieldExtensions as manageStockFieldExtensions } from './manage_stock/field';
+import { fieldExtensions as regularPriceFieldExtensions } from './regular_price/field';
+import { fieldExtensions as salePriceFieldExtensions } from './sale_price/field';
+import { fieldExtensions as scheduleSaleFieldExtensions } from './schedule_sale/field';
+import { fieldExtensions as shippingClassFieldExtensions } from './shipping_class/field';
+import { fieldExtensions as skuFieldExtensions } from './sku/field';
+import { fieldExtensions as stockFieldExtensions } from './stock/field';
+import { fieldExtensions as stockQuantityFieldExtensions } from './stock_quantity/field';
+import { fieldExtensions as taxClassFieldExtensions } from './tax_class/field';
+import { fieldExtensions as taxStatusFieldExtensions } from './tax_status/field';
+import { fieldExtensions as variationActiveFieldExtensions } from './variation_active/field';
+import { fieldExtensions as virtualFieldExtensions } from './virtual/field';
+import { fieldExtensions as weightFieldExtensions } from './weight/field';
+import { fieldExtensions as widthFieldExtensions } from './width/field';
+
+type VariationEditField = Field< ProductEntityRecord >;
+
+// TODO: Move all product field definitions into a common fields folder once
+// the product list/edit pages and variation edit drawer standardize their
+// UI/UX. The current page-specific designs require separate field
+// implementations even when the underlying product data is the same.
+
+function withVirtualGuard( field: VariationEditField ): VariationEditField {
+	const existing = field.isVisible;
+	return {
+		...field,
+		isVisible: ( item: ProductEntityRecord ) =>
+			! item.virtual && ( existing === undefined || existing( item ) ),
+	};
+}
+
+const SHARED_FIELDS: Array< {
+	id: string;
+	extensions: Partial< VariationEditField >;
+} > = [
+	{ id: 'description', extensions: descriptionFieldExtensions },
+	{ id: 'sku', extensions: skuFieldExtensions },
+	{ id: 'global_unique_id', extensions: globalUniqueIdFieldExtensions },
+	{ id: 'regular_price', extensions: regularPriceFieldExtensions },
+	{ id: 'sale_price', extensions: salePriceFieldExtensions },
+	{ id: 'schedule_sale', extensions: scheduleSaleFieldExtensions },
+	{ id: 'date_on_sale_from', extensions: dateOnSaleFromFieldExtensions },
+	{ id: 'date_on_sale_to', extensions: dateOnSaleToFieldExtensions },
+	{ id: 'cost_of_goods_sold', extensions: costOfGoodsSoldFieldExtensions },
+	{ id: 'images', extensions: imagesFieldExtensions },
+	{ id: 'manage_stock', extensions: manageStockFieldExtensions },
+	{ id: 'stock', extensions: stockFieldExtensions },
+	{ id: 'stock_quantity', extensions: stockQuantityFieldExtensions },
+	{ id: 'tax_status', extensions: taxStatusFieldExtensions },
+	{ id: 'variation_active', extensions: variationActiveFieldExtensions },
+];
+
+const SHIPPING_FIELDS: Array< {
+	id: string;
+	extensions: Partial< VariationEditField >;
+} > = [
+	{ id: 'shipping_class', extensions: shippingClassFieldExtensions },
+	{ id: 'weight', extensions: weightFieldExtensions },
+	{ id: 'length', extensions: lengthFieldExtensions },
+	{ id: 'width', extensions: widthFieldExtensions },
+	{ id: 'height', extensions: heightFieldExtensions },
+];
+
+const VARIATION_ONLY_FIELDS: Array< {
+	id: string;
+	extensions: Partial< VariationEditField >;
+} > = [
+	{ id: 'downloadable', extensions: downloadableFieldExtensions },
+	{ id: 'tax_class', extensions: taxClassFieldExtensions },
+	{ id: 'virtual', extensions: virtualFieldExtensions },
+	{ id: 'allow_backorders', extensions: allowBackordersFieldExtensions },
+	{ id: 'low_stock_amount', extensions: lowStockAmountFieldExtensions },
+	{ id: 'downloadable_files', extensions: downloadableFilesFieldExtensions },
+	{ id: 'download_limit', extensions: downloadLimitFieldExtensions },
+	{ id: 'download_expiry', extensions: downloadExpiryFieldExtensions },
+	{ id: 'attributes', extensions: attributesFieldExtensions },
+];
+
+const sharedFields: VariationEditField[] = SHARED_FIELDS.map(
+	( { id, extensions } ) => ( { id, ...extensions } as VariationEditField )
+);
+
+const shippingFields: VariationEditField[] = SHIPPING_FIELDS.map(
+	( { id, extensions } ) =>
+		withVirtualGuard( { id, ...extensions } as VariationEditField )
+);
+
+const variationOnlyFields: VariationEditField[] = VARIATION_ONLY_FIELDS.map(
+	( { id, extensions } ) => ( { id, ...extensions } as VariationEditField )
+);
+
+// The full field list used by the variation edit drawer.
+export const variationEditFields: VariationEditField[] = [
+	...sharedFields,
+	...shippingFields,
+	...variationOnlyFields,
+];
+
+export type VariationEditFieldId =
+	| ( typeof SHARED_FIELDS )[ number ][ 'id' ]
+	| ( typeof SHIPPING_FIELDS )[ number ][ 'id' ]
+	| ( typeof VARIATION_ONLY_FIELDS )[ number ][ 'id' ];
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx
new file mode 100644
index 00000000000..a9a250229f3
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/regular_price/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { CurrencyInput } from '../components/currency-input';
+import { validatePrice } from '../utils/price';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Regular Price', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isValid: {
+		custom: ( item ) => validatePrice( item.regular_price ),
+	},
+	Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx
new file mode 100644
index 00000000000..2c360ecefe2
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { CurrencyInput } from '../components/currency-input';
+import { validateSalePrice } from './validation';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Sale Price', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isValid: {
+		custom: ( item ) => validateSalePrice( item ),
+	},
+	Edit: CurrencyInput,
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts
new file mode 100644
index 00000000000..7460f62bf80
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sale_price/validation.ts
@@ -0,0 +1,33 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { toNumberOrNaN, validatePrice } from '../utils/price';
+
+export function validateSalePrice( item: ProductEntityRecord ): string | null {
+	const numericError = validatePrice( item.sale_price );
+	if ( numericError ) {
+		return numericError;
+	}
+
+	const regularPrice = toNumberOrNaN( item.regular_price );
+
+	if ( Number.isNaN( regularPrice ) ) {
+		return null;
+	}
+
+	const salePrice = toNumberOrNaN( item.sale_price );
+	if ( salePrice >= regularPrice ) {
+		return __(
+			'Sale price must be lower than the regular price.',
+			'woocommerce'
+		);
+	}
+
+	return null;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx
new file mode 100644
index 00000000000..f033b1ac37d
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/schedule_sale/field.tsx
@@ -0,0 +1,186 @@
+/**
+ * External dependencies
+ */
+import { CheckboxControl } from '@wordpress/components';
+import { useCallback, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { getLocalDefaultSaleStart } from '../utils/price';
+import {
+	DatePicker,
+	formatDateTimeLocal,
+	parseDateTimeLocal,
+} from '../components/date-picker';
+
+const fieldDefinition = {
+	type: 'boolean',
+	label: __( 'Schedule sale', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	Edit: ( { data, onChange, field } ) => {
+		const [ tempDateOnSaleFrom, setTempDateOnSaleFrom ] = useState(
+			data.date_on_sale_from || ''
+		);
+		const [ tempDateOnSaleTo, setTempDateOnSaleTo ] = useState(
+			data.date_on_sale_to || ''
+		);
+		const checked = !! data.date_on_sale_to || !! data.date_on_sale_from;
+		const today = useMemo( () => {
+			const d = new Date();
+			d.setHours( 0, 0, 0, 0 );
+			return d;
+		}, [] );
+		const dateOnSaleFrom = useMemo(
+			() =>
+				typeof data.date_on_sale_from === 'string' &&
+				data.date_on_sale_from
+					? parseDateTimeLocal( data.date_on_sale_from )
+					: null,
+			[ data.date_on_sale_from ]
+		);
+		const minDateOnSaleTo = useMemo( () => {
+			if ( dateOnSaleFrom ) {
+				const min = new Date( dateOnSaleFrom );
+				min.setMinutes( min.getMinutes() + 1 );
+				return min;
+			}
+
+			return today;
+		}, [ dateOnSaleFrom, today ] );
+		const handleScheduleChange = useCallback(
+			( value: boolean ) => {
+				if ( ! value ) {
+					setTempDateOnSaleFrom( data.date_on_sale_from || '' );
+					setTempDateOnSaleTo( data.date_on_sale_to || '' );
+					onChange( {
+						date_on_sale_from: '',
+						date_on_sale_to: '',
+					} );
+					return;
+				}
+
+				let nextDateOnSaleFrom =
+					data.date_on_sale_from || tempDateOnSaleFrom;
+				const nextDateOnSaleTo =
+					data.date_on_sale_to || tempDateOnSaleTo;
+
+				if ( ! nextDateOnSaleFrom && ! nextDateOnSaleTo ) {
+					nextDateOnSaleFrom = getLocalDefaultSaleStart();
+				}
+
+				onChange( {
+					date_on_sale_from: nextDateOnSaleFrom,
+					date_on_sale_to: nextDateOnSaleTo,
+				} );
+			},
+			[
+				data.date_on_sale_from,
+				data.date_on_sale_to,
+				onChange,
+				tempDateOnSaleFrom,
+				tempDateOnSaleTo,
+			]
+		);
+		const handleDateOnSaleFromChange = useCallback(
+			( value: { date_on_sale_from?: string | null } ) => {
+				const newStart = value.date_on_sale_from;
+				const currentEnd = data.date_on_sale_to;
+
+				if (
+					typeof newStart !== 'string' ||
+					! newStart ||
+					typeof currentEnd !== 'string' ||
+					! currentEnd
+				) {
+					onChange( value );
+					return;
+				}
+
+				const startDate = parseDateTimeLocal( newStart );
+				const endDate = parseDateTimeLocal( currentEnd );
+
+				if (
+					startDate &&
+					endDate &&
+					startDate.getTime() >= endDate.getTime()
+				) {
+					const newEndDate = new Date( startDate );
+					newEndDate.setDate( newEndDate.getDate() + 1 );
+
+					onChange( {
+						...value,
+						date_on_sale_to: formatDateTimeLocal( newEndDate ),
+					} );
+					return;
+				}
+
+				onChange( value );
+			},
+			[ data.date_on_sale_to, onChange ]
+		);
+
+		return (
+			<div className="woocommerce-schedule-sale-control">
+				<CheckboxControl
+					label={ field.label }
+					checked={ checked }
+					onChange={ handleScheduleChange }
+				/>
+				{ checked && (
+					<div className="woocommerce-schedule-sale-control__dates">
+						<DatePicker
+							data={ data }
+							onChange={ handleDateOnSaleFromChange }
+							field={ {
+								label: __( 'Start sale on', 'woocommerce' ),
+							} }
+							fieldKey="date_on_sale_from"
+							min={ today }
+						/>
+						<DatePicker
+							data={ data }
+							onChange={ onChange }
+							field={ {
+								label: __( 'End sale on', 'woocommerce' ),
+							} }
+							fieldKey="date_on_sale_to"
+							min={ minDateOnSaleTo }
+						/>
+					</div>
+				) }
+			</div>
+		);
+	},
+	getValue: ( { item } ) =>
+		!! item.date_on_sale_to || !! item.date_on_sale_from,
+	setValue: ( { item, value } ) => {
+		if ( ! value ) {
+			return {
+				date_on_sale_from: '',
+				date_on_sale_to: '',
+			};
+		}
+
+		let dateOnSaleFrom = item.date_on_sale_from || '';
+		const dateOnSaleTo = item.date_on_sale_to || '';
+
+		if ( ! dateOnSaleFrom && ! dateOnSaleTo ) {
+			dateOnSaleFrom = getLocalDefaultSaleStart();
+		}
+
+		return {
+			date_on_sale_from: dateOnSaleFrom,
+			date_on_sale_to: dateOnSaleTo,
+		};
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx
new file mode 100644
index 00000000000..235a1f6c742
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/shipping_class/field.tsx
@@ -0,0 +1,75 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+interface ProductShippingClass {
+	id: number;
+	slug: string;
+	name: string;
+	description: string;
+	count: number;
+}
+
+// Radix UI (used by @wordpress/ui SelectControl) rejects empty-string values.
+// We use 'parent' as a UI sentinel for the '' API value ("Same as parent").
+const SAME_AS_PARENT = 'parent';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	Edit: ( { data, onChange, field } ) => {
+		const { shippingClasses } = useSelect( ( select ) => {
+			// eslint-disable-next-line @wordpress/data-no-store-string-literals
+			const store = select(
+				'experimental/wc/admin/products/shipping-classes'
+			);
+			return {
+				shippingClasses:
+					// @ts-expect-error - The store return type lives in Woo core.
+					( store?.getProductShippingClasses?.() ??
+						[] ) as ProductShippingClass[],
+			};
+		}, [] );
+
+		const options = [
+			{
+				label: __( 'Same as parent', 'woocommerce' ),
+				value: SAME_AS_PARENT,
+			},
+			...( shippingClasses?.length
+				? shippingClasses.map( ( shippingClass ) => ( {
+						label: shippingClass.name,
+						value: shippingClass.slug,
+				  } ) )
+				: [] ),
+		];
+
+		const apiValue = data.shipping_class ?? '';
+		const uiValue = apiValue === '' ? SAME_AS_PARENT : apiValue;
+		const selectedOption = options.find( ( o ) => o.value === uiValue );
+
+		return (
+			<SelectControl
+				label={ field.label }
+				value={ selectedOption }
+				items={ options }
+				onValueChange={ ( option ) => {
+					if ( option !== null && option !== undefined ) {
+						const apiVal =
+							option.value === SAME_AS_PARENT
+								? ''
+								: option.value ?? '';
+						onChange( { shipping_class: apiVal } );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx
new file mode 100644
index 00000000000..3f6169cde9c
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/sku/field.tsx
@@ -0,0 +1,30 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'SKU', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	render: ( { item } ) => {
+		const sku = item.sku;
+
+		if ( sku === undefined || sku === null || sku === '' ) {
+			return <span>{ '—' }</span>;
+		}
+
+		return <span>{ sku }</span>;
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx
new file mode 100644
index 00000000000..8d180793348
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/stock/field.tsx
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge, SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type StockStatus = 'instock' | 'outofstock' | 'onbackorder';
+
+function isValidStockStatus( value: string ): value is StockStatus {
+	return (
+		value === 'instock' || value === 'outofstock' || value === 'onbackorder'
+	);
+}
+
+const stockStatusBadgeIntent: Record<
+	StockStatus,
+	React.ComponentProps< typeof Badge >[ 'intent' ]
+> = {
+	instock: 'none',
+	outofstock: 'high',
+	onbackorder: 'draft',
+};
+
+const fieldDefinition = {
+	label: __( 'Stock', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: {
+		operators: [ 'isAny' ],
+	},
+	elements: [
+		{ label: __( 'In stock', 'woocommerce' ), value: 'instock' },
+		{ label: __( 'Out of stock', 'woocommerce' ), value: 'outofstock' },
+		{ label: __( 'On backorder', 'woocommerce' ), value: 'onbackorder' },
+	],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isVisible: ( item ) => {
+		return ! item.manage_stock;
+	},
+	getValue: ( { item } ) => item.stock_status,
+	render: ( { item, field } ) => {
+		const match = field?.elements?.find(
+			( status ) => status.value === item.stock_status
+		);
+
+		if ( ! match || ! isValidStockStatus( match.value ) ) {
+			return item.stock_status;
+		}
+
+		const stockLabel =
+			item.stock_quantity && item.stock_quantity > 0
+				? `${ match.label } (${ item.stock_quantity })`
+				: match.label;
+
+		return (
+			<div className="woocommerce-fields-field__stock">
+				<Badge intent={ stockStatusBadgeIntent[ match.value ] }>
+					{ stockLabel }
+				</Badge>
+			</div>
+		);
+	},
+	Edit: ( { data, onChange, field } ) => {
+		const options = field?.elements ?? [];
+		const selectedOption =
+			field.placeholder && ! data.stock_status
+				? undefined
+				: options.find(
+						( option ) => option.value === data.stock_status
+				  );
+
+		return (
+			<SelectControl
+				label={ __( 'Stock status', 'woocommerce' ) }
+				placeholder={ field.placeholder }
+				value={ selectedOption }
+				items={ options }
+				onValueChange={ ( option ) => {
+					const value = option?.value;
+
+					if (
+						typeof value === 'string' &&
+						isValidStockStatus( value )
+					) {
+						onChange( {
+							stock_status: value,
+						} );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx
new file mode 100644
index 00000000000..a96cb84cbf8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/stock_quantity/field.tsx
@@ -0,0 +1,129 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { InputControl, Stack } from '@wordpress/ui';
+import type { DataFormControlProps, Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+type StockQuantityRange = [ number | string, number | string ];
+type StockQuantityFilterRecord = Omit<
+	ProductEntityRecord,
+	'stock_quantity'
+> & {
+	stock_quantity?: number | string | null | StockQuantityRange;
+};
+
+const castValueToString = (
+	value: number | string | null | StockQuantityRange | undefined
+): string => {
+	if ( typeof value === 'number' ) {
+		return String( value );
+	} else if ( typeof value === 'string' ) {
+		return value;
+	}
+	return '';
+};
+
+const fieldDefinition = {
+	type: 'integer',
+	label: __( 'Stock quantity', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: {
+		operators: [
+			'is',
+			'greaterThan',
+			'greaterThanOrEqual',
+			'lessThan',
+			'lessThanOrEqual',
+			'between',
+		],
+	},
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isVisible: ( item ) => {
+		return !! item.manage_stock;
+	},
+	Edit: ( {
+		data,
+		onChange,
+		hideLabelFromVision,
+		operator,
+		field,
+	}: DataFormControlProps< ProductEntityRecord > ) => {
+		const onChangeBetween = onChange as (
+			data: Partial< StockQuantityFilterRecord >
+		) => void;
+		const onChangeStockQuantity = onChange as (
+			data: Partial< StockQuantityFilterRecord >
+		) => void;
+		const raw = ( data as StockQuantityFilterRecord ).stock_quantity;
+		const disabled = field.isDisabled( { item: data, field } );
+
+		if ( operator === 'between' ) {
+			const [ minRaw = '', maxRaw = '' ] = Array.isArray( raw )
+				? raw
+				: [];
+			const min = String( minRaw );
+			const max = String( maxRaw );
+
+			return (
+				<Stack direction="row">
+					<InputControl
+						label={ __( 'From', 'woocommerce' ) }
+						type="number"
+						step={ 1 }
+						value={ min }
+						onChange={ ( event ) => {
+							const next = event.target.value;
+							const nextMin = next === '' ? '' : Number( next );
+							onChangeBetween( {
+								stock_quantity: [ nextMin, max ],
+							} );
+						} }
+					/>
+					<InputControl
+						label={ __( 'To', 'woocommerce' ) }
+						type="number"
+						step={ 1 }
+						value={ max }
+						onChange={ ( event ) => {
+							const next = event.target.value;
+							const nextMax = next === '' ? '' : Number( next );
+							onChangeBetween( {
+								stock_quantity: [ min, nextMax ],
+							} );
+						} }
+					/>
+				</Stack>
+			);
+		}
+
+		const value = castValueToString( raw );
+
+		return (
+			<InputControl
+				label={ field.label }
+				hideLabelFromVision={ hideLabelFromVision }
+				type="number"
+				step={ 1 }
+				value={ value }
+				placeholder={ field.placeholder }
+				disabled={ disabled }
+				onChange={ ( event ) => {
+					const next = event.target.value;
+					const stockQuantity: StockQuantityFilterRecord[ 'stock_quantity' ] =
+						next === '' ? null : Number( next );
+					onChangeStockQuantity( { stock_quantity: stockQuantity } );
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx
new file mode 100644
index 00000000000..ec4f7536bc6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/tax_class/field.tsx
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+// Radix UI (used by @wordpress/ui SelectControl) rejects empty-string values.
+// We use 'parent' as a UI sentinel for the '' API value ("Same as parent").
+const SAME_AS_PARENT = 'parent';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'text',
+	label: __( 'Tax class', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	getValue: ( { item } ) => item.tax_class ?? '',
+	Edit: ( { data, onChange, field } ) => {
+		const options = [
+			{
+				value: SAME_AS_PARENT,
+				label: __( 'Same as parent', 'woocommerce' ),
+			},
+			{ value: 'standard', label: __( 'Standard', 'woocommerce' ) },
+			{
+				value: 'reduced-rate',
+				label: __( 'Reduced rate', 'woocommerce' ),
+			},
+			{ value: 'zero-rate', label: __( 'Zero rate', 'woocommerce' ) },
+		];
+
+		const apiValue = data.tax_class ?? '';
+		const uiValue = apiValue === '' ? SAME_AS_PARENT : apiValue;
+		const selected = options.find( ( o ) => o.value === uiValue );
+
+		return (
+			<SelectControl
+				label={ field.label }
+				value={ selected }
+				items={ options }
+				onValueChange={ ( option ) => {
+					if ( option !== null && option !== undefined ) {
+						const apiVal =
+							option.value === SAME_AS_PARENT ? '' : option.value;
+						onChange( {
+							tax_class:
+								apiVal as ProductEntityRecord[ 'tax_class' ],
+						} );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx
new file mode 100644
index 00000000000..55722c3aa30
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/tax_status/field.tsx
@@ -0,0 +1,37 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { CheckboxControl } from '@wordpress/components';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+const fieldDefinition = {
+	label: __( 'Tax Status', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	enableSorting: false,
+	Edit: ( { data, onChange, field } ) => {
+		return (
+			<CheckboxControl
+				label={ field.label }
+				checked={ data.tax_status === 'taxable' }
+				onChange={ () =>
+					onChange( {
+						tax_status:
+							data.tax_status === 'taxable' ? 'none' : 'taxable',
+					} )
+				}
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/types.ts b/packages/js/experimental-products-app/src/variation-view/fields/types.ts
new file mode 100644
index 00000000000..2356e484226
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/types.ts
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import type {
+	Product,
+	ProductStatus as WooProductStatus,
+} from '@woocommerce/data';
+
+export type ProductEntityRecord = Omit< Product, 'categories' | 'tags' > & {
+	cost_of_goods_sold?: {
+		values?: Array< {
+			defined_value?: number | string | null;
+			effective_value?: number | string | null;
+		} >;
+		defined_value_is_additive?: boolean;
+		total_value?: number | string | null;
+	};
+	categories: Array< {
+		id: number;
+		name?: string;
+		image?: {
+			src?: string;
+			alt?: string;
+		};
+	} >;
+	tags: Array< {
+		id: number;
+		name?: string;
+	} >;
+	brands?: Array< {
+		id: number;
+		name?: string;
+		slug?: string;
+	} >;
+	global_unique_id?: string;
+	cross_sell_ids?: number[];
+	upsell_ids?: number[];
+	date_on_sale_from?: string | null;
+	date_on_sale_to?: string | null;
+	parent_id?: number;
+	_embedded?: {
+		variations?: ProductEntityRecord[];
+	};
+	seo_title?: string;
+	seo_description?: string;
+	visible_in_pos?: boolean;
+	images: Array< {
+		alt: string;
+		date_created: string;
+		date_created_gmt: string;
+		date_modified: string;
+		date_modified_gmt: string;
+		id: number;
+		name: string;
+		src: string;
+		thumbnail: string;
+	} >;
+};
+
+export type ProductStatus = WooProductStatus;
+
+export type SettingsEntityRecord = {
+	values?: {
+		woocommerce_dimension_unit?: string;
+		woocommerce_weight_unit?: string;
+	};
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts
new file mode 100644
index 00000000000..db5428fde12
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Internal dependencies
+ */
+import { getCurrencyObject, formatCurrency } from './currency';
+
+jest.mock( '@woocommerce/settings', () => ( {
+	CURRENCY: {
+		code: 'USD',
+		symbol: '$',
+		symbolPosition: 'left',
+		precision: '2',
+		decimalSeparator: '.',
+		thousandSeparator: ',',
+	},
+} ) );
+
+const getCurrencyMock = () =>
+	jest.requireMock( '@woocommerce/settings' ).CURRENCY;
+
+describe( 'getCurrencyObject', () => {
+	afterEach( () => {
+		const mock = getCurrencyMock();
+		mock.code = 'USD';
+		mock.symbol = '$';
+		mock.symbolPosition = 'left';
+		mock.precision = '2';
+	} );
+
+	it( 'returns currency settings from CURRENCY global', () => {
+		const result = getCurrencyObject();
+		expect( result.code ).toBe( 'USD' );
+		expect( result.symbol ).toBe( '$' );
+		expect( result.precision ).toBe( 2 );
+	} );
+
+	it( 'falls back to USD when code is empty', () => {
+		getCurrencyMock().code = '';
+		expect( getCurrencyObject().code ).toBe( 'USD' );
+	} );
+
+	it( 'falls back to $ when symbol is empty', () => {
+		getCurrencyMock().symbol = '';
+		expect( getCurrencyObject().symbol ).toBe( '$' );
+	} );
+
+	it( 'falls back to precision 2 when precision is invalid', () => {
+		getCurrencyMock().precision = 'invalid';
+		expect( getCurrencyObject().precision ).toBe( 2 );
+	} );
+
+	it( 'falls back to precision 2 when precision is negative', () => {
+		getCurrencyMock().precision = '-1';
+		expect( getCurrencyObject().precision ).toBe( 2 );
+	} );
+} );
+
+describe( 'formatCurrency', () => {
+	it( 'formats a number as currency', () => {
+		const result = formatCurrency( 9.99 );
+		expect( result ).toContain( '9.99' );
+	} );
+
+	it( 'formats a string number as currency', () => {
+		const result = formatCurrency( '19.99' );
+		expect( result ).toContain( '19.99' );
+	} );
+
+	it( 'returns empty string for NaN input', () => {
+		expect( formatCurrency( 'not-a-number' ) ).toBe( '' );
+	} );
+
+	it( 'returns empty string for Infinity input', () => {
+		expect( formatCurrency( Infinity ) ).toBe( '' );
+	} );
+
+	it( 'handles zero correctly', () => {
+		const result = formatCurrency( 0 );
+		expect( result ).toContain( '0.00' );
+	} );
+
+	it( 'accepts a custom currency code', () => {
+		const result = formatCurrency( 10, 'EUR' );
+		expect( result ).toContain( '10.00' );
+	} );
+
+	it( 'falls back to USD formatting when currency code is invalid', () => {
+		const result = formatCurrency( 10, 'INVALID' );
+		expect( result ).toContain( '10.00' );
+	} );
+} );
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts
new file mode 100644
index 00000000000..8316d9b28e4
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/currency.ts
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import { CURRENCY } from '@woocommerce/settings';
+
+type CurrencyObject = {
+	code: string;
+	symbol: string;
+	symbolPosition: string;
+	precision: number;
+	decimalSeparator?: string;
+	thousandSeparator?: string;
+};
+
+export function getCurrencyObject(): CurrencyObject {
+	const parsedPrecision = Number( CURRENCY.precision );
+	return {
+		code: CURRENCY.code || 'USD',
+		symbol: CURRENCY.symbol || '$',
+		symbolPosition: CURRENCY.symbolPosition || 'left',
+		precision:
+			Number.isFinite( parsedPrecision ) && parsedPrecision >= 0
+				? parsedPrecision
+				: 2,
+		decimalSeparator: CURRENCY.decimalSeparator,
+		thousandSeparator: CURRENCY.thousandSeparator,
+	};
+}
+
+export function formatCurrency(
+	value: number | string,
+	currencyCode = getCurrencyObject().code
+) {
+	const amount = typeof value === 'number' ? value : Number( value );
+	if ( ! Number.isFinite( amount ) ) {
+		return '';
+	}
+	const locale =
+		typeof document !== 'undefined'
+			? document.documentElement.lang || 'en-US'
+			: 'en-US';
+	const { precision } = getCurrencyObject();
+	const safeCurrencyCode = currencyCode || 'USD';
+
+	try {
+		return new Intl.NumberFormat( locale, {
+			style: 'currency',
+			currency: safeCurrencyCode,
+			minimumFractionDigits: precision,
+			maximumFractionDigits: precision,
+		} ).format( amount );
+	} catch {
+		return new Intl.NumberFormat( 'en-US', {
+			style: 'currency',
+			currency: 'USD',
+			minimumFractionDigits: 2,
+			maximumFractionDigits: 2,
+		} ).format( amount );
+	}
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts b/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts
new file mode 100644
index 00000000000..2afa8ddd4b7
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/utils/price.ts
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+export const getLocalDefaultSaleStart = () => {
+	const tomorrow = new Date();
+	tomorrow.setDate( tomorrow.getDate() + 1 );
+	tomorrow.setHours( 7, 0, 0, 0 );
+	tomorrow.setMinutes( tomorrow.getMinutes() - tomorrow.getTimezoneOffset() );
+	return tomorrow.toISOString().slice( 0, 16 );
+};
+
+export const toNumberOrNaN = ( value: unknown ) => {
+	if ( typeof value === 'number' ) {
+		return value;
+	}
+
+	if ( typeof value === 'string' && value.trim() !== '' ) {
+		return Number.parseFloat( value );
+	}
+
+	return Number.NaN;
+};
+
+export function validatePrice( value: unknown ): string | null {
+	if (
+		value === undefined ||
+		value === null ||
+		( typeof value === 'string' && value.trim() === '' )
+	) {
+		return null;
+	}
+
+	const parsed = toNumberOrNaN( value );
+
+	if ( Number.isNaN( parsed ) ) {
+		return __( 'Please enter a valid price.', 'woocommerce' );
+	}
+
+	if ( parsed < 0 ) {
+		return __( 'Price must not be negative.', 'woocommerce' );
+	}
+
+	return null;
+}
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx
new file mode 100644
index 00000000000..030699da6c8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/variation_active/field.tsx
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Badge, SelectControl } from '@wordpress/ui';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../types';
+
+type VariationActiveValue = 'active' | 'inactive';
+
+const ACTIVE_VALUE: VariationActiveValue = 'active';
+const INACTIVE_VALUE: VariationActiveValue = 'inactive';
+
+function isVariationActiveValue(
+	value: unknown
+): value is VariationActiveValue {
+	return value === ACTIVE_VALUE || value === INACTIVE_VALUE;
+}
+
+function hasPrice( item: Pick< ProductEntityRecord, 'price' > ) {
+	return item.price !== undefined && item.price !== null && item.price !== '';
+}
+
+export function isVariationActive(
+	item: Pick< ProductEntityRecord, 'price' | 'status' >
+) {
+	return item.status === 'publish' && hasPrice( item );
+}
+
+export function getVariationActiveValue(
+	item: Pick< ProductEntityRecord, 'price' | 'status' >
+): VariationActiveValue {
+	return isVariationActive( item ) ? ACTIVE_VALUE : INACTIVE_VALUE;
+}
+
+function getVariationActiveFormValue(
+	data: ProductEntityRecord & Record< string, unknown >
+): VariationActiveValue | undefined {
+	if ( isVariationActiveValue( data.variation_active ) ) {
+		return data.variation_active;
+	}
+
+	return getVariationActiveValue( data );
+}
+
+function getVariationActiveStatus(
+	value: VariationActiveValue
+): ProductStatus {
+	return value === ACTIVE_VALUE ? 'publish' : 'private';
+}
+
+export function getVariationActiveLabel( value: VariationActiveValue ) {
+	return value === ACTIVE_VALUE
+		? __( 'Active', 'woocommerce' )
+		: __( 'Inactive', 'woocommerce' );
+}
+
+export function VariationActiveBadge( {
+	value,
+}: {
+	value: VariationActiveValue;
+} ) {
+	return value === ACTIVE_VALUE ? (
+		<Badge intent="stable">{ getVariationActiveLabel( value ) }</Badge>
+	) : (
+		<Badge intent="draft">{ getVariationActiveLabel( value ) }</Badge>
+	);
+}
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Status', 'woocommerce' ),
+	enableSorting: false,
+	filterBy: false,
+	elements: [
+		{ value: ACTIVE_VALUE, label: __( 'Active', 'woocommerce' ) },
+		{ value: INACTIVE_VALUE, label: __( 'Inactive', 'woocommerce' ) },
+	],
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	getValue: ( { item } ) => getVariationActiveValue( item ),
+	render: ( { item }: { item: ProductEntityRecord } ) => (
+		<VariationActiveBadge value={ getVariationActiveValue( item ) } />
+	),
+	Edit: ( { data, onChange, field } ) => {
+		const options = field.elements ?? [];
+		const formData = data as ProductEntityRecord &
+			Record< string, unknown >;
+		const formValue =
+			field.placeholder &&
+			! isVariationActiveValue( formData.variation_active )
+				? undefined
+				: getVariationActiveFormValue( formData );
+		const selectedOption = options.find(
+			( option ) => option.value === formValue
+		);
+
+		return (
+			<SelectControl
+				label={ field.label }
+				placeholder={ field.placeholder }
+				value={ selectedOption }
+				items={ options }
+				onValueChange={ ( option ) => {
+					const selectedValue = option?.value;
+
+					if (
+						selectedValue === ACTIVE_VALUE ||
+						selectedValue === INACTIVE_VALUE
+					) {
+						onChange( {
+							status: getVariationActiveStatus( selectedValue ),
+						} );
+					}
+				} }
+			/>
+		);
+	},
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx
new file mode 100644
index 00000000000..6e433858848
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/virtual/field.tsx
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	type: 'boolean',
+	label: __( 'Virtual', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+	getValue: ( { item } ) => item.virtual ?? false,
+	Edit: 'checkbox',
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx
new file mode 100644
index 00000000000..923cfc1f007
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/weight/field.tsx
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import {
+	createVariationWeightField,
+	isDimensionVisible,
+} from '../components/dimension';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Weight', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	isVisible: isDimensionVisible,
+	...createVariationWeightField(),
+};
diff --git a/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx b/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx
new file mode 100644
index 00000000000..1e1fe2706e9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/variation-view/fields/width/field.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import type { Field } from '@wordpress/dataviews';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../types';
+import { createVariationDimensionField } from '../components/dimension';
+
+const fieldDefinition = {
+	type: 'text',
+	label: __( 'Width', 'woocommerce' ),
+	enableSorting: false,
+	enableHiding: false,
+	filterBy: false,
+} satisfies Partial< Field< ProductEntityRecord > >;
+
+export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
+	...fieldDefinition,
+	...createVariationDimensionField( 'width' ),
+};
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 580c0d8f7de..07cb632617f 100644
--- a/packages/js/experimental-products-app/src/variation-view/index.tsx
+++ b/packages/js/experimental-products-app/src/variation-view/index.tsx
@@ -19,7 +19,7 @@ import { buildVariationViewQuery } from './query';
 import { normalizeVariation } from './normalization';
 import { variationFields } from './fields';
 import type { VariationEntityRecord } from './types';
-import ProductEdit from '../product-edit';
+import { VariationEditDrawer } from './edit/drawer';
 import { getProductWithUpdatedVariation } from '../product-edit/utils';
 import type { ProductEntityRecord } from '../fields/types';
 import { unlock } from '../lock-unlock';
@@ -266,6 +266,15 @@ export function VariationView( { productId }: VariationViewProps ) {
 		[ handleEditSelectedVariations ]
 	);

+	const handleCloseDrawer = useCallback( () => {
+		navigate(
+			getProductListNavigationPath( location.path, {
+				...currentQuery,
+				quickEdit: undefined,
+			} )
+		);
+	}, [ currentQuery, location.path, navigate ] );
+
 	const actions: Action< VariationEntityRecord >[] = useMemo(
 		() => [
 			{
@@ -348,9 +357,11 @@ export function VariationView( { productId }: VariationViewProps ) {
 				<DataViews.Footer />
 			</DataViews>
 			{ productWithVariations && (
-				<ProductEdit
+				<VariationEditDrawer
 					products={ [ productWithVariations ] }
 					isOpen={ showQuickEdit }
+					productId={ productId }
+					onClose={ handleCloseDrawer }
 				/>
 			) }
 		</div>
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 0aee17eeded..8e66717096d 100644
--- a/packages/js/experimental-products-app/src/variation-view/style.scss
+++ b/packages/js/experimental-products-app/src/variation-view/style.scss
@@ -166,3 +166,28 @@
 		white-space: nowrap;
 	}
 }
+
+.woocommerce-variation-name {
+	display: inline-flex;
+	align-items: center;
+	gap: var(--wpds-dimension-padding-xs, 8px);
+	min-width: 0;
+}
+
+.woocommerce-variation-name__label {
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.woocommerce-variation-status-option {
+	display: flex;
+	flex-direction: column;
+	gap: var(--wpds-dimension-padding-3xs, 2px);
+}
+
+.woocommerce-variation-status-option__description {
+	color: var(--wpds-color-fg-content-neutral-weak, #757575);
+	font-size: var(--wpds-typography-font-size-sm, 12px);
+	line-height: var(--wpds-typography-line-height-sm, 16px);
+}