Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions docs/how-to-guides/themes/global-settings-and-styles.md
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,83 @@ Pseudo selectors `:hover`, `:focus`, `:focus-visible`, `:visited`, `:active`, `:
}
```

#### Responsive styles

Block styles can be scoped to two named breakpoints: `mobile` and `tablet`. Any style property that is valid at the block or element level can be nested under one of these keys.

| Key | Media query applied |
| --- | --- |
| `mobile` | `@media (width <= 480px)` |
| `tablet` | `@media (480px < width <= 782px)` |

Responsive overrides can be placed directly on a block node:

```json
{
"version": 3,
"styles": {
"blocks": {
"core/group": {
"color": {
"text": "black"
},
"mobile": {
"color": {
"text": "hotpink"
}
}
}
}
}
}
```

```css
:root :where(.wp-block-group) { color: black; }
@media (width <= 480px) { :root :where(.wp-block-group) { color: hotpink; } }
```

They can also be placed on element nodes within a block:

```json
{
"version": 3,
"styles": {
"blocks": {
"core/group": {
"elements": {
"link": {
"color": { "text": "blue" },
":hover": {
"color": { "text": "navy" }
}
}
},
"mobile": {
"elements": {
"link": {
"color": { "text": "red" },
":hover": {
"color": { "text": "darkred" }
}
}
}
}
}
}
}
}
```

```css
:root :where(.wp-block-group a) { color: blue; }
@media (width <= 480px) { :root :where(.wp-block-group a) { color: red; } }
:root :where(.wp-block-group a:hover) { color: navy; }
@media (width <= 480px) { :root :where(.wp-block-group a:hover) { color: darkred; } }
```

Responsive overrides are always output after the default styles they override, so the cascade order is preserved without needing to increase specificity.

#### Variations

