diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js
index 9b094396d9d8b..c7792608518ac 100644
--- a/packages/block-editor/src/components/index.js
+++ b/packages/block-editor/src/components/index.js
@@ -32,6 +32,7 @@ export { default as MediaUpload } from './media-upload';
export { default as MediaUploadCheck } from './media-upload/check';
export { default as PanelColorSettings } from './panel-color-settings';
export { default as PlainText } from './plain-text';
+export { default as __experimentalResponsiveBlockControl } from './responsive-block-control';
export {
default as RichText,
RichTextShortcut,
diff --git a/packages/block-editor/src/components/responsive-block-control/README.md b/packages/block-editor/src/components/responsive-block-control/README.md
new file mode 100644
index 0000000000000..deabc0bc4af4c
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/README.md
@@ -0,0 +1,237 @@
+ResponsiveBlockControl
+=============================
+
+`ResponsiveBlockControl` provides a standardised interface for the creation of Block controls that require **different settings per viewport** (ie: "responsive" settings).
+
+For example, imagine your Block provides a control which affords the ability to change a "padding" value used in the Block display. Consider that whilst this setting may work well on "large" screens, the same value may not work well on smaller screens (it may be too large for example). As a result, you now need to provide a padding control _per viewport/screensize_.
+
+`ResponsiveBlockControl` provides a standardised component for the creation of such interfaces within Gutenberg.
+
+Complete control over rendering the controls is provided and the viewport sizes used are entirely customisable.
+
+Note that `ResponsiveBlockControl` does not handle any persistence of your control values. The control you provide to `ResponsiveBlockControl` as the `renderDefaultControl` prop should take care of this.
+
+## Usage
+
+In a block's `edit` implementation, render a ` ` component passing the required props plus:
+
+1. a `renderDefaultControl` function which renders an interface control.
+2. an boolean state for `isResponsive` (see "Props" below).
+3. a handler function for `onIsResponsiveChange` (see "Props" below).
+
+
+By default the default control will be used to render the default (ie: "All") setting _as well as_ the per-viewport responsive settings.
+
+```jsx
+import { registerBlockType } from '@wordpress/blocks';
+import {
+ InspectorControls,
+ ResponsiveBlockControl,
+} from '@wordpress/block-editor';
+
+import { useState } from '@wordpress/element';
+
+import {
+ DimensionControl,
+} from '@wordpress/components';
+
+registerBlockType( 'my-plugin/my-block', {
+ // ...
+
+ edit( { attributes, setAttributes } ) {
+
+ const [ isResponsive, setIsResponsive ] = useState( false );
+
+ // Used for example purposes only
+ const sizeOptions = [
+ {
+ label: 'Small',
+ value: 'small',
+ },
+ {
+ label: 'Medium',
+ value: 'medium',
+ },
+ {
+ label: 'Large',
+ value: 'large',
+ },
+ ];
+
+ const { paddingSize } = attributes;
+
+
+ // Your custom control can be anything you'd like to use.
+ // You are not restricted to `DimensionControl`s, but this
+ // makes life easier if dealing with standard CSS values.
+ // see `packages/components/src/dimension-control/README.md`
+ const paddingControl = ( labelComponent, viewport ) => {
+ return (
+
+ );
+ };
+
+ return (
+ <>
+
+ {
+ setIsResponsive( ! isResponsive );
+ } }
+ />
+
+
+ // your Block here
+
+ >
+ );
+ }
+} );
+```
+
+## Props
+
+### `title`
+* **Type:** `String`
+* **Default:** `undefined`
+* **Required:** `true`
+
+The title of the control group used in the `fieldset`'s `legend` element to label the _entire_ set of controls.
+
+### `property`
+* **Type:** `String`
+* **Default:** `undefined`
+* **Required:** `true`
+
+Used to build accessible labels and ARIA roles for the control group. Should represent the layout property which the component controls (eg: `padding`, `margin`...etc).
+
+### `isResponsive`
+* **Type:** `Boolean`
+* **Default:** `false` )
+* **Required:** `false`
+
+Determines whether the component displays the default or responsive controls. Updates the state of the toggle control. See also `onIsResponsiveChange` below.
+
+### `onIsResponsiveChange`
+* **Type:** `Function`
+* **Default:** `undefined`
+* **Required:** `true`
+
+A callback function invoked when the component's toggle value is changed between responsive and non-responsive mode. Should be used to update the value of the `isResponsive` prop to reflect the current state of the toggle control.
+
+### `renderDefaultControl`
+* **Type:** `Function`
+* **Default:** `undefined`
+* **Required:** `true`
+* **Args:**
+ - **labelComponent:** (`Function`) - a rendered `ResponsiveBlockControlLabel` component for your control.
+ - **viewport:** (`Object`) - an object representing viewport attributes for your control.
+
+A render function (prop) used to render the control for which you would like to display per viewport settings.
+
+For example, if you have a `SelectControl` which controls padding size, then pass this component as `renderDefaultControl` and it will be used to render both default and "responsive" controls for "padding".
+
+The component you return from this function will be used to render the control displayed for the (default) "All" state and (if the `renderResponsiveControls` is not provided) the individual responsive controls when in "responsive" mode.
+
+It is passed a pre-created, accessible ``. Your control may also use the contextual information provided by the `viewport` argument to ensure your component renders appropriately depending on the `viewport` setting currently being rendered (eg: `All` or one of the responsive variants).
+
+__Note:__ you are required to handle persisting any state produced by the component you pass as `renderDefaultControl`. `ResponsiveBlockControl` is "controlled" and does not persist state in any form.
+
+```jsx
+const renderDefaultControl = ( labelComponent, viewport ) => {
+ const { id, label } = viewport;
+ // eg:
+ // {
+ // id: 'small',
+ // label: 'All'
+ // }
+ return (
+
+ );
+};
+```
+
+### `renderResponsiveControls`
+* **Type:** `Function`
+* **Default:** `undefined`
+* **Required:** `false`
+* **Args:**
+ - **viewports:** (`Array`) - an array of viewport `Object`s, each with an `id` and `label` property.
+
+
+An optional render function (prop) used to render the controls for the _responsive_ settings. If not provided, by default, responsive controls will be _automatically_ rendered using the component returned by the `renderDefaultControl` prop. For _complete_ control over the output of the responsive controls, you may return a component here and it will be rendered when the control group is in "responsive" mode.
+
+```jsx
+const renderResponsiveControls = (viewports) => {
+ const inputId = uniqueId(); // lodash
+
+ return viewports.map( ( { id, label } ) => {
+ return (
+
+ Custom Viewport { label }
+
+
+ );
+ } );
+}
+```
+
+### `toggleLabel`
+* **Type:** `String`
+* **Default:** `Use the same %s on all screensizes.` (where "%s" is the `property` prop - see above )
+* **Required:** `false`
+
+Optional label used for the toggle control which switches the interface between showing responsive controls or not.
+
+### `defaultLabel`
+* **Type:** `Object`
+* **Default:**
+```js
+{
+ id: 'all',
+ label: 'All',
+}
+```
+* **Required:** `false`
+
+Optional object describing the attributes of the default value. By default this is `All` which indicates the control will affect "all viewports/screensizes".
+
+### `viewports`
+* **Type:** `Array`
+* **Default:**
+```js
+[
+ {
+ id: 'small',
+ label: 'Small screens',
+ },
+ {
+ id: 'medium',
+ label: 'Medium screens',
+ },
+ {
+ id: 'large',
+ label: 'Large screens',
+ },
+]
+```
+* **Required:** `false`
+
+An array of viewport objects, each describing a configuration for a particular viewport size. These are used to determine the number of responsive controls to display and the configuration of each.
+
+
+
diff --git a/packages/block-editor/src/components/responsive-block-control/index.js b/packages/block-editor/src/components/responsive-block-control/index.js
new file mode 100644
index 0000000000000..c4f47d1169128
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/index.js
@@ -0,0 +1,97 @@
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+
+import { Fragment } from '@wordpress/element';
+
+import {
+ ToggleControl,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ResponsiveBlockControlLabel from './label';
+
+function ResponsiveBlockControl( props ) {
+ const {
+ title,
+ property,
+ toggleLabel,
+ onIsResponsiveChange,
+ renderDefaultControl,
+ renderResponsiveControls,
+ isResponsive = false,
+ defaultLabel = {
+ id: 'all',
+ label: __( 'All' ), /* translators: 'Label. Used to signify a layout property (eg: margin, padding) will apply uniformly to all screensizes.' */
+ },
+ viewports = [
+ {
+ id: 'small',
+ label: __( 'Small screens' ),
+ },
+ {
+ id: 'medium',
+ label: __( 'Medium screens' ),
+ },
+ {
+ id: 'large',
+ label: __( 'Large screens' ),
+ },
+ ],
+ } = props;
+
+ if ( ! title || ! property || ! renderDefaultControl ) {
+ return null;
+ }
+
+ /* translators: 'Toggle control label. Should the property be the same across all screen sizes or unique per screen size.'. %s property value for the control (eg: margin, padding...etc) */
+ const toggleControlLabel = toggleLabel || sprintf( __( 'Use the same %s on all screensizes.', ), property );
+
+ /* translators: 'Help text for the responsive mode toggle control.' */
+ const toggleHelpText = __( 'Toggle between using the same value for all screen sizes or using a unique value per screen size.' );
+
+ const defaultControl = renderDefaultControl( , defaultLabel );
+
+ const defaultResponsiveControls = () => {
+ return viewports.map( ( viewport ) => (
+
+ { renderDefaultControl( , viewport ) }
+
+ ) );
+ };
+
+ return (
+
+
+ { title }
+
+
+
+
+ { ! isResponsive && (
+
+ { defaultControl }
+
+ ) }
+
+ { isResponsive && (
+
+ { ( renderResponsiveControls ? renderResponsiveControls( viewports ) : defaultResponsiveControls() ) }
+
+ ) }
+
+
+
+ );
+}
+
+export default ResponsiveBlockControl;
diff --git a/packages/block-editor/src/components/responsive-block-control/label.js b/packages/block-editor/src/components/responsive-block-control/label.js
new file mode 100644
index 0000000000000..829af3aadec42
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/label.js
@@ -0,0 +1,21 @@
+/**
+ * WordPress dependencies
+ */
+import { withInstanceId } from '@wordpress/compose';
+import { _x, sprintf } from '@wordpress/i18n';
+import { Fragment } from '@wordpress/element';
+
+const ResponsiveBlockControlLabel = ( { instanceId, property, viewport, desc } ) => {
+ const accessibleLabel = desc || sprintf( _x( 'Controls the %1$s property for %2$s viewports.', 'Text labelling a interface as controlling a given layout property (eg: margin) for a given screen size.' ), property, viewport.label );
+ return (
+
+
+ { viewport.label }
+
+ { accessibleLabel }
+
+ );
+};
+
+export default withInstanceId( ResponsiveBlockControlLabel );
+
diff --git a/packages/block-editor/src/components/responsive-block-control/style.scss b/packages/block-editor/src/components/responsive-block-control/style.scss
new file mode 100644
index 0000000000000..bb1ffd6683acc
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/style.scss
@@ -0,0 +1,47 @@
+@mixin screen-reader-text() {
+ border: 0;
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+ word-wrap: normal !important;
+}
+
+.block-editor-responsive-block-control {
+ margin-bottom: $block-padding*2;
+ border-bottom: 1px solid $light-gray-600;
+ padding-bottom: $block-padding;
+
+ &:last-child {
+ padding-bottom: 0;
+ border-bottom: 0;
+ }
+}
+
+.block-editor-responsive-block-control__title {
+ margin: 0;
+ margin-bottom: 0.6em;
+ margin-left: -3px;
+}
+
+.block-editor-responsive-block-control__label {
+ font-weight: 600;
+ margin-bottom: 0.6em;
+ margin-left: -3px; // visual compensation
+}
+
+.block-editor-responsive-block-control__inner {
+ margin-left: -1px; // visual compensation
+}
+
+.block-editor-responsive-block-control__toggle {
+ margin-left: 1px;
+}
+
+.block-editor-responsive-block-control .components-base-control__help {
+ @include screen-reader-text();
+}
diff --git a/packages/block-editor/src/components/responsive-block-control/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/responsive-block-control/test/__snapshots__/index.js.snap
new file mode 100644
index 0000000000000..d05674f87f31d
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/test/__snapshots__/index.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Basic rendering should render with required props 1`] = `"Padding Toggle between using the same value for all screen sizes or using a unique value per screen size.
All Controls the padding property for All viewports. Please select Small Medium Large
All is used here for testing purposes to ensure we have access to details about the device.
"`;
diff --git a/packages/block-editor/src/components/responsive-block-control/test/index.js b/packages/block-editor/src/components/responsive-block-control/test/index.js
new file mode 100644
index 0000000000000..877bddb0dfb91
--- /dev/null
+++ b/packages/block-editor/src/components/responsive-block-control/test/index.js
@@ -0,0 +1,354 @@
+/**
+ * External dependencies
+ */
+import { render, unmountComponentAtNode } from 'react-dom';
+import { act, Simulate } from 'react-dom/test-utils';
+import { uniqueId } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Fragment, useState } from '@wordpress/element';
+
+import {
+ SelectControl,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ResponsiveBlockControl from '../index';
+
+let container = null;
+beforeEach( () => {
+ // setup a DOM element as a render target
+ container = document.createElement( 'div' );
+ document.body.appendChild( container );
+} );
+
+afterEach( () => {
+ // cleanup on exiting
+ unmountComponentAtNode( container );
+ container.remove();
+ container = null;
+} );
+
+const inputId = uniqueId();
+
+const sizeOptions = [
+ {
+ label: 'Please select',
+ value: '',
+ },
+ {
+ label: 'Small',
+ value: 'small',
+ },
+ {
+ label: 'Medium',
+ value: 'medium',
+ },
+ {
+ label: 'Large',
+ value: 'large',
+ },
+];
+
+const renderTestDefaultControlComponent = ( labelComponent, device ) => {
+ return (
+
+
+
+ { device.label } is used here for testing purposes to ensure we have access
+ to details about the device.
+
+
+ );
+};
+
+describe( 'Basic rendering', () => {
+ it( 'should render with required props', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ const activePropertyLabel = Array.from( container.querySelectorAll( 'legend' ) ).find( ( legend ) => legend.innerHTML === 'Padding' );
+
+ const activeViewportLabel = Array.from( container.querySelectorAll( 'label' ) ).find( ( label ) => label.innerHTML.includes( 'All' ) );
+
+ const defaultControl = container.querySelector( `#${ activeViewportLabel.getAttribute( 'for' ) }` );
+
+ const toggleLabel = Array.from( container.querySelectorAll( 'label' ) ).filter( ( label ) => label.innerHTML.includes( 'Use the same padding on all screensizes' ) );
+
+ const toggleState = container.querySelector( 'input[type="checkbox"]' ).checked;
+
+ const defaultControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--default' );
+
+ const responsiveControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--responsive' );
+
+ expect( container.innerHTML ).not.toBe( '' );
+
+ expect( defaultControlGroup ).not.toBeNull();
+ expect( responsiveControlGroup ).toBeNull();
+
+ expect( activeViewportLabel ).not.toBeNull();
+ expect( activePropertyLabel ).not.toBeNull();
+ expect( defaultControl ).not.toBeNull();
+ expect( toggleLabel ).not.toBeNull();
+ expect( toggleState ).toBe( true );
+ expect( container.innerHTML ).toMatchSnapshot();
+ } );
+
+ it( 'should not render without valid legend', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ expect( container.innerHTML ).toBe( '' );
+ } );
+
+ it( 'should not render without valid property', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ expect( container.innerHTML ).toBe( '' );
+ } );
+
+ it( 'should not render without valid default control render prop', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ expect( container.innerHTML ).toBe( '' );
+ } );
+
+ it( 'should render with custom label for toggle control when provided', () => {
+ const customToggleLabel = 'Utilise a matching padding value on all viewports';
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ const actualToggleLabel = container.querySelector( 'label.components-toggle-control__label' ).innerHTML;
+
+ expect( actualToggleLabel ).toEqual( customToggleLabel );
+ } );
+
+ it( 'should pass custom label for default control group to the renderDefaultControl function when provided', () => {
+ const customDefaultControlGroupLabel = 'Everything';
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ const defaultControlLabel = Array.from( container.querySelectorAll( 'label' ) ).find( ( label ) => label.innerHTML.includes( 'Everything' ) );
+
+ expect( defaultControlLabel ).not.toBeNull();
+ } );
+} );
+
+describe( 'Default and Responsive modes', () => {
+ it( 'should render in responsive mode when isResponsive prop is set to true', () => {
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ const defaultControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--default' );
+ const responsiveControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--responsive' );
+
+ expect( defaultControlGroup ).toBeNull();
+ expect( responsiveControlGroup ).not.toBeNull();
+ } );
+
+ it( 'should render controls for a set of custom viewports in responsive mode when provided', () => {
+ const customViewportSet = [
+ {
+ id: 'tiny',
+ label: 'Tiny',
+ },
+ {
+ id: 'small',
+ label: 'Small',
+ },
+ {
+ id: 'medium',
+ label: 'Medium',
+ },
+ {
+ id: 'huge',
+ label: 'Huge',
+ },
+ ];
+
+ const mockRenderDefaultControl = jest.fn( renderTestDefaultControlComponent );
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ const defaultRenderControlCall = 1;
+
+ // Get array of labels which match those in the custom viewports provided
+ const responsiveViewportsLabels = Array.from( container.querySelectorAll( 'label' ) ).filter( ( label ) => {
+ const labelText = label.innerHTML;
+ // Is the label one of those in the custom device set?
+ return !! customViewportSet.find( ( deviceName ) => labelText.includes( deviceName.label ) );
+ } );
+
+ expect( responsiveViewportsLabels ).toHaveLength( customViewportSet.length );
+ expect( mockRenderDefaultControl ).toHaveBeenCalledTimes( customViewportSet.length + defaultRenderControlCall );
+ } );
+
+ it( 'should switch between default and responsive modes when interacting with toggle control', () => {
+ const ResponsiveBlockControlConsumer = () => {
+ const [ isResponsive, setIsResponsive ] = useState( false );
+
+ return (
+ {
+ setIsResponsive( ! isResponsive );
+ } }
+ renderDefaultControl={ renderTestDefaultControlComponent }
+ />
+ );
+ };
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ let defaultControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--default' );
+ let responsiveControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--responsive' );
+
+ // Select elements based on what the user can see
+ const toggleLabel = Array.from( container.querySelectorAll( 'label' ) ).find( ( label ) => label.innerHTML.includes( 'Use the same padding on all screensizes' ) );
+ const toggleInput = container.querySelector( `#${ toggleLabel.getAttribute( 'for' ) }` );
+
+ // Initial mode (default)
+ expect( defaultControlGroup ).not.toBeNull();
+ expect( responsiveControlGroup ).toBeNull();
+
+ // Toggle to "responsive" mode
+ act( () => {
+ Simulate.change( toggleInput, { target: { checked: false } } );
+ } );
+
+ defaultControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--default' );
+ responsiveControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--responsive' );
+
+ expect( defaultControlGroup ).toBeNull();
+ expect( responsiveControlGroup ).not.toBeNull();
+
+ // Toggle back to "default" mode
+ act( () => {
+ Simulate.change( toggleInput, { target: { checked: true } } );
+ } );
+
+ defaultControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--default' );
+ responsiveControlGroup = container.querySelector( '.block-editor-responsive-block-control__group--responsive' );
+
+ expect( defaultControlGroup ).not.toBeNull();
+ expect( responsiveControlGroup ).toBeNull();
+ } );
+
+ it( 'should render custom responsive controls when renderResponsiveControls prop is provided and in responsive mode ', () => {
+ const spyRenderDefaultControl = jest.fn();
+
+ const mockRenderResponsiveControls = jest.fn( ( viewports ) => {
+ return viewports.map( ( { id, label } ) => {
+ return (
+
+ Custom Viewport { label }
+
+
+ );
+ } );
+ } );
+
+ act( () => {
+ render(
+ , container
+ );
+ } );
+
+ // The user should see "range" controls so we can legitimately query for them here
+ const customControls = Array.from( container.querySelectorAll( 'input[type="range"]' ) );
+
+ // Also called because default control rendeer function is always called
+ // (for convenience) even though it's not displayed in output.
+ expect( spyRenderDefaultControl ).toHaveBeenCalledTimes( 1 );
+
+ expect( mockRenderResponsiveControls ).toHaveBeenCalledTimes( 1 );
+
+ expect( customControls ).toHaveLength( 3 );
+ } );
+} );
+
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index 9c149f001cf7f..3169be962d3b3 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -28,6 +28,7 @@
@import "./components/multi-selection-inspector/style.scss";
@import "./components/panel-color-settings/style.scss";
@import "./components/plain-text/style.scss";
+@import "./components/responsive-block-control/style.scss";
@import "./components/rich-text/format-toolbar/style.scss";
@import "./components/rich-text/style.scss";
@import "./components/skip-to-selected-block/style.scss";