diff --git a/src/wp-includes/block-supports/rotation.php b/src/wp-includes/block-supports/rotation.php
new file mode 100644
index 0000000000000..06248b1831893
--- /dev/null
+++ b/src/wp-includes/block-supports/rotation.php
@@ -0,0 +1,199 @@
+attributes ) {
+ $block_type->attributes = array();
+ }
+
+ // Check for existing style attribute definition e.g. from block.json.
+ if ( ! array_key_exists( 'style', $block_type->attributes ) ) {
+ $block_type->attributes['style'] = array(
+ 'type' => 'object',
+ );
+ }
+}
+
+/**
+ * Reads a stored rotation, which is a number of degrees.
+ *
+ * Block attributes are untrusted, so anything that is not numeric is ignored.
+ * The angle is rounded to two decimals and wrapped into the (-180, 180] range.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param mixed $value Stored rotation.
+ * @return float|null The rotation in degrees, or null when the value is not a rotation.
+ */
+function wp_get_rotate_value( $value ) {
+ // PHP 7 rejects numeric strings with trailing whitespace, PHP 8 accepts them.
+ if ( is_string( $value ) ) {
+ $value = trim( $value );
+ }
+ if ( ! is_numeric( $value ) ) {
+ return null;
+ }
+
+ $angle = (float) $value;
+ if ( ! is_finite( $angle ) ) {
+ return null;
+ }
+
+ $angle = fmod( round( $angle, 2 ), 360 );
+ if ( $angle <= -180 ) {
+ $angle += 360;
+ } elseif ( $angle > 180 ) {
+ $angle -= 360;
+ }
+
+ // Avoid returning -0.
+ return 0.0 === $angle ? 0.0 : $angle;
+}
+
+/**
+ * Builds the CSS rules that rotate a block, without their selector.
+ *
+ * The `rotate` property is used because it maps most directly to the editor feature.
+ * In a viewport override, 0 undoes the default rotation, so it is output as `none`.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param array $style Block style attribute.
+ * @param array $responsive_media_queries Media queries keyed by viewport state, e.g. `@mobile`.
+ * @return array[] CSS rules with `declarations` and, for viewport overrides, `rules_group`.
+ */
+function wp_get_rotate_style_rules( $style, $responsive_media_queries ) {
+ $rules = array();
+ $rotate = wp_get_rotate_value( $style['rotate'] ?? null );
+
+ if ( $rotate ) {
+ $rules[] = array(
+ 'declarations' => array( 'rotate' => $rotate . 'deg' ),
+ );
+ }
+
+ foreach ( $responsive_media_queries as $breakpoint => $media_query ) {
+ if ( ! isset( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
+ continue;
+ }
+
+ $viewport_rotate = wp_get_rotate_value( $style[ $breakpoint ]['rotate'] ?? null );
+ if ( null === $viewport_rotate ) {
+ continue;
+ }
+
+ $rules[] = array(
+ 'rules_group' => $media_query,
+ 'declarations' => array( 'rotate' => $viewport_rotate ? $viewport_rotate . 'deg' : 'none' ),
+ );
+ }
+
+ return $rules;
+}
+
+/**
+ * Rotates a block on the front end.
+ *
+ * The rules are added to the block supports stylesheet under a class generated from
+ * them.
+ *
+ * @since 7.3.0
+ * @access private
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function wp_render_rotate_support( $block_content, $block ) {
+ $style = $block['attrs']['style'] ?? null;
+ if ( empty( $block_content ) || empty( $block['blockName'] ) || ! is_array( $style ) ) {
+ return $block_content;
+ }
+
+ $has_viewport_rotate = false;
+ foreach ( $style as $key => $value ) {
+ if ( is_string( $key ) && str_starts_with( $key, '@' ) && is_array( $value ) && array_key_exists( 'rotate', $value ) ) {
+ $has_viewport_rotate = true;
+ break;
+ }
+ }
+
+ if ( ! array_key_exists( 'rotate', $style ) && ! $has_viewport_rotate ) {
+ return $block_content;
+ }
+
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ if ( ! $block_type || ! block_has_support( $block_type, 'rotation', false ) ) {
+ return $block_content;
+ }
+
+ // A block's own `style` attribute that is not an object doesn't hold a rotation.
+ $style_attribute_type = $block_type->attributes['style']['type'] ?? 'object';
+ if ( 'object' !== $style_attribute_type ) {
+ return $block_content;
+ }
+
+ // Only resolve the global settings when there is a viewport override to place.
+ $responsive_media_queries = $has_viewport_rotate
+ ? WP_Theme_JSON::get_viewport_media_queries( wp_get_global_settings( array( 'viewport' ) ) )
+ : array();
+ $rules = wp_get_rotate_style_rules( $style, $responsive_media_queries );
+ if ( empty( $rules ) ) {
+ return $block_content;
+ }
+
+ $processor = new WP_HTML_Tag_Processor( $block_content );
+ if ( ! $processor->next_tag() ) {
+ return $block_content;
+ }
+
+ $class_name = wp_unique_id_from_values( $rules, 'wp-rotate-' );
+ foreach ( $rules as $index => $rule ) {
+ $rules[ $index ]['selector'] = ".$class_name";
+ }
+
+ wp_style_engine_get_stylesheet_from_css_rules(
+ $rules,
+ array(
+ 'context' => 'block-supports',
+ 'prettify' => false,
+ )
+ );
+
+ $processor->add_class( $class_name );
+
+ return $processor->get_updated_html();
+}
+
+// Register the block support.
+WP_Block_Supports::get_instance()->register(
+ 'rotation',
+ array(
+ 'register_attribute' => 'wp_register_rotate_support',
+ )
+);
+
+add_filter( 'render_block', 'wp_render_rotate_support', 10, 2 );
diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php
index aee33b474a4d6..9aadf7fc16dac 100644
--- a/src/wp-includes/kses.php
+++ b/src/wp-includes/kses.php
@@ -3721,6 +3721,7 @@ function kses_init() {
* and URLs in the SVG element reference properties.
* @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`.
* Added support for `background-clip`.
+ * @since 7.3.0 Added support for `rotate`.
*
* @param string $css A string of CSS rules, decoded from an HTML `style` attribute.
* @param string $deprecated Not used.
@@ -3908,6 +3909,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) {
'box-shadow',
'aspect-ratio',
'container-type',
+ 'rotate',
'fill',
'fill-opacity',
diff --git a/src/wp-settings.php b/src/wp-settings.php
index 92f9456aa02ca..7c122f2fbb110 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -463,6 +463,7 @@
require ABSPATH . WPINC . '/block-supports/anchor.php';
require ABSPATH . WPINC . '/block-supports/block-visibility.php';
require ABSPATH . WPINC . '/block-supports/custom-css.php';
+require ABSPATH . WPINC . '/block-supports/rotation.php';
require ABSPATH . WPINC . '/block-supports/states.php';
require ABSPATH . WPINC . '/style-engine.php';
require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php';
diff --git a/tests/phpunit/tests/block-supports/rotation.php b/tests/phpunit/tests/block-supports/rotation.php
new file mode 100644
index 0000000000000..65ecd88abe7c1
--- /dev/null
+++ b/tests/phpunit/tests/block-supports/rotation.php
@@ -0,0 +1,395 @@
+test_block_name = null;
+ WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ }
+
+ public function tear_down() {
+ if ( $this->test_block_name ) {
+ unregister_block_type( $this->test_block_name );
+ }
+ $this->test_block_name = null;
+ WP_Style_Engine_CSS_Rules_Store::remove_all_stores();
+ parent::tear_down();
+ }
+
+ /**
+ * Registers a block for the tests.
+ *
+ * @param array $supports Block supports.
+ * @param array $attributes Block attributes.
+ * @return WP_Block_Type The registered block type.
+ */
+ private function register_test_block( $supports = array( 'rotation' => true ), $attributes = array() ) {
+ $this->test_block_name = 'test/rotate-block';
+ register_block_type(
+ $this->test_block_name,
+ array(
+ 'api_version' => 3,
+ 'attributes' => $attributes,
+ 'supports' => $supports,
+ )
+ );
+
+ return WP_Block_Type_Registry::get_instance()->get_registered( $this->test_block_name );
+ }
+
+ /**
+ * Renders the test block with a style attribute.
+ *
+ * @param array $style Block style attribute.
+ * @param string $block_content Rendered block content.
+ * @return string Filtered block content.
+ */
+ private function render_test_block( $style, $block_content = '
Content
' ) {
+ return wp_render_rotate_support(
+ $block_content,
+ array(
+ 'blockName' => 'test/rotate-block',
+ 'attrs' => array( 'style' => $style ),
+ )
+ );
+ }
+
+ /**
+ * Returns the class the rotate support added to rendered block content.
+ *
+ * @param string $block_content Rendered block content.
+ * @return string|null The class, or null when there is none.
+ */
+ private function get_rotate_class( $block_content ) {
+ return preg_match( '/wp-rotate-[a-f0-9]+/', $block_content, $matches ) ? $matches[0] : null;
+ }
+
+ /**
+ * Returns the block supports stylesheet.
+ *
+ * @return string The stylesheet.
+ */
+ private function get_block_supports_stylesheet() {
+ return wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) );
+ }
+
+ /**
+ * Tests that a block supports rotation only when it opts in.
+ *
+ * @covers ::wp_register_rotate_support
+ */
+ public function test_registers_style_attribute_only_when_block_opts_in() {
+ $block_type = $this->register_test_block( array( 'rotation' => true ) );
+ wp_register_rotate_support( $block_type );
+ $this->assertSame( array( 'type' => 'object' ), $block_type->attributes['style'] ?? null, 'A block that opts in supports rotation.' );
+ unregister_block_type( $this->test_block_name );
+
+ $block_type = $this->register_test_block( array() );
+ wp_register_rotate_support( $block_type );
+ $this->assertArrayNotHasKey( 'style', $block_type->attributes, 'A block that does not opt in does not get a style attribute from rotation.' );
+ }
+
+ /**
+ * Tests that a block's own style attribute definition is kept.
+ *
+ * @covers ::wp_register_rotate_support
+ */
+ public function test_keeps_existing_style_attribute() {
+ $style_attribute = array(
+ 'type' => 'object',
+ 'default' => array( 'color' => array( 'text' => 'red' ) ),
+ );
+ $block_type = $this->register_test_block( array( 'rotation' => true ), array( 'style' => $style_attribute ) );
+ wp_register_rotate_support( $block_type );
+
+ $this->assertSame( $style_attribute, $block_type->attributes['style'] );
+ }
+
+ /**
+ * Tests that stored rotations are read as angles in (-180, 180].
+ *
+ * @covers ::wp_get_rotate_value
+ *
+ * @dataProvider data_get_rotate_value
+ *
+ * @param mixed $value Stored rotation.
+ * @param float|null $expected Expected rotation.
+ */
+ public function test_get_rotate_value( $value, $expected ) {
+ $this->assertSame( $expected, wp_get_rotate_value( $value ) );
+ }
+
+ /**
+ * Data provider for test_get_rotate_value().
+ *
+ * @return array[]
+ */
+ public function data_get_rotate_value() {
+ return array(
+ 'integer' => array( 15, 15.0 ),
+ 'negative float' => array( -12.5, -12.5 ),
+ 'numeric string' => array( '30', 30.0 ),
+ 'zero' => array( 0, 0.0 ),
+ 'half turn' => array( 180, 180.0 ),
+ 'minus half turn' => array( -180, 180.0 ),
+ 'more than a half turn' => array( 270, -90.0 ),
+ 'more than a turn' => array( 405, 45.0 ),
+ 'whole negative turn' => array( -360, 0.0 ),
+ 'rounded to two decimals' => array( 12.3456, 12.35 ),
+ 'negative half rounded away from zero' => array( -12.345, -12.35 ),
+ 'half rounded away from zero' => array( 1.005, 1.01 ),
+ 'numeric string with spaces' => array( ' 15 ', 15.0 ),
+ 'numeric string with exponent' => array( '1e2', 100.0 ),
+ 'hexadecimal string' => array( '0x10', null ), // phpcs:ignore PHPCompatibility.Miscellaneous.ValidIntegers.HexNumericStringFound -- Checks that hexadecimal strings are rejected.
+ 'binary string' => array( '0b11', null ),
+ 'rounded onto a half turn' => array( -179.999, 180.0 ),
+ 'string with a unit' => array( '15deg', null ),
+ 'string with a css injection' => array( '15deg; color: red', null ),
+ 'empty string' => array( '', null ),
+ 'null' => array( null, null ),
+ 'boolean' => array( true, null ),
+ 'array' => array( array( 15 ), null ),
+ 'infinite' => array( INF, null ),
+ 'not a number' => array( NAN, null ),
+ );
+ }
+
+ /**
+ * Tests that a rotated block gets a class with a rotate rule.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_renders_default_rotation() {
+ $this->register_test_block();
+
+ $actual = $this->render_test_block( array( 'rotate' => 15 ) );
+ $class_name = $this->get_rotate_class( $actual );
+
+ $this->assertNotNull( $class_name, 'The block should get a rotate class.' );
+ $this->assertSame(
+ 'Content
',
+ $actual
+ );
+ $this->assertSame( ".$class_name{rotate:15deg;}", $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that the rotation is wrapped and rounded before it is output.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_renders_normalized_rotation() {
+ $this->register_test_block();
+
+ $class_name = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => '-270.004' ) ) );
+
+ $this->assertSame( ".$class_name{rotate:90deg;}", $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that blocks without a rotation are left alone.
+ *
+ * @covers ::wp_render_rotate_support
+ *
+ * @dataProvider data_does_not_render_without_rotation
+ *
+ * @param array $style Block style attribute.
+ */
+ public function test_does_not_render_without_rotation( $style ) {
+ $this->register_test_block();
+ $block_content = 'Content
';
+
+ $this->assertSame( $block_content, $this->render_test_block( $style, $block_content ) );
+ $this->assertSame( '', $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Data provider for test_does_not_render_without_rotation().
+ *
+ * @return array[]
+ */
+ public function data_does_not_render_without_rotation() {
+ return array(
+ 'no rotation' => array( array( 'color' => array( 'text' => 'red' ) ) ),
+ 'zero rotation' => array( array( 'rotate' => 0 ) ),
+ 'rotation that is not a number' => array( array( 'rotate' => '15deg; color: red' ) ),
+ 'viewport rotation that is not a number' => array( array( '@mobile' => array( 'rotate' => 'none' ) ) ),
+ 'malformed viewport style' => array( array( '@mobile' => 'rotate' ) ),
+ );
+ }
+
+ /**
+ * Tests that viewport overrides are output in media queries after the default rotation,
+ * and that 0 undoes the default rotation.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_renders_viewport_overrides() {
+ $this->register_test_block();
+
+ $class_name = $this->get_rotate_class(
+ $this->render_test_block(
+ array(
+ 'rotate' => 15,
+ '@mobile' => array( 'rotate' => 0 ),
+ '@tablet' => array( 'rotate' => -20 ),
+ )
+ )
+ );
+
+ $this->assertNotNull( $class_name, 'The block should get a rotate class.' );
+ $this->assertSame(
+ ".$class_name{rotate:15deg;}" .
+ "@media (width <= 480px){.$class_name{rotate:none;}}" .
+ "@media (480px < width <= 782px){.$class_name{rotate:-20deg;}}",
+ $this->get_block_supports_stylesheet()
+ );
+ }
+
+ /**
+ * Tests that a block can be rotated on a viewport only.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_renders_viewport_rotation_without_default_rotation() {
+ $this->register_test_block();
+
+ $class_name = $this->get_rotate_class(
+ $this->render_test_block(
+ array(
+ '@mobile' => array( 'rotate' => 45 ),
+ )
+ )
+ );
+
+ $this->assertNotNull( $class_name, 'The block should get a rotate class.' );
+ $this->assertSame(
+ "@media (width <= 480px){.$class_name{rotate:45deg;}}",
+ $this->get_block_supports_stylesheet()
+ );
+ }
+
+ /**
+ * Tests that blocks with the same rotation share a class and a rule.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_blocks_with_the_same_rotation_share_a_class() {
+ $this->register_test_block();
+
+ $first = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 15 ) ) );
+ $second = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 15 ) ) );
+ $third = $this->get_rotate_class( $this->render_test_block( array( 'rotate' => 30 ) ) );
+
+ $this->assertSame( $first, $second, 'Blocks with the same rotation should share a class.' );
+ $this->assertNotSame( $first, $third, 'Blocks with different rotations should have different classes.' );
+ $this->assertSame( ".$first{rotate:15deg;}.$third{rotate:30deg;}", $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that a block whose own style attribute is not an object is not rotated.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_does_not_render_when_style_attribute_is_not_an_object() {
+ $this->register_test_block( array( 'rotation' => true ), array( 'style' => array( 'type' => 'string' ) ) );
+ $block_content = 'Content
';
+
+ $this->assertSame( $block_content, $this->render_test_block( array( 'rotate' => 15 ), $block_content ) );
+ $this->assertSame( '', $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that viewport overrides use the theme's viewport breakpoints.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_renders_viewport_overrides_with_theme_breakpoints() {
+ $this->register_test_block();
+
+ $filter = static function ( $theme_json ) {
+ return $theme_json->update_with(
+ array(
+ 'version' => WP_Theme_JSON::LATEST_SCHEMA,
+ 'settings' => array(
+ 'viewport' => array(
+ 'mobile' => '640px',
+ 'tablet' => '960px',
+ ),
+ ),
+ )
+ );
+ };
+
+ add_filter( 'wp_theme_json_data_theme', $filter );
+ WP_Theme_JSON_Resolver::clean_cached_data();
+
+ try {
+ $class_name = $this->get_rotate_class(
+ $this->render_test_block(
+ array(
+ '@mobile' => array( 'rotate' => 10 ),
+ '@tablet' => array( 'rotate' => 0 ),
+ )
+ )
+ );
+
+ $this->assertSame(
+ "@media (width <= 640px){.$class_name{rotate:10deg;}}" .
+ "@media (640px < width <= 960px){.$class_name{rotate:none;}}",
+ $this->get_block_supports_stylesheet()
+ );
+ } finally {
+ remove_filter( 'wp_theme_json_data_theme', $filter );
+ WP_Theme_JSON_Resolver::clean_cached_data();
+ }
+ }
+
+ /**
+ * Tests that blocks that opt out of rotation are not rotated.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_does_not_render_when_block_opts_out() {
+ $this->register_test_block( array() );
+ $block_content = 'Content
';
+
+ $this->assertSame( $block_content, $this->render_test_block( array( 'rotate' => 15 ), $block_content ) );
+ $this->assertSame( '', $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that block content without a tag to rotate is left alone.
+ *
+ * @covers ::wp_render_rotate_support
+ */
+ public function test_does_not_render_without_a_tag() {
+ $this->register_test_block();
+
+ $this->assertSame( 'Plain text', $this->render_test_block( array( 'rotate' => 15 ), 'Plain text' ) );
+ $this->assertSame( '', $this->get_block_supports_stylesheet() );
+ }
+
+ /**
+ * Tests that the rotate property is allowed in inline styles.
+ *
+ * @covers ::safecss_filter_attr
+ */
+ public function test_rotate_is_a_safe_css_property() {
+ $this->assertSame( 'rotate: 15deg', safecss_filter_attr( 'rotate: 15deg' ) );
+ $this->assertSame( 'rotate: none', safecss_filter_attr( 'rotate: none' ) );
+ }
+}