Last Monday, I landed on wpcrafter.com and noticed a cool little CSS effect on the homepage—it smoothly transitions between different words.

Naturally, I had to peek under the hood. Turns out, the site’s built with WordPress (of course), and the effect was done using Elementor. That got me thinking: “Hey, this would make a neat WordPress block! And I could build it without relying on any external libraries.”
So I started prototyping the block, and while doing that, I realized it was actually a great example of how to bring together several WordPress features, like:
- The Interactivity API
- The
WP_HTML_Tag_Processor(a.k.a. “The HTML API”) - The Formatting Toolbar API
- Static or Dynamic rendering of a block
I also thought this block would be perfect to show on one of my streams—so I set myself a mini goal: finish a solid first version before my next stream (just a few days away).
But then I hit a wall.
Here’s the markup I had for the block after saved:
<div class="wp-block-streams-april-word-switcher">
<p>LEARN HOW TO <span class="word-switcher">BUILD, CREATE, GROW, SECURE, MAINTAIN</span></p>
</div>And this was my attributes definition in the block.json:
{
...
"apiVersion": 3,
"name": "juanma-blocks/word-switcher",
"title": "Word Switcher",
...
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p"
},
"wordsToSwitch": {
"type": "string",
"source": "html",
"selector": ".word-switcher"
}
},
...
"render": "file:./render.php",
}
In theory, I expected the wordsToSwitch attribute to be available in render.php under the $attributes variable . That file receives (or has access to) three variables:
$attributes– the block’s attributes$content– the block’s markup$block_instance– the block’s instance object
But when I tried to use $attributes['wordsToSwitch'] in render.php, it just wasn’t there. 😅
I had assumed WordPress would automatically parse the HTML using the selector. It would fill in the attribute for me. But nope, it doesn’t quite work that way.
I started digging into how block attributes work at different stages of the block’s lifecycle. And I realized there were a few gaps in my understanding.
That’s when I thought: “I should write this down. It’s not just for anyone else wondering the same thing. It’s also for future me.”
Which brings us to this post—let’s dive in!
Table of Contents
- How the attributes Property Is Used During Block Registration
- How Attributes Are Parsed and Passed to the Edit Component
- How Attributes Are Calculated and Sent to save.js
- Server-Side Processing and Callback Rendering
- Known Limitations in Attribute Processing
- Conclusion
- Further Reading
The attributes property defined in a block’s block.json plays a critical role in how a block functions across the WordPress editor and the front end. It serves as a contract between the editor, server, and renderer. This ensures that the block behaves predictably regardless of where it’s rendered.
In this post, I explore how these attributes are used during registration, parsing, editing, saving, and rendering.
How the attributes Property Is Used During Block Registration
Client-Side Registration
When a block is registered using registerBlockType(), the attributes property defines the shape of data. It also specifies the source of data used within the block.
registerBlockType('namespace/example-block', {
attributes: {
title: {
type: 'string',
source: 'text',
selector: 'h2'
},
content: {
type: 'string',
source: 'html',
selector: 'p'
}
},
// ... edit, save
});Server-Side Registration
On the PHP side, attributes are also passed to register_block_type():
register_block_type('namespace/example-block', array(
'attributes' => array(
'title' => array('type' => 'string'),
'content' => array('type' => 'string'),
),
'render_callback' => 'example_render_callback',
));
It is recommended to take the metadata of the block from the block.json file. This applies to both client and server registration. See Registration of a block for more related info.
Diagram: Block Registration Flow
sequenceDiagram
participant Block as Block Definition
participant Registry as Block Registry
participant Schema as Schema Validation
participant Store as Block Store
Block->>Registry: registerBlockType(name, settings)
Registry->>Schema: validate settings.attributes
Schema-->>Registry: validated settings
Registry->>Store: store block
Store-->>Block: registration completeHow Attributes Are Parsed and Passed to the Edit Component
When the block is loaded in the editor, the block parser extracts attributes from the block markup and comment wrapper. These attributes are validated and normalized before being passed into the block’s Edit component:
export default function Edit({ attributes, setAttributes }) {
return (
<>
<RichText
tagName="h2"
value={ attributes.title }
onChange={ (title) => setAttributes({ title }) }
/>
<RichText
tagName="p"
value={ attributes.content }
onChange={ (content) => setAttributes({ content }) }
/>
</>
);
}
Diagram: Edit Component Lifecycle
sequenceDiagram
participant Editor as Block Editor
participant Parser as Block Parser
participant Attributes as Attribute Processor
participant Edit as Edit Component
Editor->>Parser: parseBlock(content)
Parser->>Attributes: getBlockAttributes()
Attributes-->>Parser: processed attributes
Parser->>Edit: render Edit(attributes)
Edit-->>Editor: block rendered
This is the stage where source: "html" and source: "query" become relevant. The attributes are extracted and passed to the Edit’s component. This process is based on the defined selectors from the block’s saved markup.
How Attributes Are Calculated and Sent to save.js
During editing, the values of block attributes are extracted from DOM nodes. These nodes are defined by the source and selector in the block.json. This process is handled by the getBlockAttribute() function in @wordpress/blocks:
function getBlockAttribute(attributeKey, schema, innerDOM, commentAttributes, innerHTML) {
let value;
switch (schema.source) {
case 'html':
case 'text':
case 'rich-text':
value = parseWithAttributeSchema(innerDOM, schema);
break;
case undefined:
value = commentAttributes ? commentAttributes[attributeKey] : undefined;
break;
case 'raw':
value = innerHTML;
break;
}
if (!isValidByType(value, schema.type)) {
value = getDefault(schema);
}
return value;
}
The resulting validated attributes are passed as props to the block’s save() function:
export default function save({ attributes }) {
return (
<div>
<h2>{ attributes.title }</h2>
<p>{ attributes.content }</p>
</div>
);
}
Diagram: Save.js Attribute Processing
sequenceDiagram
participant Editor as Block Editor
participant Block as Block Instance
participant Save as save.js
participant DOM as DOM Parser
participant Schema as Validator
Editor->>Block: getBlockAttributes()
Block->>DOM: parseWithAttributeSchema(innerHTML)
DOM-->>Block: parsed attributes
Block->>Schema: validateAttributes(parsed)
Schema-->>Block: validated attributes
Block->>Save: save(attributes)
Save-->>Editor: saved block contentServer-Side Processing and Callback Rendering
When rendering dynamic blocks on the server, attributes are extracted from the parsed block markup before reaching the callback. Here’s how it works:
Attribute Extraction
parse_blocks()callsWP_Block_Parser.- Inside
WP_Block_Parser::next_token(), a regex matches block comments like:
<!-- wp:namespace/block-name {"text":"Hello World"} -->- Attributes are extracted from the JSON inside the comment:
public function next_token() {
$matches = null;
// The regex that matches block comment delimiters
$has_match = preg_match(
'/<!--\s+(?P<closer>\/)?wp:(?P<namespace>[a-z][a-z0-9_-]*\/)?(?P<name>[a-z][a-z0-9_-]*)\s+(?P<attrs>{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P<void>\/)?-->/s',
$this->document,
$matches,
PREG_OFFSET_CAPTURE,
$this->offset
);
// ... error handling ...
$has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1];
// Parse the JSON attributes
$attrs = $has_attrs
? json_decode( $matches['attrs'][0], /* as-associative */ true )
: array();
...Notice how only the attributes that are part of header comment’s delimiter in the markup will be passed to the render_callback of the block
- These attributes are passed into the parsed block structure:
return array( 'block-opener', $name, $attrs, $started_at, $length );Attribute Validation Before Rendering
Once parsed, the block goes through its render process first through the render_block() method:
// In wp-includes/blocks.php
function render_block($parsed_block) {
...
// 1. Check for pre-render filter
$pre_render = apply_filters('pre_render_block', null, $parsed_block, $parent_block);
if (!is_null($pre_render)) {
return $pre_render;
}
...
// 2. Process block data
$parsed_block = apply_filters('render_block_data', $parsed_block, $source_block, $parent_block);
...
// 3. Create block instance and render
$block = new WP_Block($parsed_block, $context);
return $block->render();
}And it’s finally rendered using the render method of WP_Block class
// In wp-includes/class-wp-block.php
class WP_Block {
public function render($options = array()) {
$options = wp_parse_args($options, array('dynamic' => true));
...
// For dynamic blocks, call the render callback
if ($is_dynamic) {
$block_content = (string) call_user_func(
$this->block_type->render_callback,
$this->attributes,
$block_content,
$this
);
}
return $block_content;
}
}This is where $attributes passed to your render_callback come from. WordPress ensures the attributes are parsed from block markup and validated with the schema before they reach your PHP function.
Example Callback
function example_render_callback($attributes) {
return sprintf(
'<div><h2>%s</h2><p>%s</p></div>',
esc_html($attributes['title']),
wp_kses_post($attributes['content'])
);
}Diagram: Server-Side Rendering Flow
sequenceDiagram
participant Markup as Block Markup
participant Parser as WP_Block_Parser
participant Renderer as render_block()
participant Registry as Block Registry
participant Type as WP_Block_Type
participant Callback as render_callback
Markup->>Parser: extract name + attrs from comment
Parser-->>Renderer: parsed_block
Renderer->>Registry: get_registered(blockName)
Registry->>Type: WP_Block_Type
Type->>Type: prepare_attributes_for_render(attrs)
Type->>Callback: render_callback(attributes)
Callback-->>Renderer: rendered HTML
So, lessons learned…
Attributes with a source: 'html' or source: 'text' work well in most cases, but:
- If the block is dynamic and rendered on the server, attributes with
source: 'html'are not parsed automatically. They are not extracted from the saved content. - Such attributes are also not available in
$attributespassed to the PHP render callback unless manually preserved. - Attributes with
source: 'query'require careful handling and may not behave correctly if the DOM structure is altered.
I finally got what I want by parsing the block’s $content from the render.php using the WP_HTML_Tag_Processor
$processor = new WP_HTML_Tag_Processor($content);And this is a diagram I created during the stream to explain the journey of this block’s attributes and markup.


Leave a Reply to Gutenberg Times: WordPress 6.8 released, Block Accessibility checks, Stacking cards layout, and more — Weekend Edition 326 – MCNM Digital Media MarketingCancel reply