Variations, Styles, Transforms and Deprecations
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
A block type is not frozen once it is registered. A variation inserts the same block with preset attributes and
inner blocks under its own name and icon; a block style adds an is-style-* class the author picks from the
sidebar; a transform converts one block (or a shortcode, or pasted HTML) into another; and a deprecation
teaches the editor to read content that an older version of a static block saved. This page is for plugin and block
developers. It adds all four to the lumen-catalog plugin of the Lumen Press site: a "Book" variation of the core
Group block, genre variations of lumen/latest-books generated in PHP, a "Compact" style for lumen/book-card,
transforms from the [lumen_books] shortcode, and version 0.2.0 of lumen/book-card, whose markup change is shipped
with a deprecated entry and a migrate function.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and
checked on 2026-10-28. WordPress 7.2 is in beta and due in December
2026; nothing on this page changes in it so far. The code was built with a real wp-scripts build; the PHP side ran on
a fresh 7.1.3 site (Docker Compose stack of Local Development
Environments); editor-side results come from @wordpress/blocks 15.27.0 from npm (7.1.3 itself bundles 15.24.0) and
@wordpress/block-library run in Node
with jsdom against the built files, as in Static and Dynamic
Blocks, which also explains block validation and the lumen/book-card 0.1.0 code this page starts from.
Four tools compared
| Tool | What it changes | Where it is stored | Typical use |
|---|---|---|---|
Variation |
Initial attributes and inner blocks, plus title, icon and description in the inserter and sidebar |
Nothing of its own: the block’s attributes are saved as usual |
Presets of one block (Group "Row", Embed "YouTube", a "Book" Group) |
Block style |
One class, |
|
Alternative looks of the same markup |
Transform |
Converts blocks, shortcodes, pasted HTML or typed text into a block |
Nothing: the result is a new block |
Migration paths and authoring shortcuts |
Deprecation |
How the editor parses content saved by an older |
Nothing until the post is re-saved |
Changing a static block’s markup without breaking posts |
Where the code lives
Blocks keep their transforms and deprecations next to their index.js in src/blocks/<name>/. Variations and
styles for blocks the plugin does not own (here the core Group block) need a separate editor script: src/editor/index.js,
built into build/editor/ and enqueued by src/Editor.php. The build script of package.json (see
block.json and Registration) gains a second step:
wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules
wp-scripts build --source-path=src/editor --output-path=build/editor
With no block.json in src/editor/, the second command uses src/editor/index.js as its entry and writes
build/editor/index.js plus index.asset.php, whose dependencies are wp-blocks and wp-i18n.
Block variations
What a variation is
A variation is an object attached to a block type. The editor shows it in the inserter (and in other places, per its
scope) as if it were a block of its own; inserting it creates the base block with the variation’s attributes and
innerBlocks. The fields are:
| Field | Meaning |
|---|---|
|
Unique machine name. Needed to unregister the variation, to target it from a transform ( |
|
What the inserter, its search and preview, and the sidebar’s block card show. |
|
Attribute values and nested blocks ( |
|
|
|
Replace the base block in the inserter. Only the first registered default wins. |
|
How the editor recognizes an existing block as this variation: an array of attribute names (nested paths such as |
The handbook also lists shortcut and innerContent (Custom HTML only); neither is in the editor scripts bundled with
7.1.3, so they need the Gutenberg plugin for now.
A "Book" variation of core/group
Lumen Press editors often lay out a book by hand: cover, title, ISBN and blurb in a Group. A variation turns that into one inserter item, "Book", while the content stays core blocks that survive the plugin’s removal. The editor script also registers the Book Card styles described in Block styles:
import { registerBlockStyle, registerBlockVariation } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
registerBlockVariation( 'core/group', {
name: 'lumen-book',
title: __( 'Book', 'lumen-catalog' ),
description: __(
'A cover, title, ISBN and blurb in one group, styled by the theme.',
'lumen-catalog'
),
icon: 'book-alt',
category: 'lumen-catalog',
keywords: [ __( 'book', 'lumen-catalog' ), 'isbn' ],
scope: [ 'inserter' ],
attributes: {
className: 'is-lumen-book',
layout: { type: 'constrained' },
metadata: { name: __( 'Book', 'lumen-catalog' ) },
},
innerBlocks: [
[ 'core/image', { sizeSlug: 'medium' } ],
[
'core/heading',
{ level: 3, placeholder: __( 'Book title', 'lumen-catalog' ) },
],
[
'core/paragraph',
{ className: 'lumen-book__isbn', placeholder: 'ISBN' },
],
[
'core/paragraph',
{ placeholder: __( 'Short blurb', 'lumen-catalog' ) },
],
],
// The class list may also hold a block style or a user class.
isActive: ( { className = '' } ) =>
className.split( ' ' ).includes( 'is-lumen-book' ),
} );
registerBlockStyle( 'lumen/book-card', [
{
name: 'default',
label: __( 'Default', 'lumen-catalog' ),
isDefault: true,
},
{ name: 'compact', label: __( 'Compact', 'lumen-catalog' ) },
] );
metadata.name is the List View name an author sets with Rename. The variation ships no CSS: is-lumen-book is a
hook for the theme (see Style Variations and Section
Styles).
isActive and scope
Without isActive, an inserted Book shows as a plain "Group". The editor walks the variations in registration order:
array-form isActive entries compare attributes with exact equality and the match with the most attributes wins (6.6);
a function returns the first match. Core’s Row, Stack and Grid check layout.type; Group has no isActive and is the
fallback. [ 'className' ] would stop matching once an author added a style or class, hence the function. In Node,
getActiveBlockVariation() returned lumen-book for className is-lumen-book is-style-section-dark, and group
for a plain constrained group.
The transform scope would add Book to the Group/Row/Stack/Grid switcher in the sidebar, but that switcher applies only
attributes, never innerBlocks, so a group would gain the class and nothing else. To remove a variation, call
wp.blocks.unregisterBlockVariation( 'core/group', 'group-grid' ) (by name). A new isDefault variation is ignored
while an earlier default, such as Group’s "Group", is still registered.
Variations from PHP
Variations that depend on site data, such as one inserter item per genre, are easier to build on the server. Since 6.5,
WP_Block_Type::get_variations() calls an optional variation_callback and passes the result through the
get_block_type_variations filter, so a plugin can add variations to any block, its own or core’s:
<?php
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
// Editor-only additions: the editor script and PHP block variations.
final class Editor {
// Called from Plugin::boot().
public static function boot(): void {
add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue' ) );
add_filter( 'get_block_type_variations', array( self::class, 'genre_variations' ), 10, 2 );
}
// Loads build/editor/index.js: the "Book" Group variation and Book Card styles.
public static function enqueue(): void {
$asset = include dirname( LUMEN_CATALOG_FILE ) . '/build/editor/index.asset.php';
wp_enqueue_script(
'lumen-catalog-editor',
plugins_url( 'build/editor/index.js', LUMEN_CATALOG_FILE ),
$asset['dependencies'],
$asset['version'],
true
);
wp_set_script_translations( 'lumen-catalog-editor', 'lumen-catalog' );
}
// Adds a "Latest <genre> books" variation of lumen/latest-books per genre with books.
public static function genre_variations( array $variations, \WP_Block_Type $block_type ): array {
if ( 'lumen/latest-books' !== $block_type->name ) {
return $variations;
}
$terms = get_terms(
array(
'taxonomy' => 'lp_genre',
'hide_empty' => true,
'number' => 12,
)
);
if ( is_wp_error( $terms ) ) {
return $variations;
}
foreach ( $terms as $term ) {
$variations[] = array(
'name' => 'genre-' . $term->slug,
/* translators: %s: genre name. */
'title' => sprintf( __( 'Latest %s books', 'lumen-catalog' ), $term->name ),
'icon' => 'book',
'keywords' => array( $term->name ),
'attributes' => array( 'genre' => $term->slug ),
'isActive' => array( 'genre' ),
'scope' => array( 'inserter', 'transform' ),
);
}
return $variations;
}
}
Plugin::boot() calls Editor::boot() next to Blocks::boot(). The transform scope suits these variations:
switching a Latest Books block between genres only changes genre. With three genres, Poetry still without books:
wp eval 'foreach ( WP_Block_Type_Registry::get_instance()->get_registered( "lumen/latest-books" )->get_variations() as $v ) { printf( "%s | %s | %s\n", $v["name"], $v["title"], wp_json_encode( $v["attributes"] ) ); }'
wp eval '$r = rest_do_request( new WP_REST_Request( "GET", "/wp/v2/block-types/lumen/latest-books" ) ); echo wp_json_encode( wp_list_pluck( $r->get_data()["variations"], "name" ) ), "\n";' --user=admin
genre-crime | Latest Crime books | {"genre":"crime"}
genre-fantasy | Latest Fantasy books | {"genre":"fantasy"}
["genre-crime","genre-fantasy"]
Source: REST API Handbook: Block Types
The post editor gets the same list without a REST call: get_block_editor_server_block_settings() puts each block
type’s variations into the editor’s bootstrap data (two for lumen/latest-books here), where they merge with those
registered in JavaScript. The filter runs for every block type on every get_variations() call, so it returns early
for other blocks and keeps its query bounded. isActive can only be an array and icon a Dashicon name or SVG
string, because the value travels as JSON, and strings need __(). A block the plugin owns can instead point
block.json at a PHP file returning the array ("variations": "file:./variations.php", 6.7), loaded lazily through
variation_callback.
Block styles
A block style is a name and a label; picking it in the sidebar’s Styles panel adds is-style-<name> to the block’s
className, and nothing else, which is the line between a style and a variation. The editor script above registers two
for lumen/book-card (an array registers several at once); isDefault: true makes "Default" the style shown as active
when no is-style-* class is present, and selecting it removes the class. The CSS goes where both the front end and
the iframed editor load it, the block’s style file; 0.2.0 appends these rules to style.scss:
.wp-block-lumen-book-card__cover {
margin: 0;
}
/* "Compact" block style, registered in src/editor/index.js. */
.wp-block-lumen-book-card.is-style-compact {
gap: 0.5rem;
}
.wp-block-lumen-book-card.is-style-compact .wp-block-lumen-book-card__isbn {
display: none;
}
Source: Block Editor Handbook: Styles
A style can come from registerBlockStyle() in an editor script, as here (PHP’s WP_Block_Styles_Registry never
sees it); from "styles" in the block’s own block.json; or from register_block_style() in PHP, usually in a theme,
with inline_style, style_handle or (6.6) style_data, which also shows it in Global Styles and allows section
styles: see Style Variations and Section Styles. Remove a
style on the side that registered it: wp.blocks.unregisterBlockStyle( 'core/quote', 'plain' ) inside
wp.domReady() for JavaScript and block.json styles, unregister_block_style() for PHP ones. Content that already
has the class keeps it.
Transforms
Types
Transforms are declared under transforms.from and transforms.to of a block; every type accepts priority
(default 10, lower wins) for when several match:
| Type | Direction | Applied when |
|---|---|---|
|
from, to |
The author picks a block in the toolbar’s block switcher (or a multi-block selection with |
|
from |
Raw handling: pasting text, or Convert to blocks on a Classic block. |
|
from |
Pasting or dropping HTML, and Convert to blocks. Matched by |
|
from |
Typed text matching |
From [lumen_books] to Latest Books
The [lumen_books genre="..." limit="..."] shortcode of Shortcodes and the
dynamic lumen/latest-books block do the same job. Transforms give old content three paths into the block, and one
back:
import { createBlock } from '@wordpress/blocks';
import { next } from '@wordpress/shortcode';
// [lumen_books genre="..." limit="..."] -> block attributes.
const fromShortcodeAttributes = ( { genre = '', limit = 5 } ) => ( {
genre: String( genre ),
count: Math.min( Math.max( parseInt( limit, 10 ) || 5, 1 ), 12 ),
} );
const transforms = {
from: [
{
// Pasted text and "Convert to blocks" on Classic content.
type: 'shortcode',
tag: 'lumen_books',
transform: ( { named } ) =>
createBlock(
'lumen/latest-books',
fromShortcodeAttributes( named )
),
},
{
// An existing Shortcode block, from the block switcher.
type: 'block',
blocks: [ 'core/shortcode' ],
isMatch: ( { text } ) =>
/^\s*\[lumen_books[\s\]]/.test( text || '' ),
transform: ( { text } ) =>
createBlock(
'lumen/latest-books',
fromShortcodeAttributes(
next( 'lumen_books', text ).shortcode.attrs.named
)
),
},
{
// HTML that [lumen_books] printed, pasted from the front end.
type: 'raw',
priority: 5,
// Paste cleanup strips "class" from lists, so match on data-genre.
isMatch: ( node ) =>
node.nodeName === 'UL' && node.hasAttribute( 'data-genre' ),
schema: {
ul: {
attributes: [ 'data-genre' ],
children: {
li: {
children: {
a: {
attributes: [ 'href' ],
children: { '#text': {} },
},
},
},
},
},
},
transform: ( node ) =>
createBlock( 'lumen/latest-books', {
genre: node.dataset.genre || '',
count: Math.min( node.children.length || 3, 12 ),
} ),
},
],
to: [
{
type: 'block',
blocks: [ 'core/shortcode' ],
transform: ( { genre, count } ) =>
createBlock( 'core/shortcode', {
text: genre
? `[lumen_books genre="${ genre }" limit="${ count }"]`
: `[lumen_books limit="${ count }"]`,
} ),
},
],
};
export default transforms;
The file is src/blocks/latest-books/transforms.js; index.js passes it to registerBlockType() next to edit.
@wordpress/shortcode (wp-shortcode in index.asset.php) parses attributes with the same rules as PHP. count is
clamped to 1–12, the block’s RangeControl range, where the shortcode allowed 20.
Run in Node against the built block (paste and Convert to blocks go through pasteHandler() and rawHandler(), the
block switcher through switchToBlockType()):
paste '[lumen_books genre="fantasy" limit="3"]' -> lumen/latest-books {"count":3,"genre":"fantasy","showDate":true}
convert '<p>New this month:</p>\n[lumen_books limit=40]\n' -> core/paragraph {"content":"New this month:"}, lumen/latest-books {"count":12,"genre":"","showDate":true}
paste '<ul class="lumen-books" data-genre="crime">' (2 items) -> lumen/latest-books {"count":2,"genre":"crime","showDate":true}
paste '<ul><li>One</li><li>Two</li></ul>' -> core/list
core/shortcode '[lumen_books genre="poetry"]' -> lumen/latest-books {"count":5,"genre":"poetry","showDate":true}
lumen/latest-books { genre: "crime", count: 4 } -> core/shortcode {"text":"[lumen_books genre=\"crime\" limit=\"4\"]"}
Three behaviours shaped the code. A shortcode in Classic content is converted only on its own line or paragraph. A
raw transform sees the HTML after the paste cleanup, which keeps only what some block’s schema allows: the List
schema has no class on ul, so selector: 'ul.lumen-books' never matched and the List block won, while
data-genre, allowed by this schema, survives (an ordinary list stays a List). And the raw handler copies any class
left on the node into the new block’s className, one more reason not to allow class.
Deprecations
The 0.2.0 change
Version 0.1.0 of lumen/book-card saved its cover as a bare <img> and had a showIsbn toggle. Version 0.2.0 wraps
the cover in a <figure>, always saves the ISBN, and replaces the toggle with the "Compact" style, which hides the ISBN
with CSS. In block.json, showIsbn is removed and "version" becomes "0.2.0"; the toggle leaves edit.js.
save.js is the 0.1.0 save() shown in deprecated.js below with two changes: the image is wrapped in
<figure className="wp-block-lumen-book-card__cover">, and the ISBN condition is isbn && instead of
showIsbn && isbn && (edit.js renders the same <figure>).
Every post saved with 0.1.0 is now at risk. Two stored cards, one with a cover and one with the ISBN hidden:
<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p><!-- wp:paragraph -->
<p>A keeper, a storm and a logbook that does not add up.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
<!-- wp:lumen/book-card {"isbn":"978-1-23456-790-3","showIsbn":false} -->
<article class="wp-block-lumen-book-card"><h3 class="wp-block-lumen-book-card__title">The Glass Orchard</h3><!-- wp:paragraph -->
<p>Three sisters, one orchard and a winter that will not end.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
Parsed with the 0.2.0 build and no deprecated entry, both are invalid:
Block validation: Expected tag name `figure`, instead saw `img`.
book-card-v1.html: lumen/book-card INVALID
Block validation: Expected token of type `StartTag` ({ type: 'StartTag', tagName: 'p', … }), instead saw `EndTag` ({ type: 'EndTag', tagName: 'article' }).
book-card-v1-hidden-isbn.html: lumen/book-card INVALID
The second card fails although its markup was untouched: without showIsbn, the new save() prints the hidden ISBN.
In the editor, each card would show "Block contains unexpected or invalid content." and an Attempt recovery button.
A deprecation with migrate
src/blocks/book-card/deprecated.js keeps a frozen copy of 0.1.0; index.js imports it and passes it as
deprecated to registerBlockType() next to edit and save:
import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor';
// Snapshot of version 0.1.0: attributes, supports and save() exactly as shipped.
const v1 = {
attributes: {
bookId: { type: 'integer' },
title: {
type: 'rich-text',
source: 'rich-text',
selector: '.wp-block-lumen-book-card__title',
role: 'content',
},
isbn: { type: 'string', default: '' },
coverUrl: {
type: 'string',
source: 'attribute',
selector: 'img',
attribute: 'src',
},
showIsbn: { type: 'boolean', default: true },
},
supports: {
html: false,
align: [ 'wide' ],
anchor: true,
color: { background: true, text: true, link: true },
spacing: { padding: true, margin: [ 'top', 'bottom' ] },
typography: { fontSize: true },
},
// The hidden-ISBN toggle became the "Compact" block style in 0.2.0.
migrate( { showIsbn, ...attributes } ) {
if ( showIsbn !== false ) {
return attributes;
}
const classes = ( attributes.className || '' ).split( ' ' );
return {
...attributes,
className: [ ...classes, 'is-style-compact' ].join( ' ' ).trim(),
};
},
save( { attributes } ) {
const { title, isbn, coverUrl, showIsbn } = attributes;
return (
<article { ...useBlockProps.save() }>
{ coverUrl && <img src={ coverUrl } alt="" /> }
<RichText.Content
tagName="h3"
className="wp-block-lumen-book-card__title"
value={ title }
/>
{ showIsbn && isbn && (
<p className="wp-block-lumen-book-card__isbn">{ isbn }</p>
) }
<InnerBlocks.Content />
</article>
);
},
};
// Newest first.
export default [ v1 ];
The same two posts parsed with this build are valid, and re-serializing them gives what the editor writes on the next save:
<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><figure class="wp-block-lumen-book-card__cover"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/></figure><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p><!-- wp:paragraph -->
<p>A keeper, a storm and a logbook that does not add up.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
<!-- wp:lumen/book-card {"isbn":"978-1-23456-790-3","className":"is-style-compact"} -->
<article class="wp-block-lumen-book-card is-style-compact"><h3 class="wp-block-lumen-book-card__title">The Glass Orchard</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-790-3</p><!-- wp:paragraph -->
<p>Three sisters, one orchard and a winter that will not end.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
The validator logged Updated Block: lumen/book-card for each. The hidden ISBN is now a class: the Glass Orchard
card saves its ISBN and the Compact style hides it, so the page looks as before.
How the editor applies deprecations
-
Deprecations are tried only when the current
save()does not reproduce the stored HTML (or when an entry’sisEligible( attributes, innerBlocks, { blockNode, block } )returnstruefor a block that is valid). -
The entries are tried in array order, and the first whose
save()reproduces the stored HTML wins; its attributes (and itsmigrate, if any) produce the new block. They are not a migration chain: content from 0.1.0 is not passed through a 0.2.0 deprecation on its way to 0.3.0. When a later version needs the same migration, every older entry carries it. Keep the array newest first, so the likely match is tried first. -
attributes,supportsandsaveare not inherited.supportsadds attributes and wrapper classes (className, colours,anchor). Snapshot them as literals rather than importingblock.jsonor shared helpers, which change with the next release; passingmetadata.attributesis safe only when they did not change. -
migrate( attributes, innerBlocks )returns new attributes, or[ attributes, innerBlocks ]to move content into or out of inner blocks. It runs only when that entry’ssave()matched. -
An entry whose
save()fails is skipped entirely,migrateandisEligibleincluded. A 0.1.0 card without cover and with the ISBN shown is valid under 0.2.0 already, so no deprecation runs and the droppedshowIsbn(defaulttrue) is simply not written back.
Keep one stored-markup fixture per released version, as above, and parse them all in a test. Dynamic blocks avoid the problem entirely (Static and Dynamic Blocks).
What the front end shows
Deprecations live in the editor. Until a post is opened and saved, the server keeps printing the stored 0.1.0 HTML:
on the 7.1.3 site, apply_filters( 'the_content', ... ) returned the bare <img> and the card without ISBN. After
storing the migrated markup (wp post update <id> migrated.html, standing in for a save in the editor), the same page
printed the <figure> and is-style-compact versions, and the front-end response carried the Compact rules in
lumen-book-card-style-inline-css (WordPress inlines small block stylesheets). A release’s CSS therefore has to style
both markups while un-migrated posts exist; here .wp-block-lumen-book-card img still sizes the bare <img>. Core has
no server-side migration of static block HTML: a bulk update would parse, rewrite and save each post itself.
Versions
| Version | Change |
|---|---|
5.0 |
Block editor with |
5.3 |
|
5.4 |
Block Variations API: |
5.9 |
|
6.5 |
|
6.6 |
Nested attribute paths in array-form |
6.7 |
|
7.1 |
Post editor always iframed: style CSS must come from the block’s |
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
An inserted variation shows the base block’s title and icon in the sidebar |
No |
The variation appears in the sidebar switcher but adds an empty group |
|
The editor slows down after adding a |
The filter runs for every block type and every call. Check |
|
It ran before the style was registered. Depend on the registering script and wrap the call in |
A style looks right on the front end but not in the editor |
The CSS was enqueued on |
A |
The paste cleanup removed what |
A shortcode is not converted by Convert to blocks |
It is inline in a paragraph. Only shortcodes on their own line become blocks. |
"Block contains unexpected or invalid content." after a release |
|
What older books and tutorials get wrong
| Older advice | Current practice |
|---|---|
The general WordPress books present block styles and patterns only as author features and teach layouts through page builders or shortcodes (Pro WordPress covers shortcodes in classic themes only). |
Plugins add their own variations and styles, and give existing shortcodes a |
Tutorials register variations or styles in an inline ES5 script using |
Build the script with |
"Changing |
Ship a |
Deprecations run in sequence like database migrations. |
The first entry whose |
Copying |
Freeze the old |