diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php
new file mode 100644
index 0000000000000..f3b751258651b
--- /dev/null
+++ b/src/wp-includes/block-supports/states.php
@@ -0,0 +1,91 @@
+get_registered( $block['blockName'] );
+ if ( ! $block_type ) {
+ return $block_content;
+ }
+
+ $supported_states = $block_type->supports['__experimentalStates'] ?? null;
+ if ( empty( $supported_states ) || ! is_array( $supported_states ) ) {
+ return $block_content;
+ }
+
+ $style = $block['attrs']['style'] ?? array();
+ $css_rules = array();
+
+ foreach ( $supported_states as $state ) {
+ if ( empty( $style[ $state ] ) || ! is_array( $style[ $state ] ) ) {
+ continue;
+ }
+
+ $compiled = wp_style_engine_get_styles( $style[ $state ] );
+ if ( ! empty( $compiled['css'] ) ) {
+ $css_rules[] = array(
+ 'state' => $state,
+ 'css' => $compiled['css'],
+ );
+ }
+ }
+
+ if ( empty( $css_rules ) ) {
+ return $block_content;
+ }
+
+ $unique_class = 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 );
+ $css = '';
+
+ foreach ( $css_rules as $rule ) {
+ // Use !important to override utility classes like
+ // .has-accent-3-background-color which are generated with !important.
+ $declarations = str_replace( ';', ' !important;', $rule['css'] );
+ $css .= ".$unique_class$rule[state] { $declarations }\n";
+ }
+
+ // Add the unique class to the interactive element so that state selectors
+ // like `.$unique_class:hover` match directly without needing a descendant.
+ // If the block declares selectors.root with a descendant (e.g. the button
+ // block's ".wp-block-button .wp-block-button__link"), we extract the last
+ // class and walk to that element. Otherwise we fall back to the wrapper.
+ $root_selector = $block_type->selectors['root'] ?? null;
+ $target_class = null;
+ if ( $root_selector && preg_match( '/\.([a-zA-Z0-9_-]+)\s*$/', $root_selector, $matches ) ) {
+ $target_class = $matches[1];
+ }
+
+ $processor = new WP_HTML_Tag_Processor( $block_content );
+ if ( $target_class ) {
+ while ( $processor->next_tag() ) {
+ if ( $processor->has_class( $target_class ) ) {
+ $processor->add_class( $unique_class );
+ break;
+ }
+ }
+ } elseif ( $processor->next_tag() ) {
+ $processor->add_class( $unique_class );
+ }
+ $block_content = $processor->get_updated_html();
+
+ return '' . $block_content;
+}
+add_filter( 'render_block', 'wp_render_block_states_support', 10, 2 );
diff --git a/src/wp-settings.php b/src/wp-settings.php
index dab1d8fd4c0de..cfcc6e828cce7 100644
--- a/src/wp-settings.php
+++ b/src/wp-settings.php
@@ -430,6 +430,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/states.php';
require ABSPATH . WPINC . '/style-engine.php';
require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php';
require ABSPATH . WPINC . '/style-engine/class-wp-style-engine-css-declarations.php';
diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php
new file mode 100644
index 0000000000000..2c3cf38e28139
--- /dev/null
+++ b/tests/phpunit/tests/block-supports/states.php
@@ -0,0 +1,532 @@
+test_block_name = null;
+ }
+
+ public function tear_down() {
+ if ( $this->test_block_name ) {
+ unregister_block_type( $this->test_block_name );
+ }
+ $this->test_block_name = null;
+ parent::tear_down();
+ }
+
+ /**
+ * Registers a block with `__experimentalStates` support.
+ *
+ * @param string $block_name Block name.
+ * @param array $selectors Optional block selectors (e.g. `['root' => '.foo .bar']`).
+ * @return WP_Block_Type
+ */
+ private function register_block_with_states( $block_name, $selectors = array() ) {
+ $this->test_block_name = $block_name;
+ $args = array(
+ 'api_version' => 3,
+ 'attributes' => array(
+ 'style' => array( 'type' => 'object' ),
+ ),
+ 'supports' => array(
+ '__experimentalStates' => array( ':hover', ':focus', ':active' ),
+ ),
+ );
+ if ( ! empty( $selectors ) ) {
+ $args['selectors'] = $selectors;
+ }
+ register_block_type( $block_name, $args );
+
+ return WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
+ }
+
+ /**
+ * Mirrors the CSS-building logic in wp_render_block_states_support()
+ * to produce the expected `',
+ 'unique_class' => $unique_class,
+ );
+ }
+
+ /**
+ * Tests that block content is returned unchanged when the block name is missing.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_returns_unchanged_when_block_name_missing() {
+ $block_content = '
Hello
';
+ $block = array(
+ 'blockName' => '',
+ 'attrs' => array(),
+ );
+
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $block_content, $actual );
+ }
+
+ /**
+ * Tests that block content is returned unchanged when content is empty.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_returns_unchanged_when_block_content_empty() {
+ $this->register_block_with_states( 'test/states-empty-content' );
+
+ $block = array(
+ 'blockName' => 'test/states-empty-content',
+ 'attrs' => array(
+ 'style' => array(
+ ':hover' => array( 'color' => array( 'text' => '#ff0000' ) ),
+ ),
+ ),
+ );
+
+ $actual = wp_render_block_states_support( '', $block );
+
+ $this->assertSame( '', $actual );
+ }
+
+ /**
+ * Tests that block content is returned unchanged when `__experimentalStates` support is not declared.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_returns_unchanged_when_states_support_not_declared() {
+ $this->test_block_name = 'test/no-states-support';
+ register_block_type(
+ $this->test_block_name,
+ array(
+ 'api_version' => 3,
+ 'attributes' => array(
+ 'style' => array( 'type' => 'object' ),
+ ),
+ 'supports' => array(),
+ )
+ );
+
+ $block_content = 'Hello
';
+ $block = array(
+ 'blockName' => 'test/no-states-support',
+ 'attrs' => array(
+ 'style' => array(
+ ':hover' => array( 'color' => array( 'text' => '#ff0000' ) ),
+ ),
+ ),
+ );
+
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $block_content, $actual );
+ }
+
+ /**
+ * Tests that block content is returned unchanged when no state styles are set.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_returns_unchanged_when_no_state_styles_set() {
+ $this->register_block_with_states( 'test/states-no-state-style' );
+
+ $block_content = 'Hello
';
+ $block = array(
+ 'blockName' => 'test/states-no-state-style',
+ 'attrs' => array(
+ 'style' => array(
+ 'color' => array( 'text' => '#000000' ),
+ ),
+ ),
+ );
+
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $block_content, $actual );
+ }
+
+ /**
+ * Tests that block content is returned unchanged when the state key is an empty array.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_returns_unchanged_when_state_style_is_empty_array() {
+ $this->register_block_with_states( 'test/states-empty-hover' );
+
+ $block_content = 'Hello
';
+ $block = array(
+ 'blockName' => 'test/states-empty-hover',
+ 'attrs' => array(
+ 'style' => array(
+ ':hover' => array(),
+ ),
+ ),
+ );
+
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $block_content, $actual );
+ }
+
+ /**
+ * Tests that hover text color generates a scoped style tag with !important.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_text_color_generates_scoped_css() {
+ $this->register_block_with_states( 'test/states-hover-text-color' );
+
+ $block_content = 'Hello
';
+ $state_styles = array( ':hover' => array( 'color' => array( 'text' => '#e6ffe8' ) ) );
+ $block = array(
+ 'blockName' => 'test/states-hover-text-color',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover background color generates a scoped style tag.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_background_color_generates_scoped_css() {
+ $this->register_block_with_states( 'test/states-hover-bg-color' );
+
+ $block_content = 'Hello
';
+ $state_styles = array( ':hover' => array( 'color' => array( 'background' => '#ff00d0' ) ) );
+ $block = array(
+ 'blockName' => 'test/states-hover-bg-color',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover text and background color both appear in a single rule.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_text_and_background_color_in_same_rule() {
+ $this->register_block_with_states( 'test/states-hover-both-colors' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array(
+ 'color' => array(
+ 'background' => '#ff00d0',
+ 'text' => '#e6ffe8',
+ ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-hover-both-colors',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that a font family stored as a preset reference is resolved to a CSS
+ * custom property in the generated style tag.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_font_family_preset_reference_generates_css_custom_property() {
+ $this->register_block_with_states( 'test/states-hover-font-family' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array(
+ 'typography' => array( 'fontFamily' => 'var:preset|font-family|heading' ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-hover-font-family',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover font size generates a scoped style tag.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_font_size_generates_scoped_css() {
+ $this->register_block_with_states( 'test/states-hover-font-size' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array(
+ 'typography' => array( 'fontSize' => '1.5rem' ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-hover-font-size',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover border width and color generate a scoped style tag.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_border_width_and_color_generate_scoped_css() {
+ $this->register_block_with_states( 'test/states-hover-border' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array(
+ 'border' => array(
+ 'width' => '2px',
+ 'color' => '#000000',
+ ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-hover-border',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover border radius generates a scoped style tag.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_hover_border_radius_generates_scoped_css() {
+ $this->register_block_with_states( 'test/states-hover-border-radius' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array(
+ 'border' => array( 'radius' => '8px' ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-hover-border-radius',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that multiple states each generate a separate scoped CSS rule.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_multiple_states_generate_separate_css_rules() {
+ $this->register_block_with_states( 'test/states-multiple' );
+
+ $block_content = 'Hello
';
+ $state_styles = array(
+ ':hover' => array( 'color' => array( 'text' => '#ff0000' ) ),
+ ':focus' => array( 'color' => array( 'text' => '#00ff00' ) ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-multiple',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that the unique scoped class is added to the wrapper element for a
+ * block with no descendant root selector.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_unique_class_is_added_to_wrapper_when_no_root_selector() {
+ $this->register_block_with_states( 'test/states-wrapper' );
+
+ $block_content = 'Hello
';
+ $state_styles = array( ':hover' => array( 'color' => array( 'text' => '#ff0000' ) ) );
+ $block = array(
+ 'blockName' => 'test/states-wrapper',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . 'Hello
';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that the unique scoped class is added to the descendant element (not
+ * the wrapper) for a block whose `selectors.root` targets a descendant, so
+ * that `.wp-states-XXXX:hover` matches correctly.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_selector_has_descendant() {
+ $this->register_block_with_states(
+ 'test/states-descendant',
+ array( 'root' => '.wp-block-button .wp-block-button__link' )
+ );
+
+ $block_content = '';
+ $state_styles = array( ':hover' => array( 'color' => array( 'background' => '#ff00d0' ) ) );
+ $block = array(
+ 'blockName' => 'test/states-descendant',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . '';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Integration test using the exact block markup and style attribute captured
+ * from a core/button block in the editor with Twenty Twenty-Four theme.
+ * Covers color, typography (preset font family reference), and class injection
+ * onto the descendant element.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_button_like_block_with_hover_color_and_font_family_preset() {
+ $this->register_block_with_states(
+ 'test/states-button-full',
+ array( 'root' => '.wp-block-button .wp-block-button__link' )
+ );
+
+ $block_content = '';
+ $state_styles = array(
+ ':hover' => array(
+ 'color' => array(
+ 'background' => '#ff00d0',
+ 'text' => '#e6ffe8',
+ ),
+ // Font family is stored as a preset reference by the editor.
+ 'typography' => array(
+ 'fontFamily' => 'var:preset|font-family|heading',
+ ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-button-full',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . '';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Tests that hover border styles on a button-like block are scoped to the
+ * descendant element.
+ *
+ * @covers ::wp_render_block_states_support
+ */
+ public function test_button_like_block_with_hover_border() {
+ $this->register_block_with_states(
+ 'test/states-button-border',
+ array( 'root' => '.wp-block-button .wp-block-button__link' )
+ );
+
+ $block_content = '';
+ $state_styles = array(
+ ':hover' => array(
+ 'border' => array(
+ 'color' => '#0000ff',
+ 'width' => '3px',
+ 'style' => 'dashed',
+ ),
+ ),
+ );
+ $block = array(
+ 'blockName' => 'test/states-button-border',
+ 'attrs' => array( 'style' => $state_styles ),
+ );
+
+ $parts = $this->build_expected_state_output( $state_styles );
+ $expected = $parts['style_tag'] . '';
+ $actual = wp_render_block_states_support( $block_content, $block );
+
+ $this->assertSame( $expected, $actual );
+ }
+}