diff --git a/lib/block-supports/background.php b/lib/block-supports/background.php
new file mode 100644
index 00000000000000..ac734895663d5a
--- /dev/null
+++ b/lib/block-supports/background.php
@@ -0,0 +1,114 @@
+attributes ) {
+ $block_type->attributes = array();
+ }
+
+ if ( $has_background_image_support && ! array_key_exists( 'style', $block_type->attributes ) ) {
+ $block_type->attributes['style'] = array(
+ 'type' => 'object',
+ );
+ }
+}
+
+/**
+ * Checks whether serialization of the current block's background image properties should
+ * occur.
+ *
+ * @since 5.9.0
+ * @access private
+ *
+ * @param WP_Block_Type $block_type Block type.
+ * @return bool Whether to serialize spacing support styles & classes.
+ */
+function wp_skip_background_image_serialization( $block_type ) {
+ $background_image_support = _wp_array_get( $block_type->supports, array( '__experimentalBackgroundImage' ), false );
+
+ return is_array( $background_image_support ) &&
+ array_key_exists( '__experimentalSkipSerialization', $background_image_support ) &&
+ $background_image_support['__experimentalSkipSerialization'];
+}
+
+/**
+ * Renders the background image styles to the block wrapper.
+ * This block support uses the `render_block` hook to ensure that
+ * it is applied to non-server-rendered blocks.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function gutenberg_render_background_image_support( $block_content, $block ) {
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ $block_attributes = $block['attrs'];
+ $has_background_image_support = gutenberg_block_has_support( $block_type, array( '__experimentalBackgroundImage' ), false );
+ if ( ! $has_background_image_support || ! isset( $block_attributes['style']['backgroundImage'] ) ) {
+ return $block_content;
+ }
+
+ if ( wp_skip_background_image_serialization( $block_type ) ) {
+ return $block_content;
+ }
+
+ $styles = array();
+
+ $background_image_source = _wp_array_get( $block_attributes, array( 'style', 'backgroundImage', 'source' ), null );
+ $background_image_url = _wp_array_get( $block_attributes, array( 'style', 'backgroundImage', 'url' ), null );
+
+ if (
+ 'file' === $background_image_source &&
+ $background_image_url
+ ) {
+ $styles[] = sprintf( "background-image: url('%s')", esc_url( $background_image_url ) );
+ }
+
+ $inline_style = safecss_filter_attr( implode( '; ', $styles ) );
+
+ // Attempt to update an existing style attribute on the wrapper element.
+ $injected_style = preg_replace(
+ '/^([^>.]+?)(' . preg_quote( 'style="', '/' ) . ')(?=.+?>)/',
+ '$1$2' . $inline_style . '; ',
+ $block_content,
+ 1
+ );
+
+ // If there is no existing style attribute, add one to the wrapper element.
+ if ( $injected_style === $block_content ) {
+ $injected_style = preg_replace(
+ '/<([a-zA-Z0-9]+)([ >])/',
+ '<$1 style="' . $inline_style . '"$2',
+ $block_content,
+ 1
+ );
+ };
+
+ return $injected_style;
+}
+
+// Register the block support.
+WP_Block_Supports::get_instance()->register(
+ 'backgroundImage',
+ array(
+ 'register_attribute' => 'wp_register_background_image_support',
+ )
+);
+
+add_filter( 'render_block', 'gutenberg_render_background_image_support', 10, 2 );
diff --git a/lib/compat/wordpress-5.9/theme.json b/lib/compat/wordpress-5.9/theme.json
index ec29439d7f13f2..5f437713c9ed77 100644
--- a/lib/compat/wordpress-5.9/theme.json
+++ b/lib/compat/wordpress-5.9/theme.json
@@ -2,6 +2,7 @@
"version": 2,
"settings": {
"appearanceTools": false,
+ "backgroundImage": true,
"border": {
"color": false,
"radius": false,
diff --git a/lib/compat/wordpress-6.0/class-wp-theme-json-gutenberg.php b/lib/compat/wordpress-6.0/class-wp-theme-json-gutenberg.php
index c68f6fb3d205a7..75b8ac76fe6789 100644
--- a/lib/compat/wordpress-6.0/class-wp-theme-json-gutenberg.php
+++ b/lib/compat/wordpress-6.0/class-wp-theme-json-gutenberg.php
@@ -16,6 +16,58 @@
*/
class WP_Theme_JSON_Gutenberg extends WP_Theme_JSON_5_9 {
+ /**
+ * The valid properties under the settings key.
+ *
+ * @var array
+ */
+ const VALID_SETTINGS = array(
+ 'appearanceTools' => null,
+ 'backgroundImage' => null,
+ 'border' => array(
+ 'color' => null,
+ 'radius' => null,
+ 'style' => null,
+ 'width' => null,
+ ),
+ 'color' => array(
+ 'background' => null,
+ 'custom' => null,
+ 'customDuotone' => null,
+ 'customGradient' => null,
+ 'defaultGradients' => null,
+ 'defaultPalette' => null,
+ 'duotone' => null,
+ 'gradients' => null,
+ 'link' => null,
+ 'palette' => null,
+ 'text' => null,
+ ),
+ 'custom' => null,
+ 'layout' => array(
+ 'contentSize' => null,
+ 'wideSize' => null,
+ ),
+ 'spacing' => array(
+ 'blockGap' => null,
+ 'margin' => null,
+ 'padding' => null,
+ 'units' => null,
+ ),
+ 'typography' => array(
+ 'customFontSize' => null,
+ 'dropCap' => null,
+ 'fontFamilies' => null,
+ 'fontSizes' => null,
+ 'fontStyle' => null,
+ 'fontWeight' => null,
+ 'letterSpacing' => null,
+ 'lineHeight' => null,
+ 'textDecoration' => null,
+ 'textTransform' => null,
+ ),
+ );
+
/**
* The top-level keys a theme.json can have.
*
diff --git a/lib/load.php b/lib/load.php
index 26cd50608dbdc1..74a17fd17ede17 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -119,6 +119,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/global-styles.php';
require __DIR__ . '/pwa.php';
+require __DIR__ . '/block-supports/background.php';
require __DIR__ . '/block-supports/elements.php';
require __DIR__ . '/block-supports/colors.php';
require __DIR__ . '/block-supports/typography.php';
diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js
index e3061f0004af45..61ead4907e1965 100644
--- a/packages/block-editor/src/components/block-inspector/index.js
+++ b/packages/block-editor/src/components/block-inspector/index.js
@@ -143,6 +143,10 @@ const BlockInspectorSingleBlock = ( {
) }
+
{
const [ mediaURLValue, setMediaURLValue ] = useState( mediaURL );
const mediaUpload = useSelect( ( select ) => {
@@ -148,6 +149,7 @@ const MediaReplaceFlow = ( {
aria-haspopup="true"
onClick={ onToggle }
onKeyDown={ openOnArrowDown }
+ variant={ variant }
>
{ name }
diff --git a/packages/block-editor/src/hooks/backgroundImage.js b/packages/block-editor/src/hooks/backgroundImage.js
new file mode 100644
index 00000000000000..a911ea559e8aa3
--- /dev/null
+++ b/packages/block-editor/src/hooks/backgroundImage.js
@@ -0,0 +1,191 @@
+/**
+ * WordPress dependencies
+ */
+import { getBlobTypeByURL, isBlobURL } from '@wordpress/blob';
+import { getBlockSupport } from '@wordpress/blocks';
+import { __experimentalToolsPanelItem as ToolsPanelItem } from '@wordpress/components';
+import { Platform } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import InspectorControls from '../components/inspector-controls';
+import MediaReplaceFlow from '../components/media-replace-flow';
+
+import useSetting from '../components/use-setting';
+import { cleanEmptyObject } from './utils';
+
+export const BACKGROUND_IMAGE_SUPPORT_KEY = '__experimentalBackgroundImage';
+export const IMAGE_BACKGROUND_TYPE = 'image';
+
+export function BackgroundImagePanel( props ) {
+ const { attributes, clientId, setAttributes } = props;
+
+ const { id, url } = attributes.style?.backgroundImage || {};
+
+ const onSelectMedia = ( media ) => {
+ if ( ! media || ! media.url ) {
+ setAttributes( { url: undefined, id: undefined } );
+ return;
+ }
+
+ if ( isBlobURL( media.url ) ) {
+ media.type = getBlobTypeByURL( media.url );
+ }
+
+ // For media selections originated from a file upload.
+ if ( media.media_type && media.media_type !== IMAGE_BACKGROUND_TYPE ) {
+ return;
+ } else if ( media.type !== IMAGE_BACKGROUND_TYPE ) {
+ // For media selections originated from existing files in the media library.
+ return;
+ }
+
+ const newStyle = {
+ ...attributes.style,
+ backgroundImage: {
+ ...attributes.style?.backgroundImage,
+ ...{
+ url: media.url,
+ id: media.id,
+ source: 'file',
+ },
+ },
+ };
+
+ const newAttributes = {
+ style: cleanEmptyObject( newStyle ),
+ };
+
+ setAttributes( newAttributes );
+ };
+
+ const isBackgroundImageSupported =
+ useSetting( 'backgroundImage' ) &&
+ hasBackgroundImageSupport( props.name );
+
+ const isDisabled = [ ! isBackgroundImageSupported ].every( Boolean );
+
+ if ( isDisabled ) {
+ return null;
+ }
+
+ const createResetAllFilter = (
+ backgroundImageAttributes,
+ topLevelAttributes = {}
+ ) => ( newAttributes ) => ( {
+ ...newAttributes,
+ ...topLevelAttributes,
+ style: removeBackgroundImageAttributes(
+ newAttributes.style,
+ backgroundImageAttributes
+ ),
+ } );
+
+ return (
+
+ { isBackgroundImageSupported && (
+ hasBackgroundImageValue( props ) }
+ label={ __( 'Image' ) }
+ onDeselect={ () => resetBackgroundImage( props ) }
+ isShownByDefault={ true }
+ resetAllFilter={ createResetAllFilter( [ 'url', 'id' ] ) }
+ panelId={ clientId }
+ >
+
+
+ ) }
+
+ );
+}
+
+/**
+ * Checks if there is a current value in the background image block support
+ * attributes.
+ *
+ * @param {Object} props Block props.
+ * @return {boolean} Whether or not the block has a background image value set.
+ */
+export function hasBackgroundImageValue( props ) {
+ const hasValue =
+ !! props.attributes.style?.backgroundImage?.id ||
+ !! props.attributes.style?.backgroundImage?.url;
+
+ return hasValue;
+}
+
+/**
+ * Determine whether there is block support for background image.
+ *
+ * @param {string} blockName Block name.
+ * @param {string} feature Background image feature to check for.
+ *
+ * @return {boolean} Whether there is support.
+ */
+export function hasBackgroundImageSupport( blockName, feature = 'any' ) {
+ if ( Platform.OS !== 'web' ) {
+ return false;
+ }
+
+ const support = getBlockSupport( blockName, BACKGROUND_IMAGE_SUPPORT_KEY );
+
+ if ( support === true ) {
+ return true;
+ }
+
+ return !! support?.[ feature ];
+}
+
+/**
+ * Check whether serialization of background image classes and styles should be skipped.
+ *
+ * @param {string|Object} blockType Block name or block type object.
+ *
+ * @return {boolean} Whether serialization of border properties should occur.
+ */
+export function shouldSkipSerialization( blockType ) {
+ const support = getBlockSupport( blockType, BACKGROUND_IMAGE_SUPPORT_KEY );
+
+ return support?.__experimentalSkipSerialization;
+}
+
+export function resetBackgroundImage( { attributes = {}, setAttributes } ) {
+ const { style } = attributes;
+ setAttributes( {
+ style: removeBackgroundImageAttributes( style, [ 'url', 'id' ] ),
+ } );
+}
+
+/**
+ * Returns a new style object where the specified border attribute has been
+ * removed.
+ *
+ * @param {Object} style Styles from block attributes.
+ * @param {string} attributes The background image style attributes to clear.
+ *
+ * @return {Object} Style object with the specified attribute removed.
+ */
+export function removeBackgroundImageAttributes( style, attributes ) {
+ const clearedAttributes = {};
+ attributes?.forEach(
+ ( attribute ) => ( clearedAttributes[ attribute ] = undefined )
+ );
+ return cleanEmptyObject( {
+ ...style,
+ backgroundImage: {
+ ...style?.backgroundImage,
+ ...clearedAttributes,
+ },
+ } );
+}
diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js
index a5f30d5c1e1333..2a6570bfa91620 100644
--- a/packages/block-editor/src/hooks/style.js
+++ b/packages/block-editor/src/hooks/style.js
@@ -33,6 +33,10 @@ import { getCSSRules } from '@wordpress/style-engine';
* Internal dependencies
*/
import BlockList from '../components/block-list';
+import {
+ BACKGROUND_IMAGE_SUPPORT_KEY,
+ BackgroundImagePanel,
+} from './backgroundImage';
import { BORDER_SUPPORT_KEY, BorderPanel } from './border';
import { COLOR_SUPPORT_KEY, ColorEdit } from './color';
import {
@@ -45,6 +49,7 @@ import useDisplayBlockControls from '../components/use-display-block-controls';
const styleSupportKeys = [
...TYPOGRAPHY_SUPPORT_KEYS,
+ BACKGROUND_IMAGE_SUPPORT_KEY,
BORDER_SUPPORT_KEY,
COLOR_SUPPORT_KEY,
SPACING_SUPPORT_KEY,
@@ -197,6 +202,7 @@ const skipSerializationPathsEdit = {
*/
const skipSerializationPathsSave = {
...skipSerializationPathsEdit,
+ [ `${ BACKGROUND_IMAGE_SUPPORT_KEY }` ]: [ 'backgroundImage' ],
[ `${ SPACING_SUPPORT_KEY }` ]: [ 'spacing.blockGap' ],
};
@@ -283,6 +289,7 @@ export const withBlockControls = createHigherOrderComponent(
<>
{ shouldDisplayControls && (
<>
+
diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json
index bd79422311dbb3..1203119aa62466 100644
--- a/packages/block-library/src/group/block.json
+++ b/packages/block-library/src/group/block.json
@@ -29,6 +29,7 @@
"text": true
}
},
+ "__experimentalBackgroundImage": true,
"spacing": {
"margin": [ "top", "bottom" ],
"padding": true,
diff --git a/packages/style-engine/src/styles/backgroundImage.ts b/packages/style-engine/src/styles/backgroundImage.ts
new file mode 100644
index 00000000000000..70601e7bf50f40
--- /dev/null
+++ b/packages/style-engine/src/styles/backgroundImage.ts
@@ -0,0 +1,29 @@
+/**
+ * Internal dependencies
+ */
+import type { GeneratedCSSRule, Style, StyleOptions } from '../types';
+
+const padding = {
+ name: 'backgroundImage',
+ generate: ( style: Style, options: StyleOptions ) => {
+ const backgroundImage = style?.backgroundImage;
+
+ const styleRules: GeneratedCSSRule[] = [];
+
+ if ( ! backgroundImage ) {
+ return styleRules;
+ }
+
+ if ( backgroundImage?.source === 'file' && backgroundImage?.url ) {
+ styleRules.push( {
+ selector: options.selector,
+ key: 'backgroundImage',
+ value: `url( '${ backgroundImage.url }' )`,
+ } );
+ }
+
+ return styleRules;
+ },
+};
+
+export default padding;
diff --git a/packages/style-engine/src/styles/index.ts b/packages/style-engine/src/styles/index.ts
index 2f09e428176937..c77b29916cc589 100644
--- a/packages/style-engine/src/styles/index.ts
+++ b/packages/style-engine/src/styles/index.ts
@@ -1,6 +1,7 @@
/**
* Internal dependencies
*/
+import backgroundImage from './backgroundImage';
import padding from './padding';
-export const styleDefinitions = [ padding ];
+export const styleDefinitions = [ backgroundImage, padding ];
diff --git a/packages/style-engine/src/types.ts b/packages/style-engine/src/types.ts
index 48bd9a0d8e5c6e..0b13bfd11a372f 100644
--- a/packages/style-engine/src/types.ts
+++ b/packages/style-engine/src/types.ts
@@ -12,6 +12,10 @@ export type Box< T extends BoxVariants = undefined > = {
};
export interface Style {
+ backgroundImage?: {
+ url?: CSSProperties[ 'backgroundImage' ];
+ source?: string;
+ };
spacing?: {
margin?: CSSProperties[ 'margin' ] | Box< 'margin' >;
padding?: CSSProperties[ 'padding' ] | Box< 'padding' >;