diff --git a/lib/block-supports/spacing.php b/lib/block-supports/spacing.php
index 86e6c750185a56..044654b83959f2 100644
--- a/lib/block-supports/spacing.php
+++ b/lib/block-supports/spacing.php
@@ -73,6 +73,71 @@ function gutenberg_apply_spacing_support( $block_type, $block_attributes ) {
return $attributes;
}
+/**
+ * Returns a spacing size value based on a given spacing size preset.
+ *
+ * @since TBD
+ *
+ * @param array $preset {
+ * Required. spacingSizes preset value as seen in theme.json.
+ *
+ * @type string $name Name of the spacing size preset.
+ * @type string $slug Kebab-case unique identifier for the spacing size preset.
+ * @type string|int|float $size CSS spacing size value, including units where applicable.
+ * @type array $fluid Fluid spacing settings when fluid spacing is enabled.
+ * }
+ * @param bool|array $settings Optional Theme JSON settings array that overrides any global theme settings.
+ * Default is `array()`.
+ *
+ * @return string|null Spacing size value or `null` if a size is not passed in $preset.
+ */
+function gutenberg_get_spacing_size_value( $preset, $settings = array() ) {
+ if ( ! isset( $preset['size'] ) ) {
+ return null;
+ }
+
+ // Treat existing functional CSS size as authoritative; return as-is.
+ $size = isset( $preset['size'] ) ? (string) $preset['size'] : '';
+ if (
+ false !== strpos( $size, 'clamp(' ) ||
+ false !== strpos( $size, 'min(' ) ||
+ false !== strpos( $size, 'max(' ) ||
+ false !== strpos( $size, 'calc(' ) ||
+ false !== strpos( $size, 'var(' )
+ ) {
+ return $size;
+ }
+
+ // Fluid settings from the preset (can be false|true|array).
+ $fluid_spacing_settings = $preset['fluid'] ?? null;
+
+ // Explicitly disabled or empty size → return static size.
+ if ( false === $fluid_spacing_settings || empty( $size ) ) {
+ return $size;
+ }
+
+ // If fluid is boolean true (no explicit values), return static size for spacing.
+ // @todo: We need to create a fluid calculation for spacing (or use the same one as typography)
+ if ( true === $fluid_spacing_settings ) {
+ return $size;
+ }
+
+ // Build clamp() only when explicit min/preferred/max are provided via fluid object.
+ if ( is_array( $fluid_spacing_settings ) && ! empty( $fluid_spacing_settings ) ) {
+ $min = isset( $fluid_spacing_settings['min'] ) ? trim( (string) $fluid_spacing_settings['min'] ) : '';
+ $preferred = isset( $fluid_spacing_settings['preferred'] ) ? trim( (string) $fluid_spacing_settings['preferred'] ) : trim( (string) $size );
+ $max = isset( $fluid_spacing_settings['max'] ) ? trim( (string) $fluid_spacing_settings['max'] ) : '';
+
+ // All three are required to form a valid clamp().
+ if ( '' !== $min && '' !== $preferred && '' !== $max ) {
+ return sprintf( 'clamp(%s, %s, %s)', $min, $preferred, $max );
+ }
+ }
+
+ // Fallback to static size.
+ return $size;
+}
+
// Register the block support.
WP_Block_Supports::get_instance()->register(
'spacing',
diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php
index b6a1e7a2029758..ebdb87cbd369a6 100644
--- a/lib/class-wp-theme-json-gutenberg.php
+++ b/lib/class-wp-theme-json-gutenberg.php
@@ -190,7 +190,7 @@ class WP_Theme_JSON_Gutenberg {
'path' => array( 'spacing', 'spacingSizes' ),
'prevent_override' => array( 'spacing', 'defaultSpacingSizes' ),
'use_default_names' => true,
- 'value_key' => 'size',
+ 'value_func' => 'gutenberg_get_spacing_size_value',
'css_vars' => '--wp--preset--spacing--$slug',
'classes' => array(),
'properties' => array( 'padding', 'margin' ),
diff --git a/packages/block-editor/src/components/global-styles/get-global-styles-changes.js b/packages/block-editor/src/components/global-styles/get-global-styles-changes.js
index 264426d83a1052..e93dd88765b7b7 100644
--- a/packages/block-editor/src/components/global-styles/get-global-styles-changes.js
+++ b/packages/block-editor/src/components/global-styles/get-global-styles-changes.js
@@ -26,6 +26,7 @@ const translationMap = {
'settings.typography': __( 'Typography' ),
'settings.shadow': __( 'Shadow' ),
'settings.layout': __( 'Layout' ),
+ 'settings.spacing': __( 'Spacing' ),
'styles.color': __( 'Colors' ),
'styles.spacing': __( 'Spacing' ),
'styles.background': __( 'Background' ),
diff --git a/packages/edit-site/src/components/global-styles/screen-layout.js b/packages/edit-site/src/components/global-styles/screen-layout.js
index b6fa9f18f18dee..6b6a082f7b8627 100644
--- a/packages/edit-site/src/components/global-styles/screen-layout.js
+++ b/packages/edit-site/src/components/global-styles/screen-layout.js
@@ -9,6 +9,8 @@ import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
*/
import DimensionsPanel from './dimensions-panel';
import ScreenHeader from './header';
+import SpacingsCount from './spacing/spacings-count';
+import { hasAvailableSpacingSizes } from './spacing/utils';
import { unlock } from '../../lock-unlock';
const { useHasDimensionsPanel, useGlobalSetting, useSettingsForBlockElement } =
@@ -18,11 +20,19 @@ function ScreenLayout() {
const [ rawSettings ] = useGlobalSetting( '' );
const settings = useSettingsForBlockElement( rawSettings );
const hasDimensionsPanel = useHasDimensionsPanel( settings );
+ const hasSpacingSizes = hasAvailableSpacingSizes( settings );
return (
<>
- { hasDimensionsPanel && }
+ <>
+ { hasDimensionsPanel && }
+ { hasSpacingSizes && (
+
+
+
+ ) }
+ >
>
);
}
diff --git a/packages/edit-site/src/components/global-styles/size-control/index.js b/packages/edit-site/src/components/global-styles/size-control/index.js
index 06ea0bb5617e31..1e5d49dade1ca5 100644
--- a/packages/edit-site/src/components/global-styles/size-control/index.js
+++ b/packages/edit-site/src/components/global-styles/size-control/index.js
@@ -25,21 +25,47 @@ function SizeControl( {
...props
} ) {
const { baseControlProps } = useBaseControlProps( props );
- const { value, onChange, fallbackValue, disabled, label } = props;
+ const { value, onChange, fallbackValue, disabled, label, max } = props;
const units = useCustomUnits( {
availableUnits: DEFAULT_UNITS,
} );
+ const maxRelativeValue = 10;
+ const maxNonRelativeValue = 100;
+
+ // Helper function to check if a unit is relative
+ const isUnitRelative = ( unit ) =>
+ !! unit && [ 'em', 'rem', 'vw', 'vh' ].includes( unit );
+
const [ valueQuantity, valueUnit = 'px' ] =
parseQuantityAndUnitFromRawValue( value, units );
- const isValueUnitRelative =
- !! valueUnit && [ 'em', 'rem', 'vw', 'vh' ].includes( valueUnit );
+ const isValueUnitRelative = isUnitRelative( valueUnit );
+
+ // Determine the max value for the range control
+ const getMaxValue = () => {
+ // For relative units, always use 10
+ if ( isValueUnitRelative ) {
+ return maxRelativeValue;
+ }
+ // For non-relative units, use custom max from props or default 500
+ return max !== undefined ? max : maxNonRelativeValue;
+ };
// Receives the new value from the UnitControl component as a string containing the value and unit.
const handleUnitControlChange = ( newValue ) => {
- onChange( newValue );
+ const [ newQuantity, newUnit = 'px' ] =
+ parseQuantityAndUnitFromRawValue( newValue, units );
+
+ const isNewUnitRelative = isUnitRelative( newUnit );
+
+ // If switching to a relative unit and the value exceeds the max, clamp it
+ if ( isNewUnitRelative && newQuantity > maxRelativeValue ) {
+ onChange( maxRelativeValue + newUnit );
+ } else {
+ onChange( newValue );
+ }
};
// Receives the new value from the RangeControl component as a number.
@@ -75,7 +101,7 @@ function SizeControl( {
withInputField={ false }
onChange={ handleRangeControlChange }
min={ 0 }
- max={ isValueUnitRelative ? 10 : 100 }
+ max={ getMaxValue() }
step={ isValueUnitRelative ? 0.1 : 1 }
disabled={ disabled }
/>
diff --git a/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js b/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js
new file mode 100644
index 00000000000000..9672065c49c5b5
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/confirm-delete-spacing-dialog.js
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components';
+
+function ConfirmDeleteSpacingDialog( {
+ spacingSize,
+ isOpen,
+ toggleOpen,
+ handleRemoveSpacingSize,
+} ) {
+ const handleConfirm = () => {
+ handleRemoveSpacingSize();
+ toggleOpen();
+ };
+
+ const handleCancel = () => {
+ toggleOpen();
+ };
+
+ return (
+
+ { spacingSize &&
+ sprintf(
+ /* translators: %s: Name of the spacing size preset. */
+ __(
+ 'Are you sure you want to delete "%s" spacing size preset?'
+ ),
+ spacingSize.name
+ ) }
+
+ );
+}
+
+export default ConfirmDeleteSpacingDialog;
diff --git a/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js b/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js
new file mode 100644
index 00000000000000..c09240bc2bca93
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/confirm-reset-spacings-dialog.js
@@ -0,0 +1,41 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { __experimentalConfirmDialog as ConfirmDialog } from '@wordpress/components';
+
+function ConfirmResetSpacingsDialog( {
+ text,
+ confirmButtonText,
+ isOpen,
+ toggleOpen,
+ onConfirm,
+} ) {
+ const handleConfirm = () => {
+ onConfirm();
+ toggleOpen();
+ };
+
+ const handleCancel = () => {
+ toggleOpen();
+ };
+
+ if ( ! isOpen ) {
+ return null;
+ }
+
+ return (
+
+ { text }
+
+ );
+}
+
+export default ConfirmResetSpacingsDialog;
diff --git a/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js b/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js
new file mode 100644
index 00000000000000..9b4b653544e810
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/rename-spacing-dialog.js
@@ -0,0 +1,79 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ Button,
+ Modal,
+ __experimentalInputControl as InputControl,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+} from '@wordpress/components';
+import { useState } from '@wordpress/element';
+
+function RenameSpacingDialog( { spacingSize, toggleOpen, handleRename } ) {
+ const [ editedName, setEditedName ] = useState( spacingSize.name );
+
+ const handleConfirm = () => {
+ if ( editedName !== spacingSize.name ) {
+ handleRename( editedName );
+ }
+ toggleOpen();
+ };
+
+ const handleCancel = () => {
+ toggleOpen();
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default RenameSpacingDialog;
diff --git a/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js b/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js
new file mode 100644
index 00000000000000..1d79b1b302696f
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/spacing-preview.js
@@ -0,0 +1,47 @@
+/**
+ * Internal dependencies
+ */
+import { parseComparisonValue } from './utils';
+
+/**
+ * Renders a preview of a spacing size with visual representation.
+ *
+ * @param {Object} props Component props.
+ * @param {Object} props.spacingSize The spacing size object.
+ * @return {Element} The spacing preview component.
+ */
+function SpacingPreview( { spacingSize } ) {
+ // Handle fluid spacing values
+ const spacingValue = spacingSize?.size || '1rem';
+
+ // Parse CSS comparison functions (clamp, min, max)
+ const { displayValue, previewValue } = parseComparisonValue( spacingValue );
+
+ const hasFluidObject =
+ spacingSize?.fluid &&
+ typeof spacingSize.fluid === 'object' &&
+ spacingSize.fluid.min &&
+ spacingSize.fluid.preferred &&
+ spacingSize.fluid.max;
+
+ let boxSize = previewValue;
+ let label = displayValue;
+ if ( hasFluidObject ) {
+ const { min, preferred, max } = spacingSize.fluid;
+ const preferredValue = preferred || spacingSize.size || '1rem';
+ boxSize = preferredValue;
+ label = `${ min } → ${ max }`;
+ }
+
+ return (
+
+ );
+}
+
+export default SpacingPreview;
diff --git a/packages/edit-site/src/components/global-styles/spacing/spacing.js b/packages/edit-site/src/components/global-styles/spacing/spacing.js
new file mode 100644
index 00000000000000..0a909baf49a7ee
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/spacing.js
@@ -0,0 +1,342 @@
+/**
+ * WordPress dependencies
+ */
+import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ __experimentalSpacer as Spacer,
+ useNavigator,
+ __experimentalView as View,
+ __experimentalHStack as HStack,
+ __experimentalVStack as VStack,
+ privateApis as componentsPrivateApis,
+ Button,
+ FlexItem,
+ ToggleControl,
+} from '@wordpress/components';
+import { moreVertical } from '@wordpress/icons';
+import { useState, useEffect } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../../lock-unlock';
+import ScreenHeader from '../header';
+import SpacingPreview from './spacing-preview';
+import ConfirmDeleteSpacingDialog from './confirm-delete-spacing-dialog';
+import RenameSpacingDialog from './rename-spacing-dialog';
+import SizeControl from '../size-control';
+import { parseClampValue, generateClampValue } from './utils';
+
+const { Menu } = unlock( componentsPrivateApis );
+const { useGlobalSetting } = unlock( blockEditorPrivateApis );
+
+function Spacing() {
+ const [ isDeleteConfirmOpen, setIsDeleteConfirmOpen ] = useState( false );
+ const [ isRenameDialogOpen, setIsRenameDialogOpen ] = useState( false );
+
+ const {
+ params: { origin, slug },
+ goBack,
+ } = useNavigator();
+
+ const [ spacingSizes, setSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes'
+ );
+
+ // Get the spacing sizes from the origin, default to empty array.
+ const sizes = spacingSizes[ origin ] ?? [];
+
+ // Get the spacing size by slug.
+ const originalSpacingSize = sizes.find( ( size ) => size.slug === slug );
+
+ // Navigate to the spacing sizes list if the spacing size is not available.
+ useEffect( () => {
+ if ( !! slug && ! originalSpacingSize ) {
+ goBack();
+ }
+ }, [ slug, originalSpacingSize, goBack ] );
+
+ if ( ! origin || ! slug || ! originalSpacingSize ) {
+ return null;
+ }
+
+ // Parse clamp value if it exists
+ const clampValues = parseClampValue( originalSpacingSize.size );
+ const isClampValue = !! clampValues;
+
+ // Create normalized spacingSize for UI purposes
+ let spacingSize = originalSpacingSize;
+ if ( isClampValue ) {
+ spacingSize = {
+ ...originalSpacingSize,
+ size: clampValues.min,
+ fluid: {
+ min: clampValues.min,
+ preferred: clampValues.preferred,
+ max: clampValues.max,
+ },
+ };
+ }
+
+ // Whether the spacing size is fluid - check the original spacing size
+ // If fluid property is explicitly set, use that value.
+ // If no fluid property but there's a clamp value, default to true (fluid).
+ // Otherwise default to false.
+ const isFluid =
+ originalSpacingSize?.fluid !== undefined
+ ? !! originalSpacingSize.fluid
+ : isClampValue;
+
+ // Whether custom fluid values are used - check the original spacing size
+ // For clamp values without explicit fluid setting, they are considered custom fluid
+ const isCustomFluid =
+ typeof originalSpacingSize?.fluid === 'object' ||
+ ( isClampValue && originalSpacingSize?.fluid === undefined );
+
+ const handleNameChange = ( value ) => {
+ updateSpacingSize( 'name', value );
+ };
+
+ const handleSpacingSizeChange = ( value ) => {
+ updateSpacingSize( 'size', value );
+ };
+
+ const handleFluidChange = ( value ) => {
+ updateSpacingSize( 'fluid', value );
+ };
+
+ const handleCustomFluidValues = ( value ) => {
+ if ( value ) {
+ // If custom values are used, init the values with the current ones.
+ updateSpacingSize( 'fluid', {
+ min: spacingSize.size,
+ preferred: spacingSize.size,
+ max: spacingSize.size,
+ } );
+ } else {
+ // If custom fluid values are disabled, set fluid to true.
+ updateSpacingSize( 'fluid', true );
+ }
+ };
+
+ const handleMinChange = ( value ) => {
+ updateSpacingSize( 'fluid', { ...spacingSize.fluid, min: value } );
+ };
+
+ const handlePreferredChange = ( value ) => {
+ updateSpacingSize( 'fluid', {
+ ...spacingSize.fluid,
+ preferred: value,
+ } );
+ };
+
+ const handleMaxChange = ( value ) => {
+ updateSpacingSize( 'fluid', { ...spacingSize.fluid, max: value } );
+ };
+
+ const updateSpacingSize = ( key, value ) => {
+ const newSpacingSizes = sizes.map( ( size ) => {
+ if ( size.slug === slug ) {
+ let updatedSize = { ...size, [ key ]: value };
+
+ // Handle clamp values specially
+ if ( isClampValue ) {
+ if ( key === 'fluid' && typeof value === 'object' ) {
+ // Convert fluid object back to clamp format
+ const clampValue = generateClampValue(
+ value.min,
+ value.preferred,
+ value.max
+ );
+ updatedSize = { ...updatedSize, size: clampValue };
+ } else if ( key === 'fluid' && value === false ) {
+ // When disabling fluid, convert to min value
+ updatedSize = {
+ ...updatedSize,
+ size: clampValues.min,
+ fluid: false,
+ };
+ } else if ( key === 'fluid' && value === true ) {
+ // When enabling simple fluid, keep original clamp
+ updatedSize = {
+ ...updatedSize,
+ size: generateClampValue(
+ clampValues.min,
+ clampValues.preferred,
+ clampValues.max
+ ),
+ fluid: true,
+ };
+ }
+ }
+
+ return updatedSize;
+ }
+ return size;
+ } );
+
+ setSpacingSizes( {
+ ...spacingSizes,
+ [ origin ]: newSpacingSizes,
+ } );
+ };
+
+ const handleRemoveSpacingSize = () => {
+ const newSpacingSizes = sizes.filter( ( size ) => size.slug !== slug );
+ setSpacingSizes( {
+ ...spacingSizes,
+ [ origin ]: newSpacingSizes,
+ } );
+ };
+
+ const toggleDeleteConfirm = () => {
+ setIsDeleteConfirmOpen( ! isDeleteConfirmOpen );
+ };
+
+ const toggleRenameDialog = () => {
+ setIsRenameDialogOpen( ! isRenameDialogOpen );
+ };
+
+ return (
+ <>
+
+
+ { isRenameDialogOpen && (
+
+ ) }
+
+
+
+
+ { origin === 'custom' && (
+
+
+
+
+
+ ) }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { isFluid && (
+
+ ) }
+
+ { isFluid && isCustomFluid && (
+ <>
+
+
+
+ >
+ ) }
+
+
+
+
+ >
+ );
+}
+
+export default Spacing;
diff --git a/packages/edit-site/src/components/global-styles/spacing/spacings-count.js b/packages/edit-site/src/components/global-styles/spacing/spacings-count.js
new file mode 100644
index 00000000000000..763a313f9d3600
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/spacings-count.js
@@ -0,0 +1,37 @@
+/**
+ * WordPress dependencies
+ */
+import { __, isRTL } from '@wordpress/i18n';
+import {
+ __experimentalItemGroup as ItemGroup,
+ __experimentalVStack as VStack,
+ __experimentalHStack as HStack,
+ FlexItem,
+} from '@wordpress/components';
+import { Icon, chevronLeft, chevronRight } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import Subtitle from '../subtitle';
+import { NavigationButtonAsItem } from '../navigation-button';
+
+function Spacings() {
+ return (
+
+
+ { __( 'Spacing Sizes' ) }
+
+
+
+
+ { __( 'Spacing size presets' ) }
+
+
+
+
+
+ );
+}
+
+export default Spacings;
diff --git a/packages/edit-site/src/components/global-styles/spacing/spacings.js b/packages/edit-site/src/components/global-styles/spacing/spacings.js
new file mode 100644
index 00000000000000..03ac94b0d24a83
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/spacings.js
@@ -0,0 +1,261 @@
+/**
+ * WordPress dependencies
+ */
+import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
+import { __, sprintf, isRTL } from '@wordpress/i18n';
+import {
+ privateApis as componentsPrivateApis,
+ __experimentalSpacer as Spacer,
+ __experimentalView as View,
+ __experimentalItemGroup as ItemGroup,
+ __experimentalVStack as VStack,
+ __experimentalHStack as HStack,
+ FlexItem,
+ Button,
+} from '@wordpress/components';
+import {
+ Icon,
+ plus,
+ moreVertical,
+ chevronLeft,
+ chevronRight,
+} from '@wordpress/icons';
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../../lock-unlock';
+import Subtitle from '../subtitle';
+import { NavigationButtonAsItem } from '../navigation-button';
+import { getNewIndexFromPresets } from '../utils';
+import ScreenHeader from '../header';
+import ConfirmResetSpacingsDialog from './confirm-reset-spacings-dialog';
+
+const { Menu } = unlock( componentsPrivateApis );
+const { useGlobalSetting } = unlock( blockEditorPrivateApis );
+
+function SpacingGroup( {
+ label,
+ origin,
+ spacings,
+ handleAddSpacing,
+ handleResetSpacings,
+} ) {
+ const [ isResetDialogOpen, setIsResetDialogOpen ] = useState( false );
+
+ const toggleResetDialog = () => setIsResetDialogOpen( ! isResetDialogOpen );
+
+ const resetDialogText =
+ origin === 'custom'
+ ? __(
+ 'Are you sure you want to remove all custom spacing presets?'
+ )
+ : __(
+ 'Are you sure you want to reset all spacing presets to their default values?'
+ );
+
+ return (
+ <>
+ { isResetDialogOpen && (
+
+ ) }
+
+
+ { label }
+
+ { origin === 'custom' && (
+
+ ) }
+ { !! handleResetSpacings && (
+
+ ) }
+
+
+
+ { !! spacings.length && (
+
+ { spacings.map( ( spacing ) => (
+
+
+
+ { spacing.name }
+
+
+
+
+
+
+ ) ) }
+
+ ) }
+
+ >
+ );
+}
+
+function Spacings() {
+ const [ themeSpacingSizes, setThemeSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes.theme'
+ );
+
+ const [ baseThemeSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes.theme',
+ null,
+ 'base'
+ );
+ const [ defaultSpacingSizes, setDefaultSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes.default'
+ );
+
+ const [ baseDefaultSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes.default',
+ null,
+ 'base'
+ );
+
+ const [ customSpacingSizes = [], setCustomSpacingSizes ] = useGlobalSetting(
+ 'spacing.spacingSizes.custom'
+ );
+
+ const [ defaultSpacingSizesEnabled ] = useGlobalSetting(
+ 'spacing.defaultSpacingSizes'
+ );
+
+ const handleAddSpacing = () => {
+ const index = getNewIndexFromPresets( customSpacingSizes, 'custom-' );
+ const newSpacing = {
+ /* translators: %d: spacing size index */
+ name: sprintf( __( 'New Spacing Size %d' ), index ),
+ size: '1rem',
+ slug: `custom-${ index }`,
+ };
+
+ setCustomSpacingSizes( [ ...customSpacingSizes, newSpacing ] );
+ };
+
+ const hasSameSizeValues = ( arr1, arr2 ) =>
+ arr1.map( ( item ) => item.size ).join( '' ) ===
+ arr2.map( ( item ) => item.size ).join( '' );
+
+ return (
+
+
+
+
+
+
+ { !! themeSpacingSizes?.length && (
+
+ setThemeSpacingSizes(
+ baseThemeSpacingSizes
+ )
+ }
+ />
+ ) }
+
+ { defaultSpacingSizesEnabled &&
+ !! defaultSpacingSizes?.length && (
+
+ setDefaultSpacingSizes(
+ baseDefaultSpacingSizes
+ )
+ }
+ />
+ ) }
+
+ 0
+ ? () => setCustomSpacingSizes( [] )
+ : null
+ }
+ />
+
+
+
+
+ );
+}
+
+export default Spacings;
diff --git a/packages/edit-site/src/components/global-styles/spacing/utils.js b/packages/edit-site/src/components/global-styles/spacing/utils.js
new file mode 100644
index 00000000000000..c366691633d858
--- /dev/null
+++ b/packages/edit-site/src/components/global-styles/spacing/utils.js
@@ -0,0 +1,117 @@
+/**
+ * Parses a clamp() CSS function and extracts min, preferred, and max values.
+ *
+ * @param {string} value The CSS value to parse.
+ * @return {Object|null} Object with min, preferred, max properties or null if not a clamp function.
+ */
+export function parseClampValue( value ) {
+ if ( ! value || typeof value !== 'string' ) {
+ return null;
+ }
+
+ // Match clamp(min, preferred, max) pattern
+ const clampMatch = value.match(
+ /^clamp\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)$/
+ );
+ if ( clampMatch ) {
+ return {
+ min: clampMatch[ 1 ].trim(),
+ preferred: clampMatch[ 2 ].trim(),
+ max: clampMatch[ 3 ].trim(),
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Generates a clamp() CSS function from min, preferred, and max values.
+ *
+ * @param {string} min The minimum value.
+ * @param {string} preferred The preferred value.
+ * @param {string} max The maximum value.
+ * @return {string} The clamp() CSS function or empty string if invalid.
+ */
+export function generateClampValue( min, preferred, max ) {
+ if ( ! min || ! preferred || ! max ) {
+ return '';
+ }
+ return `clamp(${ min }, ${ preferred }, ${ max })`;
+}
+
+/**
+ * Parses a CSS value that may contain comparison functions (clamp, min, max)
+ * and returns both a display value and a preview value suitable for rendering.
+ *
+ * @param {string} value The CSS value to parse.
+ * @return {Object} Object with displayValue and previewValue properties.
+ */
+export function parseComparisonValue( value ) {
+ if ( ! value || typeof value !== 'string' ) {
+ return { displayValue: value, previewValue: value };
+ }
+
+ // Check if the value is a clamp() function
+ const clampMatch = value.match(
+ /^clamp\(\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)$/
+ );
+ if ( clampMatch ) {
+ const min = clampMatch[ 1 ].trim();
+ const preferred = clampMatch[ 2 ].trim();
+ const max = clampMatch[ 3 ].trim();
+ return {
+ displayValue: `${ min } → ${ max }`,
+ previewValue: preferred,
+ };
+ }
+
+ // Check if the value is a min() function
+ const minMatch = value.match( /^min\(\s*([^,]+)(?:\s*,\s*([^)]+))?\s*\)$/ );
+ if ( minMatch ) {
+ const values = [ minMatch[ 1 ].trim(), minMatch[ 2 ]?.trim() ]
+ .filter( Boolean )
+ .join( ', ' );
+ return {
+ displayValue: `min( ${ values } )`,
+ previewValue: minMatch[ 1 ].trim(),
+ };
+ }
+
+ // Check if the value is a max() function
+ const maxMatch = value.match( /^max\(\s*([^,]+)(?:\s*,\s*([^)]+))?\s*\)$/ );
+ if ( maxMatch ) {
+ const values = [ maxMatch[ 1 ].trim(), maxMatch[ 2 ]?.trim() ]
+ .filter( Boolean )
+ .join( ', ' );
+ return {
+ displayValue: `max( ${ values } )`,
+ previewValue: maxMatch[ 1 ].trim(),
+ };
+ }
+
+ // Return the value as-is if it doesn't match any pattern
+ return { displayValue: value, previewValue: value };
+}
+
+/**
+ * Determines if any spacing sizes are available to display from settings.
+ *
+ * @param {Object} settings Settings object from useSettingsForBlockElement.
+ * @return {boolean} True if there are spacing sizes available.
+ */
+export function hasAvailableSpacingSizes( settings ) {
+ const spacingSizes = settings?.spacing?.spacingSizes;
+ const defaultEnabled = settings?.spacing?.defaultSpacingSizes;
+
+ if ( ! spacingSizes ) {
+ return false;
+ }
+
+ return (
+ ( spacingSizes.custom && spacingSizes.custom.length > 0 ) ||
+ ( spacingSizes.theme && spacingSizes.theme.length > 0 ) ||
+ ( spacingSizes.default &&
+ spacingSizes.default.length > 0 &&
+ defaultEnabled !== false )
+ );
+}
diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss
index 080eb5ecd71d94..e6092a64ef8419 100644
--- a/packages/edit-site/src/components/global-styles/style.scss
+++ b/packages/edit-site/src/components/global-styles/style.scss
@@ -228,6 +228,36 @@
}
}
+// Spacing preview styles
+.edit-site-spacing-preview {
+ padding: $grid-unit-40;
+ min-height: 100px;
+ margin-bottom: $grid-unit-20;
+ background: $gray-100;
+ border-radius: $radius-small;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+}
+
+.edit-site-spacing-preview__box {
+ background-color: var(--wp-admin-theme-color);
+ border-radius: $radius-small;
+ min-width: $grid-unit-05;
+ min-height: $grid-unit-05;
+}
+
+.edit-site-spacing-preview__value {
+ position: absolute;
+ bottom: $grid-unit-10;
+ right: $grid-unit-15;
+ font-size: $font-size-small;
+ color: $gray-700;
+}
+
.edit-site-global-styles-sidebar__navigator-provider {
height: 100%;
}
diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js
index 22ebad383884e9..37ba71ec5fd5bb 100644
--- a/packages/edit-site/src/components/global-styles/ui.js
+++ b/packages/edit-site/src/components/global-styles/ui.js
@@ -35,6 +35,8 @@ import ScreenTypography from './screen-typography';
import ScreenTypographyElement from './screen-typography-element';
import FontSize from './font-sizes/font-size';
import FontSizes from './font-sizes/font-sizes';
+import Spacing from './spacing/spacing';
+import Spacings from './spacing/spacings';
import ScreenColors from './screen-colors';
import ScreenColorPalette from './screen-color-palette';
import ScreenBackground from './screen-background';
@@ -403,6 +405,14 @@ function GlobalStylesUI( { path, onPathChange } ) {
+
+
+
+
+
+
+
+