A block can have a "style variation," as defined in the [block.json specification](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/#styles-optional). Theme authors can define the style attributes for an existing style variation using the `theme.json` file. Styles for unregistered style variations will be ignored.
Expand Down
371 changes: 356 additions & 15 deletions lib/class-wp-theme-json-gutenberg.php

Large diffs are not rendered by default.

179 changes: 174 additions & 5 deletions packages/global-styles-engine/src/core/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ const VALID_BLOCK_PSEUDO_SELECTORS: Record< string, string[] > = {
'core/navigation-link': [ ':hover', ':focus', ':focus-visible', ':active' ],
};

/**
* Responsive breakpoint state keys and their corresponding CSS media queries.
* Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS.
*/
const RESPONSIVE_BREAKPOINTS: Record< string, string > = {
mobile: '@media (width <= 480px)',
tablet: '@media (480px < width <= 782px)',
};

/**
* Transform given preset tree into a set of preset class declarations.
*
Expand Down Expand Up @@ -875,9 +884,17 @@ function pickStyleAndPseudoKeys(
const allowedPseudoSelectors = blockName
? VALID_BLOCK_PSEUDO_SELECTORS[ blockName ] ?? []
: [];
// Responsive breakpoint keys are available for all blocks (blockName contains '/').
const includeResponsive = blockName?.includes( '/' ) ?? false;
const pickedEntries = entries.filter(
( [ key ] ) =>
STYLE_KEYS.includes( key ) || allowedPseudoSelectors.includes( key )
STYLE_KEYS.includes( key ) ||
allowedPseudoSelectors.includes( key ) ||
( includeResponsive &&
Object.prototype.hasOwnProperty.call(
RESPONSIVE_BREAKPOINTS,
key
) )
);
// clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it
const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [
Expand Down Expand Up @@ -973,6 +990,124 @@ function appendPseudoSelectorStyles(
return ruleset;
}

/**
* Appends CSS rules for responsive breakpoint states to a ruleset string.
* Block styles stored under 'mobile' or 'tablet' keys are wrapped in the
* corresponding media queries instead of being appended to the selector.
*
* @param styles The styles object potentially containing responsive keys.
* @param selector The base CSS selector for the block.
* @param ruleset The accumulating CSS ruleset string.
* @param featureSelectors Optional feature-level selectors for the block.
* @param treeSettings Global styles settings tree.
* @param styleVariationSelector Optional style variation selector.
* @param blockRootSelector Optional block root selector used to detect block-level feature selectors.
* @param styleVariationName Optional variation name used when applying variation class to block-level feature selectors.
* @return Updated ruleset string with responsive CSS rules appended.
*/
function appendResponsiveStyles(
styles: Record< string, any >,
selector: string,
ruleset: string,
featureSelectors:
| string
| Record< string, string | Record< string, string > >
| undefined,
treeSettings: Record< string, any > | undefined,
styleVariationSelector?: string,
blockRootSelector?: string,
styleVariationName?: string
): string {
const responsiveStyles = Object.entries( styles ).filter( ( [ key ] ) =>
Object.prototype.hasOwnProperty.call( RESPONSIVE_BREAKPOINTS, key )
);

if ( ! responsiveStyles.length ) {
return ruleset;
}

responsiveStyles.forEach( ( [ breakpointKey, breakpointStyle ] ) => {
if ( ! breakpointStyle || typeof breakpointStyle !== 'object' ) {
return;
}

const mediaQuery = RESPONSIVE_BREAKPOINTS[ breakpointKey ];
const remainingBreakpointStyles = JSON.parse(
JSON.stringify( breakpointStyle )
);

if ( featureSelectors && typeof featureSelectors !== 'string' ) {
let breakpointFeatureDeclarations = getFeatureDeclarations(
featureSelectors,
remainingBreakpointStyles
);

breakpointFeatureDeclarations = updateParagraphTextIndentSelector(
breakpointFeatureDeclarations,
treeSettings,
undefined
);

breakpointFeatureDeclarations = updateButtonWidthDeclarations(
breakpointFeatureDeclarations,
treeSettings
);

Object.entries( breakpointFeatureDeclarations ).forEach(
( [ baseSelector, declarations ] ) => {
if ( ! declarations.length ) {
return;
}
let cssSelector: string;
if ( ! styleVariationSelector ) {
cssSelector = baseSelector;
} else if (
blockRootSelector &&
styleVariationName &&
! baseSelector.includes( blockRootSelector )
) {
/*
* Feature selector is block-level (e.g. `.wp-block-button` for
* dimensions/width) — apply the variation class directly to it.
*/
cssSelector = getBlockStyleVariationSelector(
styleVariationName,
baseSelector
);
} else {
cssSelector = concatFeatureVariationSelectorString(
baseSelector,
styleVariationSelector
);
}
const rules = declarations.join( ';' );
ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ rules };}}`;
}
);
}

const breakpointDeclarations = getStylesDeclarations(
remainingBreakpointStyles
);

if ( ! breakpointDeclarations.length ) {
return;
}

const cssSelector = styleVariationSelector
? concatFeatureVariationSelectorString(
selector,
styleVariationSelector
)
: selector;
ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join(
';'
) };}}`;
} );

return ruleset;
}

export const getNodesWithStyles = (
tree: GlobalStylesConfig,
blockSelectors: string | BlockSelectors
Expand Down Expand Up @@ -1639,11 +1774,26 @@ export const transformToStyles = (
string[],
] ) => {
if ( declarations.length ) {
/*
* If the feature selector does not include the block's
* root selector (e.g. core/button dimensions width uses
* `.wp-block-button` while root is
* `.wp-block-button .wp-block-button__link`), apply the
* variation class directly to the feature selector.
*/
const cssSelector =
concatFeatureVariationSelectorString(
baseSelector,
styleVariationSelector as string
);
! selector ||
baseSelector.includes(
selector
)
? concatFeatureVariationSelectorString(
baseSelector,
styleVariationSelector as string
)
: getBlockStyleVariationSelector(
styleVariationName,
baseSelector
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the other part of the fix for the Button variation selector issue mentioned above.

const rules =
declarations.join( ';' );
ruleset += `:root :where(${ cssSelector }){${ rules };}`;
Expand Down Expand Up @@ -1682,6 +1832,17 @@ export const transformToStyles = (
styleVariationSelector as string
);

ruleset = appendResponsiveStyles(
styleVariations,
styleVariationSelector as string,
ruleset,
featureSelectors,
tree.settings,
styleVariationSelector as string,
selector,
styleVariationName
);

// Generate layout styles for the variation if it supports layout and has blockGap defined.
if (
hasLayoutSupport &&
Expand Down Expand Up @@ -1711,6 +1872,14 @@ export const transformToStyles = (
tree.settings,
name
);

ruleset = appendResponsiveStyles(
styles,
selector,
ruleset,
featureSelectors,
tree.settings
);
}
);
}
Expand Down
34 changes: 25 additions & 9 deletions packages/global-styles-ui/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export function useStyle< T = any >(
state?: string
Copy link
Copy Markdown
Contributor

@talldan talldan Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If as per the other comment, a user is in the future able to select a combination of states, then this would need to be updated to support more than just a string. It's probably not too complicate though, and object like { responsive: 'mobile', pseudo: 'focus' } would work I think, and that would remove the need for some of the isPseudoSelectorState stuff.

) {
const { user, base, merged, onChange } = useContext( GlobalStylesContext );
const isPseudoSelectorState = state?.startsWith( ':' );
const pseudoSelectorState = isPseudoSelectorState ? state : undefined;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming here is a bit confusing. Might we in the future have states that are neither responsive nor pseudo-selectors? If not it might not be a problem

const stylePath =
state && ! isPseudoSelectorState
? [ path, state ].filter( Boolean ).join( '.' )
: path;

let sourceValue = merged;
if ( readFrom === 'base' ) {
Expand All @@ -61,23 +67,33 @@ export function useStyle< T = any >(
sourceValue = user;
}

const styleValue = useMemo( () => {
const styleValue = useMemo< T | undefined >( () => {
const rawValue = getStyle< T >(
sourceValue,
path,
stylePath,
blockName,
shouldDecodeEncode
);
if ( state ) {
return ( rawValue as any )?.[ state ] ?? {};
if ( pseudoSelectorState ) {
return (
( rawValue as Record< string, T | undefined > )?.[
pseudoSelectorState
] ?? ( {} as T )
);
}
return rawValue;
}, [ sourceValue, path, blockName, shouldDecodeEncode, state ] );
}, [
sourceValue,
stylePath,
blockName,
shouldDecodeEncode,
pseudoSelectorState,
] );

const setStyleValue = useCallback(
( newValue: T | undefined ) => {
let valueToSet: any = newValue;
if ( state ) {
if ( pseudoSelectorState ) {
const fullCurrentValue = getStyle(
user,
path,
Expand All @@ -86,18 +102,18 @@ export function useStyle< T = any >(
);
valueToSet = {
...( fullCurrentValue as object ),
[ state ]: newValue,
[ pseudoSelectorState ]: newValue,
};
}
const newGlobalStyles = setStyle< any >(
user,
path,
stylePath,
valueToSet,
blockName
);
onChange( newGlobalStyles );
},
[ user, onChange, path, blockName, state ]
[ user, onChange, path, stylePath, blockName, pseudoSelectorState ]
);

return [ styleValue, setStyleValue ] as const;
Expand Down
Loading
Loading