Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b47ea62
Add custom CSS support for individual block instances
glendaviesnz Dec 18, 2025
75fdc6f
Fixes from review
glendaviesnz Dec 18, 2025
bfa879c
remove redundant method definition
glendaviesnz Jan 5, 2026
5b58d33
Add block support so customCSS can be disabled by blocks
glendaviesnz Jan 5, 2026
d4aa543
Enqueue the custom css and remove global
glendaviesnz Jan 5, 2026
f4a67ab
Disable on custom html block
glendaviesnz Jan 7, 2026
ade3aaa
Disable on RawHTML blocks
glendaviesnz Jan 7, 2026
84dafba
Add unit tests
glendaviesnz Jan 7, 2026
2f0256b
Update docs
glendaviesnz Jan 7, 2026
218155f
Fix static analysis issues
glendaviesnz Jan 15, 2026
06712df
Fix e2e
glendaviesnz Jan 15, 2026
6fa349b
Add same sanitization as global customCSS and also limit to users wit…
glendaviesnz Jan 18, 2026
82884e4
Revert package-lock.json change
glendaviesnz Jan 19, 2026
c9e5cbe
Switch to textarea control to better match other panel styling
glendaviesnz Jan 19, 2026
f14260c
Revert "Switch to textarea control to better match other panel styling"
glendaviesnz Jan 21, 2026
82dde70
Fixes from PR review
glendaviesnz Jan 21, 2026
ad1730a
Don't add invalid CSS to the editor and show error to user on change
glendaviesnz Jan 22, 2026
ac76315
disable customCSS on archive block
glendaviesnz Jan 22, 2026
af22573
don't save white space only css strings
glendaviesnz Jan 22, 2026
24cab8d
enable again on archive block, it does work
glendaviesnz Jan 22, 2026
139af04
add backport changelog
glendaviesnz Jan 23, 2026
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
14 changes: 7 additions & 7 deletions docs/reference-guides/core-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Reuse this design across your site. ([Source](https://github.com/WordPress/guten

- **Name:** core/block
- **Category:** reusable
- **Supports:** interactivity (clientNavigation), ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~
- **Supports:** interactivity (clientNavigation), ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~renaming~~
- **Attributes:** content, ref

## Breadcrumbs
Expand Down Expand Up @@ -380,7 +380,7 @@ Use the classic WordPress editor. ([Source](https://github.com/WordPress/gutenbe

- **Name:** core/freeform
- **Category:** text
- **Supports:** ~~className~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~
- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~
- **Attributes:** content

## Gallery
Expand Down Expand Up @@ -427,7 +427,7 @@ Add custom HTML code and preview it as you edit. ([Source](https://github.com/Wo

- **Name:** core/html
- **Category:** widgets
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~
- **Attributes:** content

## Image
Expand Down Expand Up @@ -511,7 +511,7 @@ Your site doesn’t include support for this block. ([Source](https://github.com

- **Name:** core/missing
- **Category:** text
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~inserter~~, ~~lock~~, ~~renaming~~, ~~reusable~~, ~~visibility~~
- **Attributes:** originalContent, originalName, originalUndelimitedContent

## More
Expand All @@ -520,7 +520,7 @@ Content before this block will be shown in the excerpt on your archives page. ([

- **Name:** core/more
- **Category:** design
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~visibility~~
- **Supports:** interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~multiple~~, ~~visibility~~
- **Attributes:** customText, noTeaser

## Navigation
Expand Down Expand Up @@ -571,7 +571,7 @@ Separate your content into a multi-page experience. ([Source](https://github.com
- **Name:** core/nextpage
- **Category:** design
- **Parent:** core/post-content
- **Supports:** anchor, interactivity (clientNavigation), ~~className~~, ~~customClassName~~, ~~html~~, ~~visibility~~
- **Supports:** anchor, interactivity (clientNavigation), ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~, ~~visibility~~

## Page List

Expand Down Expand Up @@ -904,7 +904,7 @@ Insert additional custom elements with a WordPress shortcode. ([Source](https://

- **Name:** core/shortcode
- **Category:** widgets
- **Supports:** ~~className~~, ~~customClassName~~, ~~html~~
- **Supports:** ~~className~~, ~~customCSS~~, ~~customClassName~~, ~~html~~
- **Attributes:** text

## Site Logo
Expand Down
94 changes: 94 additions & 0 deletions lib/block-supports/custom-css.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php
/**
* Custom CSS block support.
*
* @package gutenberg
*/

/**
* Render the custom CSS stylesheet and add class name to block as required.
*
* @since 7.0.0
*
* @param array $parsed_block The parsed block.
* @return array The same parsed block with custom CSS class name added if appropriate.
*/
function gutenberg_render_custom_css_support_styles( $parsed_block ) {
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $parsed_block['blockName'] );

if ( ! block_has_support( $block_type, 'customCSS', true ) ) {
return $parsed_block;
}

$custom_css = $parsed_block['attrs']['style']['css'] ?? null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just remind me again, the santitization takes place before going into the database, right?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also, are there any if ( ! current_user_can( 'edit_css' ) ) checks required? Sorry, I'm doing a very quick test of this so forgive me if this was addressed already.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just remind me again, the santitization takes place before going into the database, right?

Have added the same sanitisation at save as global custom CSS

Also, are there any if ( ! current_user_can( 'edit_css' ) ) checks required?

I guess so, added now.


if ( empty( $custom_css ) ) {
return $parsed_block;
}

// Generate a unique class name for this block instance.
$class_name = wp_unique_id_from_values( $parsed_block, 'wp-custom-css-' );
$updated_class_name = isset( $parsed_block['attrs']['className'] )
? $parsed_block['attrs']['className'] . " $class_name"
: $class_name;

_wp_array_set( $parsed_block, array( 'attrs', 'className' ), $updated_class_name );

// Process the custom CSS using the same method as global styles.
$selector = '.' . $class_name;
$processed_css = WP_Theme_JSON_Gutenberg::process_blocks_custom_css( $custom_css, $selector );

if ( ! empty( $processed_css ) ) {
/*
* Register and add inline style for block custom CSS.
* The style depends on global-styles to ensure custom CSS loads after
* and can override global styles.
*/
wp_register_style( 'wp-block-custom-css', false, array( 'global-styles' ) );
wp_add_inline_style( 'wp-block-custom-css', $processed_css );
}

return $parsed_block;
}

/**
* Enqueues the block custom CSS styles.
*
* @since 7.0.0
*/
function gutenberg_enqueue_block_custom_css() {
wp_enqueue_style( 'wp-block-custom-css' );
}

/**
* Applies the custom CSS class name to the block's rendered HTML.
*
* The class name is generated in `gutenberg_render_custom_css_support_styles`
* and stored in block attributes. This filter adds it to the actual markup.
*
* @since 7.0.0
*
* @param string $block_content Rendered block content.
* @param array $block Block object.
* @return string Filtered block content.
*/
function gutenberg_render_custom_css_class_name( $block_content, $block ) {
$class_string = $block['attrs']['className'] ?? '';
preg_match( '/\bwp-custom-css-\S+\b/', $class_string, $matches );

if ( empty( $matches ) ) {
return $block_content;
}

$tags = new WP_HTML_Tag_Processor( $block_content );

if ( $tags->next_tag() ) {
$tags->add_class( $matches[0] );
}

return $tags->get_updated_html();
}

add_filter( 'render_block', 'gutenberg_render_custom_css_class_name', 10, 2 );
add_filter( 'render_block_data', 'gutenberg_render_custom_css_support_styles', 10, 1 );
add_action( 'wp_enqueue_scripts', 'gutenberg_enqueue_block_custom_css', 1 );
2 changes: 1 addition & 1 deletion lib/class-wp-theme-json-gutenberg.php
Original file line number Diff line number Diff line change
Expand Up @@ -1494,7 +1494,7 @@ public function get_stylesheet( $types = array( 'variables', 'styles', 'presets'
* @param string $selector The selector to nest.
* @return string The processed CSS.
*/
protected function process_blocks_custom_css( $css, $selector ) {
public static function process_blocks_custom_css( $css, $selector ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Likewise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this one need to be public because its used in lib/block-supports/custom-css.php

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, correct.

$processed_css = '';

if ( empty( $css ) ) {
Expand Down
1 change: 1 addition & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/block-supports/aria-label.php';
require __DIR__ . '/block-supports/anchor.php';
require __DIR__ . '/block-supports/block-visibility.php';
require __DIR__ . '/block-supports/custom-css.php';

// Client-side media processing.
if ( gutenberg_is_experiment_enabled( 'gutenberg-media-processing' ) ) {
Expand Down
10 changes: 0 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function AdvancedPanel( {
value,
onChange,
inheritedValue = value,
help,
} ) {
// Custom CSS
const [ cssError, setCSSError ] = useState( null );
Expand Down Expand Up @@ -69,12 +70,15 @@ export default function AdvancedPanel( {
</Notice>
) }
<TextareaControl
__nextHasNoMarginBottom
__next40pxDefaultSize
Comment thread
aaronrobertshaw marked this conversation as resolved.
Resolved
label={ __( 'Additional CSS' ) }
value={ customCSS }
onChange={ ( newValue ) => handleOnChange( newValue ) }
onBlur={ handleOnBlur }
className="block-editor-global-styles-advanced-panel__custom-css-input"
spellCheck={ false }
help={ help }
/>
</VStack>
);
Expand Down
149 changes: 149 additions & 0 deletions packages/block-editor/src/hooks/custom-css.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* WordPress dependencies
*/
import { useMemo } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
import { useInstanceId } from '@wordpress/compose';
import { getBlockType, hasBlockSupport } from '@wordpress/blocks';
import { __, sprintf } from '@wordpress/i18n';
import { processCSSNesting } from '@wordpress/global-styles-engine';

/**
* Internal dependencies
*/
import InspectorControls from '../components/inspector-controls';
import AdvancedPanel from '../components/global-styles/advanced-panel';
import { cleanEmptyObject, useStyleOverride } from './utils';
import { store as blockEditorStore } from '../store';

// Stable reference for useInstanceId.
const CUSTOM_CSS_INSTANCE_REFERENCE = {};

/**
* Inspector control for custom CSS.
*
* @param {Object} props Component props.
* @param {string} props.blockName Block name.
* @param {Function} props.setAttributes Function to set block attributes.
* @param {Object} props.style Block style attribute.
*/
function CustomCSSControl( { blockName, setAttributes, style } ) {
const blockType = getBlockType( blockName );

function onChange( newStyle ) {
setAttributes( {
style: cleanEmptyObject( newStyle ),
} );
}

const cssHelpText = sprintf(
// translators: %s: is the name of a block e.g., 'Image' or 'Quote'.
__(
'Add your own CSS to customize the appearance of the %s block. You do not need to include a CSS selector, just add the property and value.'
Comment thread
aaronrobertshaw marked this conversation as resolved.
Resolved
),
blockType?.title
);

return (
<InspectorControls group="advanced">
<AdvancedPanel
value={ style }
onChange={ onChange }
inheritedValue={ style }
help={ cssHelpText }
/>
</InspectorControls>
);
}

function CustomCSSEdit( { clientId, name, setAttributes } ) {
const style = useSelect(
( select ) =>
select( blockEditorStore ).getBlockAttributes( clientId )?.style ||
{},
[ clientId ]
);

return (
<CustomCSSControl
blockName={ name }
setAttributes={ setAttributes }
style={ style }
/>
);
}

/**
* Hook to handle custom CSS for a block in the editor.
* Generates a unique class and applies scoped CSS via style override.
*
* @param {Object} props Block props.
* @param {Object} props.style Block style attribute.
* @return {Object} Block props including className for custom CSS scoping.
*/
function useBlockProps( { style } ) {
const customCSS = style?.css;

const customCSSIdentifier = useInstanceId(
CUSTOM_CSS_INSTANCE_REFERENCE,
'wp-custom-css'
);

const customCSSSelector = `.${ customCSSIdentifier }`;

// Transform the custom CSS using the same logic as global styles.
const transformedCSS = useMemo( () => {
return processCSSNesting( customCSS, customCSSSelector );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, I wonder whether we still need this now that nesting has solid browser support. Perhaps we can consider just wrapping the whole thing in the outer selector instead? (Probably not for this PR but something to be done globally wherever we're using this function)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nested CSS doesn't work in blocks anyway because you can't specify selectors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nesting does work for individual block custom CSS if you use the same syntax as for global block custom CSS, eg. if you had the following to a group block that has nested paragraphs it will work:

& p {
color: orange;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ah nice!

}, [ customCSS, customCSSSelector ] );

// Inject the CSS via style override.
useStyleOverride( { css: transformedCSS } );

// Only add the class if there's custom CSS.
if ( ! customCSS ) {
return {};
}

return {
className: customCSSIdentifier,
};
}

/**
* Adds a marker class to blocks with custom CSS for server-side rendering.
*
* @param {Object} props Additional props applied to save element.
* @param {Object} blockType Block type definition.
* @param {Object} attributes Block's attributes.
* @return {Object} Filtered props applied to save element.
*/
function addSaveProps( props, blockType, attributes ) {
if ( ! hasBlockSupport( blockType, 'customCSS', true ) ) {
return props;
}

if ( ! attributes?.style?.css ) {
return props;
}

// Add a class to indicate this block has custom CSS.
// The actual CSS is rendered server-side using the render_block filter.
const className = props.className
? `${ props.className } has-custom-css`
: 'has-custom-css';

return {
...props,
className,
};
}

export default {
edit: CustomCSSEdit,
useBlockProps,
addSaveProps,
attributeKeys: [ 'style' ],
hasSupport( name ) {
return hasBlockSupport( name, 'customCSS', true );
},
};
4 changes: 4 additions & 0 deletions packages/block-editor/src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import fontSize from './font-size';
import textAlign from './text-align';
import fitText from './fit-text';
import border from './border';
import customCSS from './custom-css';
import position from './position';
import blockStyleVariation from './block-style-variation';
import layout from './layout';
Expand All @@ -47,6 +48,7 @@ createBlockEditFilter(
anchor,
customClassName,
style,
customCSS,
duotone,
fitText,
position,
Expand All @@ -72,6 +74,7 @@ createBlockListBlockFilter( [
fontSize,
fitText,
border,
customCSS,
position,
blockStyleVariation,
childLayout,
Expand All @@ -83,6 +86,7 @@ createBlockSaveFilter( [
ariaLabel,
customClassName,
border,
customCSS,
fitText,
color,
style,
Expand Down
Loading
Loading