diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index 25fe5746a04122..71c0c1c8b957ff 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -275,7 +275,7 @@ Gather blocks in a layout container. ([Source](https://github.com/WordPress/gute
- **Name:** core/group
- **Category:** design
-- **Supports:** align (full, wide), anchor, ariaLabel, color (background, gradients, link, text), dimensions (minHeight), spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~
+- **Supports:** align (full, wide), anchor, ariaLabel, color (background, gradients, link, text), dimensions (minHeight), position (sticky), spacing (blockGap, margin, padding), typography (fontSize, lineHeight), ~~html~~
- **Attributes:** tagName, templateLock
## Heading
diff --git a/lib/block-supports/position.php b/lib/block-supports/position.php
new file mode 100644
index 00000000000000..8d1436049a7e42
--- /dev/null
+++ b/lib/block-supports/position.php
@@ -0,0 +1,143 @@
+attributes ) {
+ $block_type->attributes = array();
+ }
+
+ if ( $has_position_support && ! array_key_exists( 'style', $block_type->attributes ) ) {
+ $block_type->attributes['style'] = array(
+ 'type' => 'object',
+ );
+ }
+}
+
+/**
+ * Renders position styles to the block wrapper.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function gutenberg_render_position_support( $block_content, $block ) {
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ $has_position_support = block_has_support( $block_type, array( 'position' ), false );
+
+ if (
+ ! $has_position_support ||
+ empty( $block['attrs']['style']['position'] )
+ ) {
+ return $block_content;
+ }
+
+ $global_settings = gutenberg_get_global_settings();
+ $theme_has_sticky_support = _wp_array_get( $global_settings, array( 'position', 'sticky' ), false );
+ $theme_has_fixed_support = _wp_array_get( $global_settings, array( 'position', 'fixed' ), false );
+
+ // Only allow output for position types that the theme supports.
+ $allowed_position_types = array();
+ if ( true === $theme_has_sticky_support ) {
+ $allowed_position_types[] = 'sticky';
+ }
+ if ( true === $theme_has_fixed_support ) {
+ $allowed_position_types[] = 'fixed';
+ }
+
+ $style_attribute = _wp_array_get( $block, array( 'attrs', 'style' ), null );
+ $class_name = wp_unique_id( 'wp-container-' );
+ $selector = ".$class_name";
+ $position_styles = array();
+ $position_type = _wp_array_get( $style_attribute, array( 'position', 'type' ), '' );
+
+ if (
+ in_array( $position_type, $allowed_position_types, true )
+ ) {
+ $sides = array( 'top', 'right', 'bottom', 'left' );
+
+ foreach ( $sides as $side ) {
+ $side_value = _wp_array_get( $style_attribute, array( 'position', $side ) );
+ if ( null !== $side_value ) {
+ /*
+ * For fixed or sticky top positions,
+ * ensure the value includes an offset for the logged in admin bar.
+ */
+ if (
+ 'top' === $side &&
+ ( 'fixed' === $position_type || 'sticky' === $position_type )
+ ) {
+ // Ensure 0 values can be used in `calc()` calculations.
+ if ( '0' === $side_value || 0 === $side_value ) {
+ $side_value = '0px';
+ }
+
+ // Ensure current side value also factors in the height of the logged in admin bar.
+ $side_value = "calc($side_value + var(--wp-admin--admin-bar--height, 0px))";
+ }
+
+ $position_styles[] =
+ array(
+ 'selector' => $selector,
+ 'declarations' => array(
+ $side => $side_value,
+ ),
+ );
+ }
+ }
+
+ $position_styles[] =
+ array(
+ 'selector' => $selector,
+ 'declarations' => array(
+ 'position' => $position_type,
+ 'z-index' => '10', // TODO: Replace hard-coded z-index value with a z-index preset approach in theme.json.
+ ),
+ );
+ }
+
+ if ( ! empty( $position_styles ) ) {
+ /*
+ * Add to the style engine store to enqueue and render position styles.
+ */
+ gutenberg_style_engine_get_stylesheet_from_css_rules(
+ $position_styles,
+ array(
+ 'context' => 'block-supports',
+ 'prettify' => false,
+ )
+ );
+
+ // Inject class name to block container markup.
+ $content = new WP_HTML_Tag_Processor( $block_content );
+ $content->next_tag();
+ $content->add_class( $class_name );
+ return (string) $content;
+ }
+
+ return $block_content;
+}
+
+// Register the block support. (overrides core one).
+WP_Block_Supports::get_instance()->register(
+ 'position',
+ array(
+ 'register_attribute' => 'gutenberg_register_position_support',
+ )
+);
+
+if ( function_exists( 'wp_render_position_support' ) ) {
+ remove_filter( 'render_block', 'wp_render_position_support' );
+}
+add_filter( 'render_block', 'gutenberg_render_position_support', 10, 2 );
diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php
index ddad36e15fc02d..982832f33722c6 100644
--- a/lib/class-wp-theme-json-gutenberg.php
+++ b/lib/class-wp-theme-json-gutenberg.php
@@ -330,7 +330,7 @@ class WP_Theme_JSON_Gutenberg {
* and `typography`, and renamed others according to the new schema.
* @since 6.0.0 Added `color.defaultDuotone`.
* @since 6.1.0 Added `layout.definitions` and `useRootPaddingAwareAlignments`.
- * @since 6.2.0 Added `dimensions.minHeight`.
+ * @since 6.2.0 Added `dimensions.minHeight`, `position.fixed` and `position.sticky`.
* @var array
*/
const VALID_SETTINGS = array(
@@ -369,6 +369,10 @@ class WP_Theme_JSON_Gutenberg {
'definitions' => null,
'wideSize' => null,
),
+ 'position' => array(
+ 'fixed' => null,
+ 'sticky' => null,
+ ),
'spacing' => array(
'customSpacingSize' => null,
'spacingSizes' => null,
@@ -536,6 +540,7 @@ public static function get_element_class_name( $element ) {
* Options that settings.appearanceTools enables.
*
* @since 6.0.0
+ * @since 6.2.0 Added `position.fixed` and `position.sticky`.
* @var array
*/
const APPEARANCE_TOOLS_OPT_INS = array(
@@ -545,6 +550,8 @@ public static function get_element_class_name( $element ) {
array( 'border', 'width' ),
array( 'color', 'link' ),
array( 'dimensions', 'minHeight' ),
+ array( 'position', 'fixed' ),
+ array( 'position', 'sticky' ),
array( 'spacing', 'blockGap' ),
array( 'spacing', 'margin' ),
array( 'spacing', 'padding' ),
diff --git a/lib/compat/wordpress-6.2/blocks.php b/lib/compat/wordpress-6.2/blocks.php
new file mode 100644
index 00000000000000..250ed70ff9b98a
--- /dev/null
+++ b/lib/compat/wordpress-6.2/blocks.php
@@ -0,0 +1,26 @@
+= 6.2.
+ *
+ * @param string[] $attrs Array of allowed CSS attributes.
+ * @return string[] CSS attributes.
+ */
+function gutenberg_safe_style_attrs_6_2( $attrs ) {
+ $attrs[] = 'position';
+ $attrs[] = 'top';
+ $attrs[] = 'right';
+ $attrs[] = 'bottom';
+ $attrs[] = 'left';
+ $attrs[] = 'z-index';
+
+ return $attrs;
+}
+add_filter( 'safe_style_css', 'gutenberg_safe_style_attrs_6_2' );
diff --git a/lib/load.php b/lib/load.php
index 1b296053965319..83af73adc53d11 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -75,6 +75,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/compat/wordpress-6.1/theme.php';
// WordPress 6.2 compat.
+require __DIR__ . '/compat/wordpress-6.2/blocks.php';
require __DIR__ . '/compat/wordpress-6.2/script-loader.php';
require __DIR__ . '/compat/wordpress-6.2/block-template-utils.php';
require __DIR__ . '/compat/wordpress-6.2/get-global-styles-and-settings.php';
@@ -132,6 +133,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/block-supports/typography.php';
require __DIR__ . '/block-supports/border.php';
require __DIR__ . '/block-supports/layout.php';
+require __DIR__ . '/block-supports/position.php';
require __DIR__ . '/block-supports/spacing.php';
require __DIR__ . '/block-supports/dimensions.php';
require __DIR__ . '/block-supports/duotone.php';
diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js
index 3e2aebb5d500a5..eb19c84e3ef47b 100644
--- a/packages/block-editor/src/components/block-inspector/index.js
+++ b/packages/block-editor/src/components/block-inspector/index.js
@@ -35,6 +35,7 @@ import { default as InspectorControls } from '../inspector-controls';
import { default as InspectorControlsTabs } from '../inspector-controls-tabs';
import useInspectorControlsTabs from '../inspector-controls-tabs/use-inspector-controls-tabs';
import AdvancedControls from '../inspector-controls-tabs/advanced-controls-panel';
+import PositionControls from '../inspector-controls-tabs/position-controls-panel';
function useContentBlocks( blockTypes, block ) {
const contentBlocksObjectAux = useMemo( () => {
@@ -377,6 +378,7 @@ const BlockInspectorSingleBlock = ( { clientId, blockName } ) => {
__experimentalGroup="border"
label={ __( 'Border' ) }
/>
+
diff --git a/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js b/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js
index d218b1104139cf..f99323dd5c80a7 100644
--- a/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js
+++ b/packages/block-editor/src/components/block-tools/use-block-toolbar-popover-props.js
@@ -3,13 +3,20 @@
*/
import { useRefEffect } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
-import { useCallback, useLayoutEffect, useState } from '@wordpress/element';
+import { getScrollContainer } from '@wordpress/dom';
+import {
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useState,
+} from '@wordpress/element';
/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { __unstableUseBlockElement as useBlockElement } from '../block-list/use-block-props/use-block-refs';
+import { hasStickyOrFixedPositionValue } from '../../hooks/position';
const COMMON_PROPS = {
placement: 'top-start',
@@ -40,28 +47,50 @@ const RESTRICTED_HEIGHT_PROPS = {
*
* @param {Element} contentElement The DOM element that represents the editor content or canvas.
* @param {Element} selectedBlockElement The outer DOM element of the first selected block.
+ * @param {Element} scrollContainer The scrollable container for the contentElement.
* @param {number} toolbarHeight The height of the toolbar in pixels.
+ * @param {boolean} isSticky Whether or not the selected block is sticky or fixed.
*
* @return {Object} The popover props used to determine the position of the toolbar.
*/
-function getProps( contentElement, selectedBlockElement, toolbarHeight ) {
+function getProps(
+ contentElement,
+ selectedBlockElement,
+ scrollContainer,
+ toolbarHeight,
+ isSticky
+) {
if ( ! contentElement || ! selectedBlockElement ) {
return DEFAULT_PROPS;
}
+ // Get how far the content area has been scrolled.
+ const scrollTop = scrollContainer?.scrollTop || 0;
+
const blockRect = selectedBlockElement.getBoundingClientRect();
const contentRect = contentElement.getBoundingClientRect();
+ // Get the vertical position of top of the visible content area.
+ const topOfContentElementInViewport = scrollTop + contentRect.top;
+
// The document element's clientHeight represents the viewport height.
const viewportHeight =
contentElement.ownerDocument.documentElement.clientHeight;
- const hasSpaceForToolbarAbove =
- blockRect.top - contentRect.top > toolbarHeight;
+ // The restricted height area is calculated as the sum of the
+ // vertical position of the visible content area, plus the height
+ // of the block toolbar.
+ const restrictedTopArea = topOfContentElementInViewport + toolbarHeight;
+ const hasSpaceForToolbarAbove = blockRect.top > restrictedTopArea;
+
const isBlockTallerThanViewport =
blockRect.height > viewportHeight - toolbarHeight;
- if ( hasSpaceForToolbarAbove || isBlockTallerThanViewport ) {
+ // Sticky blocks are treated as if they will never have enough space for the toolbar above.
+ if (
+ ! isSticky &&
+ ( hasSpaceForToolbarAbove || isBlockTallerThanViewport )
+ ) {
return DEFAULT_PROPS;
}
@@ -83,13 +112,34 @@ export default function useBlockToolbarPopoverProps( {
} ) {
const selectedBlockElement = useBlockElement( clientId );
const [ toolbarHeight, setToolbarHeight ] = useState( 0 );
- const [ props, setProps ] = useState( () =>
- getProps( contentElement, selectedBlockElement, toolbarHeight )
- );
- const blockIndex = useSelect(
- ( select ) => select( blockEditorStore ).getBlockIndex( clientId ),
+ const { blockIndex, isSticky } = useSelect(
+ ( select ) => {
+ const { getBlockIndex, getBlockAttributes } =
+ select( blockEditorStore );
+ return {
+ blockIndex: getBlockIndex( clientId ),
+ isSticky: hasStickyOrFixedPositionValue(
+ getBlockAttributes( clientId )
+ ),
+ };
+ },
[ clientId ]
);
+ const scrollContainer = useMemo( () => {
+ if ( ! contentElement ) {
+ return;
+ }
+ return getScrollContainer( contentElement );
+ }, [ contentElement ] );
+ const [ props, setProps ] = useState( () =>
+ getProps(
+ contentElement,
+ selectedBlockElement,
+ scrollContainer,
+ toolbarHeight,
+ isSticky
+ )
+ );
const popoverRef = useRefEffect( ( popoverNode ) => {
setToolbarHeight( popoverNode.offsetHeight );
@@ -98,9 +148,15 @@ export default function useBlockToolbarPopoverProps( {
const updateProps = useCallback(
() =>
setProps(
- getProps( contentElement, selectedBlockElement, toolbarHeight )
+ getProps(
+ contentElement,
+ selectedBlockElement,
+ scrollContainer,
+ toolbarHeight,
+ isSticky
+ )
),
- [ contentElement, selectedBlockElement, toolbarHeight ]
+ [ contentElement, selectedBlockElement, scrollContainer, toolbarHeight ]
);
// Update props when the block is moved. This also ensures the props are
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.js b/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.js
new file mode 100644
index 00000000000000..5fc71fab5960f4
--- /dev/null
+++ b/packages/block-editor/src/components/inspector-controls-tabs/position-controls-panel.js
@@ -0,0 +1,37 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ PanelBody,
+ __experimentalUseSlotFills as useSlotFills,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import InspectorControlsGroups from '../inspector-controls/groups';
+import { default as InspectorControls } from '../inspector-controls';
+
+const PositionControls = () => {
+ const fills = useSlotFills(
+ InspectorControlsGroups.position.Slot.__unstableName
+ );
+ const hasFills = Boolean( fills && fills.length );
+
+ if ( ! hasFills ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default PositionControls;
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/settings-tab.js b/packages/block-editor/src/components/inspector-controls-tabs/settings-tab.js
index beac0f10a178a5..ec34035b754a91 100644
--- a/packages/block-editor/src/components/inspector-controls-tabs/settings-tab.js
+++ b/packages/block-editor/src/components/inspector-controls-tabs/settings-tab.js
@@ -2,11 +2,13 @@
* Internal dependencies
*/
import AdvancedControls from './advanced-controls-panel';
+import PositionControls from './position-controls-panel';
import { default as InspectorControls } from '../inspector-controls';
const SettingsTab = ( { showAdvancedControls = false } ) => (
<>
+
{ showAdvancedControls && (
diff --git a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js
index fbe072fab11c58..bf7f61de4c8cb7 100644
--- a/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js
+++ b/packages/block-editor/src/components/inspector-controls-tabs/use-inspector-controls-tabs.js
@@ -43,6 +43,7 @@ export default function useInspectorControlsTabs( blockName ) {
default: defaultGroup,
dimensions: dimensionsGroup,
list: listGroup,
+ position: positionGroup,
typography: typographyGroup,
} = InspectorControlsGroups;
@@ -71,6 +72,7 @@ export default function useInspectorControlsTabs( blockName ) {
// or Advanced Controls slot, then add this tab.
const settingsFills = [
...( useSlotFills( defaultGroup.Slot.__unstableName ) || [] ),
+ ...( useSlotFills( positionGroup.Slot.__unstableName ) || [] ),
...( useSlotFills( InspectorAdvancedControls.slotName ) || [] ),
];
diff --git a/packages/block-editor/src/components/inspector-controls/groups.js b/packages/block-editor/src/components/inspector-controls/groups.js
index cb03c1ff13fa57..46fca564925aa6 100644
--- a/packages/block-editor/src/components/inspector-controls/groups.js
+++ b/packages/block-editor/src/components/inspector-controls/groups.js
@@ -10,6 +10,7 @@ const InspectorControlsColor = createSlotFill( 'InspectorControlsColor' );
const InspectorControlsDimensions = createSlotFill(
'InspectorControlsDimensions'
);
+const InspectorControlsPosition = createSlotFill( 'InspectorControlsPosition' );
const InspectorControlsTypography = createSlotFill(
'InspectorControlsTypography'
);
@@ -23,6 +24,7 @@ const groups = {
dimensions: InspectorControlsDimensions,
list: InspectorControlsListView,
typography: InspectorControlsTypography,
+ position: InspectorControlsPosition,
};
export default groups;
diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js
index f6fa73a053ac58..84b3ba3a95a33b 100644
--- a/packages/block-editor/src/hooks/index.js
+++ b/packages/block-editor/src/hooks/index.js
@@ -14,6 +14,7 @@ import './color';
import './duotone';
import './font-size';
import './border';
+import './position';
import './layout';
import './content-lock-ui';
import './metadata';
diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js
new file mode 100644
index 00000000000000..1e50c35bfd0e6f
--- /dev/null
+++ b/packages/block-editor/src/hooks/position.js
@@ -0,0 +1,375 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { getBlockSupport, hasBlockSupport } from '@wordpress/blocks';
+import { BaseControl, CustomSelectControl } from '@wordpress/components';
+import { createHigherOrderComponent, useInstanceId } from '@wordpress/compose';
+import {
+ useContext,
+ useMemo,
+ createPortal,
+ Platform,
+} from '@wordpress/element';
+import { addFilter } from '@wordpress/hooks';
+
+/**
+ * Internal dependencies
+ */
+import BlockList from '../components/block-list';
+import useSetting from '../components/use-setting';
+import InspectorControls from '../components/inspector-controls';
+import { cleanEmptyObject } from './utils';
+
+const POSITION_SUPPORT_KEY = 'position';
+
+const OPTION_CLASSNAME =
+ 'block-editor-hooks__position-selection__select-control__option';
+
+const DEFAULT_OPTION = {
+ key: 'default',
+ value: '',
+ name: __( 'Default' ),
+ className: OPTION_CLASSNAME,
+};
+
+const STICKY_OPTION = {
+ key: 'sticky',
+ value: 'sticky',
+ name: __( 'Sticky' ),
+ className: OPTION_CLASSNAME,
+ __experimentalHint: __(
+ 'The block will stick to the top of the window instead of scrolling.'
+ ),
+};
+
+const FIXED_OPTION = {
+ key: 'fixed',
+ value: 'fixed',
+ name: __( 'Fixed' ),
+ className: OPTION_CLASSNAME,
+ __experimentalHint: __(
+ 'The block will not move when the page is scrolled.'
+ ),
+};
+
+const POSITION_SIDES = [ 'top', 'right', 'bottom', 'left' ];
+const VALID_POSITION_TYPES = [ 'sticky', 'fixed' ];
+
+/**
+ * Get calculated position CSS.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.selector Selector to use.
+ * @param {Object} props.style Style object.
+ * @return {string} The generated CSS rules.
+ */
+export function getPositionCSS( { selector, style } ) {
+ let output = '';
+
+ const { type: positionType } = style?.position || {};
+
+ if ( ! VALID_POSITION_TYPES.includes( positionType ) ) {
+ return output;
+ }
+
+ output += `${ selector } {`;
+ output += `position: ${ positionType };`;
+
+ POSITION_SIDES.forEach( ( side ) => {
+ if ( style?.position?.[ side ] !== undefined ) {
+ output += `${ side }: ${ style.position[ side ] };`;
+ }
+ } );
+
+ if ( positionType === 'sticky' || positionType === 'fixed' ) {
+ // TODO: Replace hard-coded z-index value with a z-index preset approach in theme.json.
+ output += `z-index: 10`;
+ }
+ output += `}`;
+
+ return output;
+}
+
+/**
+ * Determines if there is sticky position support.
+ *
+ * @param {string|Object} blockType Block name or Block Type object.
+ *
+ * @return {boolean} Whether there is support.
+ */
+export function hasStickyPositionSupport( blockType ) {
+ const support = getBlockSupport( blockType, POSITION_SUPPORT_KEY );
+ return !! ( true === support || support?.sticky );
+}
+
+/**
+ * Determines if there is fixed position support.
+ *
+ * @param {string|Object} blockType Block name or Block Type object.
+ *
+ * @return {boolean} Whether there is support.
+ */
+export function hasFixedPositionSupport( blockType ) {
+ const support = getBlockSupport( blockType, POSITION_SUPPORT_KEY );
+ return !! ( true === support || support?.fixed );
+}
+
+/**
+ * Determines if there is position support.
+ *
+ * @param {string|Object} blockType Block name or Block Type object.
+ *
+ * @return {boolean} Whether there is support.
+ */
+export function hasPositionSupport( blockType ) {
+ const support = getBlockSupport( blockType, POSITION_SUPPORT_KEY );
+ return !! support;
+}
+
+/**
+ * Checks if there is a current value in the position block support attributes.
+ *
+ * @param {Object} props Block props.
+ * @return {boolean} Whether or not the block has a position value set.
+ */
+export function hasPositionValue( props ) {
+ return props.attributes.style?.position?.type !== undefined;
+}
+
+/**
+ * Checks if the block is currently set to a sticky or fixed position.
+ * This check is helpful for determining how to position block toolbars or other elements.
+ *
+ * @param {Object} attributes Block attributes.
+ * @return {boolean} Whether or not the block is set to a sticky or fixed position.
+ */
+export function hasStickyOrFixedPositionValue( attributes ) {
+ const positionType = attributes.style?.position?.type;
+ return positionType === 'sticky' || positionType === 'fixed';
+}
+
+/**
+ * Resets the position block support attributes. This can be used when disabling
+ * the position support controls for a block via a `ToolsPanel`.
+ *
+ * @param {Object} props Block props.
+ * @param {Object} props.attributes Block's attributes.
+ * @param {Object} props.setAttributes Function to set block's attributes.
+ */
+export function resetPosition( { attributes = {}, setAttributes } ) {
+ const { style = {} } = attributes;
+
+ setAttributes( {
+ style: cleanEmptyObject( {
+ ...style,
+ position: {
+ ...style?.position,
+ type: undefined,
+ top: undefined,
+ right: undefined,
+ bottom: undefined,
+ left: undefined,
+ },
+ } ),
+ } );
+}
+
+/**
+ * Custom hook that checks if position settings have been disabled.
+ *
+ * @param {string} name The name of the block.
+ *
+ * @return {boolean} Whether padding setting is disabled.
+ */
+export function useIsPositionDisabled( { name: blockName } = {} ) {
+ const allowFixed = useSetting( 'position.fixed' );
+ const allowSticky = useSetting( 'position.sticky' );
+ const isDisabled = ! allowFixed && ! allowSticky;
+
+ return ! hasPositionSupport( blockName ) || isDisabled;
+}
+
+/*
+ * Position controls to be rendered in an inspector control panel.
+ *
+ * @param {Object} props
+ *
+ * @return {WPElement} Padding edit element.
+ */
+export function PositionEdit( props ) {
+ const {
+ attributes: { style = {} },
+ name: blockName,
+ setAttributes,
+ } = props;
+
+ const allowFixed = hasFixedPositionSupport( blockName );
+ const allowSticky = hasStickyPositionSupport( blockName );
+ const value = style?.position?.type;
+
+ const options = useMemo( () => {
+ const availableOptions = [ DEFAULT_OPTION ];
+ if ( allowSticky || value === STICKY_OPTION.value ) {
+ availableOptions.push( STICKY_OPTION );
+ }
+ if ( allowFixed || value === FIXED_OPTION.value ) {
+ availableOptions.push( FIXED_OPTION );
+ }
+ return availableOptions;
+ }, [ allowFixed, allowSticky, value ] );
+
+ const onChangeType = ( next ) => {
+ // For now, use a hard-coded `0px` value for the position.
+ // `0px` is preferred over `0` as it can be used in `calc()` functions.
+ // In the future, it could be useful to allow for an offset value.
+ const placementValue = '0px';
+
+ const newStyle = {
+ ...style,
+ position: {
+ ...style?.position,
+ type: next,
+ top:
+ next === 'sticky' || next === 'fixed'
+ ? placementValue
+ : undefined,
+ },
+ };
+
+ setAttributes( {
+ style: cleanEmptyObject( newStyle ),
+ } );
+ };
+
+ const selectedOption = value
+ ? options.find( ( option ) => option.value === value ) || DEFAULT_OPTION
+ : DEFAULT_OPTION;
+
+ return Platform.select( {
+ web: (
+ <>
+
+ {
+ onChangeType( selectedItem.value );
+ } }
+ size={ '__unstable-large' }
+ />
+
+ >
+ ),
+ native: null,
+ } );
+}
+
+/**
+ * Override the default edit UI to include position controls.
+ *
+ * @param {Function} BlockEdit Original component.
+ *
+ * @return {Function} Wrapped component.
+ */
+export const withInspectorControls = createHigherOrderComponent(
+ ( BlockEdit ) => ( props ) => {
+ const { name: blockName } = props;
+ const positionSupport = hasBlockSupport(
+ blockName,
+ POSITION_SUPPORT_KEY
+ );
+ const showPositionControls =
+ positionSupport && ! useIsPositionDisabled( props );
+
+ return [
+ showPositionControls && (
+
+
+
+ ),
+
,
+ ];
+ },
+ 'withInspectorControls'
+);
+
+/**
+ * Override the default block element to add the position styles.
+ *
+ * @param {Function} BlockListBlock Original component.
+ *
+ * @return {Function} Wrapped component.
+ */
+export const withPositionStyles = createHigherOrderComponent(
+ ( BlockListBlock ) => ( props ) => {
+ const { name, attributes } = props;
+ const hasPositionBlockSupport = hasBlockSupport(
+ name,
+ POSITION_SUPPORT_KEY
+ );
+ const allowPositionStyles =
+ hasPositionBlockSupport && ! useIsPositionDisabled( props );
+
+ const id = useInstanceId( BlockListBlock );
+ const element = useContext( BlockList.__unstableElementContext );
+
+ // Higher specificity to override defaults in editor UI.
+ const positionSelector = `.wp-container-${ id }.wp-container-${ id }`;
+
+ // Get CSS string for the current position values.
+ let css;
+ if ( allowPositionStyles ) {
+ css =
+ getPositionCSS( {
+ selector: positionSelector,
+ style: attributes?.style,
+ } ) || '';
+ }
+
+ // Attach a `wp-container-` id-based class name.
+ const className = classnames( props?.className, {
+ [ `wp-container-${ id }` ]: allowPositionStyles && !! css, // Only attach a container class if there is generated CSS to be attached.
+ } );
+
+ return (
+ <>
+ { allowPositionStyles &&
+ element &&
+ !! css &&
+ createPortal( , element ) }
+
+ >
+ );
+ }
+);
+
+addFilter(
+ 'editor.BlockListBlock',
+ 'core/editor/position/with-position-styles',
+ withPositionStyles
+);
+addFilter(
+ 'editor.BlockEdit',
+ 'core/editor/position/with-inspector-controls',
+ withInspectorControls
+);
diff --git a/packages/block-editor/src/hooks/position.scss b/packages/block-editor/src/hooks/position.scss
new file mode 100644
index 00000000000000..b3bd6b1b9ef041
--- /dev/null
+++ b/packages/block-editor/src/hooks/position.scss
@@ -0,0 +1,18 @@
+.block-editor-hooks__position-selection__select-control {
+ .components-custom-select-control__hint {
+ display: none;
+ }
+}
+
+.block-editor-hooks__position-selection__select-control__option {
+ &.has-hint {
+ grid-template-columns: auto 30px;
+ line-height: $default-line-height;
+ margin-bottom: 0;
+ }
+
+ .components-custom-select-control__item-hint {
+ grid-row: 2;
+ text-align: left;
+ }
+}
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index 76da3c16325db9..3c2f60388e0df0 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -50,6 +50,7 @@
@import "./hooks/layout.scss";
@import "./hooks/border.scss";
@import "./hooks/dimensions.scss";
+@import "./hooks/position.scss";
@import "./hooks/typography.scss";
@import "./hooks/color.scss";
@import "./hooks/padding.scss";
diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json
index a3997db0621c54..2b227a15847a27 100644
--- a/packages/block-library/src/group/block.json
+++ b/packages/block-library/src/group/block.json
@@ -56,6 +56,9 @@
"width": true
}
},
+ "position": {
+ "sticky": true
+ },
"typography": {
"fontSize": true,
"lineHeight": true,