WordPress theme.json Button Hover, Focus and Active Styles

Styling a WordPress theme.json button hover state is a common search, and the theme.json reference doesn’t make it obvious. The Site Editor lets you style buttons, but most themes give you little control over what happens when a visitor hovers over a button, tabs to it with the keyboard, or presses it. If the active theme is a parent theme like Twenty Twenty-Five, editing its theme.json isn’t an option either, because the next theme update overwrites your changes.

The snippet below adds :hover, :focus-visible and :active styles to the theme’s button element from PHP. They go into the same theme.json data that WordPress turns into global styles. This covers the Button block, the Search block button, the File block download button and any other block that outputs .wp-element-button, on the front end and in the editor. Put it in a small plugin, a child theme’s functions.php, or a Scripts Organizer PHP block.

The snippet

<?php
/**
 * Plugin Name: DPC Button States
 * Description: Adds hover, focus-visible and active styles to the theme.json button element.
 */

/**
 * Button state styles in theme.json format.
 * Each colour uses a theme preset with a hard fallback, so it works on any theme.
 */
function dpc_button_state_styles(): array {
	$states = array(
		':hover'         => array(
			'color' => array(
				'background' => 'var(--wp--preset--color--contrast, #111111)',
				'text'       => 'var(--wp--preset--color--base, #ffffff)',
			),
		),
		':focus-visible' => array(
			'outline' => array(
				'color'  => 'var(--wp--preset--color--contrast, #111111)',
				'offset' => '3px',
				'style'  => 'solid',
				'width'  => '2px',
			),
		),
		':active'        => array(
			'color' => array(
				'background' => 'color-mix(in srgb, var(--wp--preset--color--contrast, #111111) 75%, #ffffff)',
				'text'       => 'var(--wp--preset--color--base, #ffffff)',
			),
		),
	);

	$states  = (array) apply_filters( 'dpc_button_state_styles', $states );
	$allowed = array( ':hover', ':focus', ':focus-visible', ':active' );

	return array_intersect_key( $states, array_flip( $allowed ) );
}

/**
 * Merge the states into the theme origin of theme.json.
 */
add_filter( 'wp_theme_json_data_theme', 'dpc_add_button_states' );
function dpc_add_button_states( $theme_json ) {
	if ( ! is_object( $theme_json ) || ! method_exists( $theme_json, 'update_with' ) ) {
		return $theme_json;
	}

	$states = dpc_button_state_styles();
	if ( empty( $states ) ) {
		return $theme_json;
	}

	return $theme_json->update_with(
		array(
			'version' => WP_Theme_JSON::LATEST_SCHEMA,
			'styles'  => array(
				'elements' => array(
					'button' => $states,
				),
			),
		)
	);
}

How it works

  • wp_theme_json_data_theme runs when WordPress reads the active theme’s theme.json. update_with() merges your array into the existing data, so the theme’s own button padding, radius and typography stay as they are and only the keys you set change.
  • In theme.json, pseudo-classes are keys under styles.elements.button (and styles.elements.link). WordPress generates .wp-element-button:hover, .wp-block-button__link:hover rules from them, wrapped in :where() so they keep low specificity.
  • Colours use var(--wp--preset--color--contrast, #111111): if the theme has a contrast palette slug you get its colour, otherwise you get the fallback. Swap in your theme’s slugs.
  • The allow-list throws away anything that isn’t a pseudo-class theme.json supports for buttons. Without it, WordPress would quietly drop the key and you’d be left wondering why nothing happened.
  • :focus-visible adds the outline only for keyboard focus, not for mouse clicks, so keyboard users get a clear focus ring and mouse users don’t see one after clicking.

Customise it

Change the states from anywhere else in your code through the dpc_button_state_styles filter, for example to add a border colour on hover:

add_filter( 'dpc_button_state_styles', function ( array $states ): array {
	$states[':hover']['border'] = array( 'color' => 'var(--wp--preset--color--contrast, #111111)' );
	return $states;
} );

If you maintain the theme yourself, skip the PHP and write the same thing directly in theme.json. There you can use the shorter var:preset|color|slug syntax:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "styles": {
    "elements": {
      "button": {
        ":hover": {
          "color": {
            "background": "var:preset|color|contrast",
            "text": "var:preset|color|base"
          }
        },
        ":focus-visible": {
          "outline": {
            "color": "var:preset|color|contrast",
            "offset": "3px",
            "style": "solid",
            "width": "2px"
          }
        }
      }
    }
  }
}

For hover styles on a custom button design rather than on every button, register a block style for core/button instead. The approach is the same as in Build Gutenberg Block Styles for WordPress Headings.

Gotchas

  • Colour picked in the block sidebar wins. If a button has a palette background chosen in its settings, WordPress adds a has-*-background-color class with !important, so the hover background can’t override it. Leave the colour at default and let theme.json handle it.
  • The Outline style ignores the hover background. Core’s .is-style-outline rule is more specific than the :where() rules from global styles. Hover text and outline colours still apply.
  • User styles override theme styles. Button colours saved in the Site Editor’s Styles panel (the user origin) are applied after the theme origin. If nothing changes, check Styles > Blocks > Button and reset it.
  • Only button and link elements support pseudo-classes in theme.json. To style the navigation block, see Change the Navigation Overlay Menu Background in WordPress.

Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.

0 of 10 votes

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.