Two weeks after the handover call, the homepage has three button sizes, two heading colours, and a section copied from another page with the old phone number still in it. The person editing did what anyone would do with an empty editor and no vocabulary for the design.
Patterns are the part of core that closes that gap: a saved block layout that shows up in the inserter, with words the editor replaces and a structure they cannot take apart by accident.
A pattern is block markup, not a page template
A page template wraps a whole page and decides its layout. A pattern is a section — a hero, a feature row, a CTA band — inserted into whatever page is open. It is stored as block markup, the same markup you see in the code editor, which means it can live in version control and does not depend on a builder's data format.
Two places to put it: the theme's /patterns directory, where current WordPress registers every PHP file automatically, or a small plugin calling register_block_pattern(). Theme files are versioned with the design and disappear with it. A plugin keeps patterns when the theme is replaced. For a handoff site I put page sections in the theme and anything the client keeps across a redesign in a plugin.
A section, written as a file
wp-content/themes/acme/patterns/hero-split.php:
<?php
/**
* Title: Hero: text and image
* Slug: acme/hero-split
* Categories: site-sections
* Description: Headline, one paragraph, one button, image on the right.
* Viewport Width: 1280
* Block Types: core/post-content
* Post Types: page
*/
$hero_image = esc_url( get_theme_file_uri( 'assets/hero-default.jpg' ) );
?>
<!-- wp:group {"templateLock":"contentOnly","layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column {"width":"55%"} -->
<div class="wp-block-column" style="flex-basis:55%">
<!-- wp:heading {"level":1} -->
<h1 class="wp-block-heading">Replace this headline</h1>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>One or two sentences on what this page offers and who it is for.</p>
<!-- /wp:paragraph -->
<!-- wp:buttons -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">Primary action</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"45%"} -->
<div class="wp-block-column" style="flex-basis:45%">
<!-- wp:image {"sizeSlug":"large"} -->
<figure class="wp-block-image size-large"><img src="<?php echo $hero_image; ?>" alt=""/></figure>
<!-- /wp:image -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
</div>
<!-- /wp:group -->
The header comment is the registration. Title is what the inserter shows, Slug is what your own checks and other code refer to, Categories groups it in the pattern panel, and Block Types: core/post-content keeps it out of contexts where it makes no sense. Viewport Width sets the preview scale, so the thumbnail looks like the section instead of a squeezed column.
The same pattern from a plugin
When it has to outlive the theme:
add_action( 'init', function () {
register_block_pattern_category(
'site-sections',
array( 'label' => 'Site sections' )
);
register_block_pattern(
'acme/cta-band',
array(
'title' => 'CTA band',
'description' => 'One line of text and a single button on a coloured background.',
'categories' => array( 'site-sections' ),
'content' => '<!-- wp:group {"templateLock":"contentOnly","backgroundColor":"brand","layout":{"type":"constrained"}} -->'
. '<div class="wp-block-group has-brand-background-color has-background">'
. '<!-- wp:heading --><h2 class="wp-block-heading">Replace this heading</h2><!-- /wp:heading -->'
. '<!-- wp:buttons --><div class="wp-block-buttons"><!-- wp:button --><div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">Get in touch</a></div><!-- /wp:button --></div><!-- /wp:buttons -->'
. '</div><!-- /wp:group -->',
)
);
} );
has-brand-background-color only exists if brand is a colour slug in the theme's theme.json palette. That is the next part.
contentOnly is what makes the handoff work
"templateLock":"contentOnly" on a wrapper restricts editing to content: text, image, button label and link. Block settings, alignment, spacing, colours, adding and removing blocks all stop being available in the UI for that subtree. The editor clicks the headline and types, and the layout survives.
Locking is per subtree, so a grid needs two levels. Each card is locked to content; the container above it uses allowedBlocks to decide what can be dropped in at all:
<!-- wp:group {"allowedBlocks":["core/group"],"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"templateLock":"contentOnly","layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:heading --><h3 class="wp-block-heading">Replace this card title</h3><!-- /wp:heading -->
<!-- wp:paragraph --><p>Card body text.</p><!-- /wp:paragraph -->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
Now the client can duplicate a card, delete one, or add another — and cannot insert a random embed in the middle of the grid.
Be honest about what this buys. Locks are guardrails against accidents. Someone determined can still open the code editor; for roles without unfiltered_html, saving from that editor runs the content through KSES, which limits what survives, but permissions are a different problem. Use locks to keep the layout intact, not to control access.
Presets, or the patterns drift apart
If one pattern hardcodes #0b5fff and the next hardcodes #0b5ff0, the design drifts a section at a time and nobody notices until the homepage looks patchy. Put the palette and a spacing scale in theme.json and reference the presets by slug:
<!-- wp:group {"backgroundColor":"brand","style":{"spacing":{"padding":{"top":"var:preset|spacing|50","bottom":"var:preset|spacing|50"}}},"layout":{"type":"constrained"}} -->
The rendered class is has-brand-background-color, and the padding resolves from the theme's spacing sizes at render time. Change the scale once and every section that uses it moves with it.
Images and links that survive a move
A hardcoded /wp-content/uploads/hero.jpg breaks on a domain change and breaks differently when staging is pushed to production. get_theme_file_uri() inside the pattern file resolves at render time from whatever the theme directory is now. Where the image should be replaceable per page, keep it as a normal image block the editor can swap, with the theme asset as the default. The button href="#" is deliberate too: it is obviously unfinished, so it gets cleaned up instead of quietly linking to last year's campaign page.
Naming and categories keep the inserter usable
One slug prefix per project (acme/), one category per purpose, and Post Types: page on anything that only makes sense on a page. A flat list of twenty entries is the same as no list.
Check what actually registered
add_action( 'admin_notices', function () {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
$count = 0;
foreach ( WP_Block_Patterns_Registry::get_instance()->get_all_registered() as $pattern ) {
if ( 0 === strpos( $pattern['name'], 'acme/' ) ) {
$count++;
}
}
printf( '<div class="notice notice-info"><p>%d acme patterns registered.</p></div>', (int) $count );
} );
A typo in the header comment, or a file outside /patterns, shows up here as a missing entry instead of a client asking why the section is not in the list.
One page of documentation, handed over with the site
Not a manual. A table with three columns: which section to use, what is editable, and what to do when the layout itself has to change.
| Section | Use it for | Editable | If the layout has to change |
|---|---|---|---|
| Hero: text and image | top of a landing page | headline, paragraph, button label and link, image | ask us — the column split lives in the pattern file |
| Feature grid | lists of benefits or products | each card's title and text; add or remove cards | ask us — card spacing is set by the theme's spacing scale |
| CTA band | bottom of every page | heading, button label and link | ask us |
And the insertion instruction in the client's own words:
Open the page, click the + in the top left, open the Patterns tab, choose Site sections, pick the section. Then click the text and type.
When someone else builds the handoff
None of the above needs to be your afternoon. Handing the build to someone who has done it before is a normal way to get here, and the setup service at AumCreate is one of the places that does it: a template from the template library installed on the client's own host, every page rewritten with the client's content, patterns and theme.json presets registered, sample copy removed, a preview link with one round of changes, and a one-page "how to edit" note at launch. $399, live within five working days of the materials being complete. The domain and the hosting stay in the client's name and are paid to their own providers. Layouts that no template covers are the custom job, from $1,500.
Before launch
Placeholder copy has a habit of shipping. Grep for it:
wp db query "SELECT ID, post_title FROM wp_posts WHERE post_status = 'publish' AND post_content LIKE '%Replace this%';"
Check $table_prefix in wp-config.php first — the query assumes wp_.
When the same section must change everywhere at once
A pattern is copied into the page when it is inserted; editing the pattern afterwards changes nothing on existing pages. If a section — a footer CTA, a promo band — has to be edited in one place and update on twenty pages, use a synced pattern instead. Where the structure is shared but the wording differs per page, a synced pattern with per-instance overrides covers it: the layout stays linked, the text stays local. The two mechanisms are complementary, and knowing which one a given section needs is the difference between a site that stays consistent and one that slowly grows four versions of the same block.
Top comments (0)