Blog

  • CSS Nesting, :has() and the Features That Changed How We Write CSS

    CSS Nesting, :has() and the Features That Changed How We Write CSS

    We rebuilt the navigation on a client’s e-commerce theme last spring and deleted 214 lines of JavaScript in the process. Not because we found a cleverer script. Because the only thing that JavaScript did was add and remove classes so CSS could react to state that was already sitting right there in the DOM.

    body:has(dialog[open]). .field:has(input:user-invalid). .nav:has(> li:nth-child(8)). Three selectors replaced an entire state-syncing layer, and the result had fewer race conditions because there was nothing left to get out of sync.

    CSS nesting, the :has() selector and the rest of the modern CSS features that landed between 2022 and 2025 didn’t just add syntax sugar. They changed which problems belong to CSS and which belong to JavaScript. Here’s what we’ve learned shipping them on production sites, including the parts that bit us.

    Key Takeaways

    • Native nesting is not Sass nesting: & behaves like :is(), which means .card, #hero { & .title {} } inherits the ID’s specificity, and BEM-style &--primary concatenation simply does not exist.
    • Declarations placed after a nested rule used to be hoisted or dropped. The CSSNestedDeclarations fix shipped across browsers through late 2024 and 2025, so if you support older Chromium builds, keep plain declarations above nested rules.
    • :has() is non-forgiving by design, so one unsupported selector inside it kills the whole rule. That’s deliberate: it makes CSS.supports('selector(:has(*))') a reliable feature test.
    • Scope :has() to a container rather than writing it against * or unqualified elements. Invalidation cost scales with how many subjects the browser must re-check on every DOM mutation.
    • @layer, @scope and @container together do most of what BEM was invented to fake. Cascade layers solve override order, scope solves leakage, container queries solve component-level responsiveness.

    CSS nesting looks like Sass and behaves differently

    Everyone’s first reaction to native css nesting is to paste in a Sass block and watch it work. Then it doesn’t, and the failure is silent.

    The first trap is specificity. The & in native nesting is defined as equivalent to :is() wrapping the parent selector list, and :is() takes the specificity of its most specific argument.

    .card, #hero {
      color: #222;
    
      / This resolves to :is(.card, #hero) .title /
      / Specificity is 1,0,1 because of #hero, not 0,1,1 /
      & .title { color: crimson; }
    }
    

    In Sass that compiles to two separate selectors with independent specificity. In native CSS it’s one selector, and a single ID anywhere in the parent list poisons everything nested under it. We hit this on a legacy build where #main was still in the markup, and a nested utility class stopped overriding anything. Took twenty minutes to find. The rule we settled on: no IDs in selector lists that have children nested inside them.

    The second trap is that there is no string concatenation. This is the one that ends the argument for BEM shops:

    .btn {
      / Invalid. Native nesting has no interpolation. /
      &--primary { background: blue; }
    }
    

    If your naming convention depends on building selectors from fragments, native nesting gives you nothing. Stay on Sass or switch conventions. We switched conventions. Attribute or data-driven variants (.btn[data-variant="primary"]) nest cleanly and read better in the inspector anyway.

    The declaration order bug

    This one cost us an afternoon. In the original nesting implementation, declarations that appeared after a nested rule were either invalid or silently reordered to the top of the block:

    .btn {
      background: blue;
    
      &:hover { background: navy; }
    
      /* Older engines hoisted this above the :hover rule.
         The hover state stopped working and nothing warned you. */
      background: rebeccapurple;
    }
    

    The CSSNestedDeclarations rule fixed this, landing in Chrome 130 and rolling out through the other engines over the following months. Behaviour is now what you’d expect: source order wins. But if your analytics still show meaningful traffic on Chromium 120 to 129 builds (embedded browsers and Android WebViews lag hard), write your plain declarations first. It costs nothing and removes a class of bug that produces no console output.

    The :has() selector is a state engine, not a parent selector

    The pitch everyone heard was “parent selector”. That’s the least useful framing. What :has() actually gives you is the ability to style an element based on a condition anywhere in its subtree or among its following siblings. The DOM itself becomes your state store.

    Form validation is the clearest win. No JS, no dirty-checking, no class toggling:

    .field {
      --border: #d0d5dd;
      border: 1px solid var(--border);
    }
    
    /* :user-invalid only fires after the user has interacted,
       unlike :invalid which flags empty required fields on load */
    .field:has(input:user-invalid) { --border: #d92d20; }
    .field:has(input:user-valid)   { --border: #12b76a; }
    .field:has(input:focus-visible){ --border: #2970ff; }
    
    / Show the error text only when there is an error /
    .field .error { display: none; }
    .field:has(input:user-invalid) .error { display: block; }
    

    Quantity queries are the second pattern we use constantly. Change a grid’s density based on how many children it actually has:

    .gallery { --cols: 2; display: grid; grid-template-columns: repeat(var(--cols), 1fr); }
    .gallery:has(> :nth-child(5)) { --cols: 3; }  / 5 or more items /
    .gallery:has(> :nth-child(9)) { --cols: 4; }  / 9 or more items /
    

    And the previous-sibling selector we never had. :has(+ .x) reads awkwardly the first time and then becomes second nature:

    / Dim the row above the one being hovered /
    .row:has(+ .row:hover) { opacity: .6; }
    
    / Kill the bottom border on the last visible accordion item /
    .accordion-item:not(:has(+ .accordion-item)) { border-bottom: 0; }
    

    Feature detection actually works here

    :has() is a non-forgiving selector list. Put one thing the browser doesn’t understand inside it and the entire rule is discarded. That was a deliberate spec change so that CSS.supports() stays truthful:

    // Reliable because :has() rejects unknown arguments rather than ignoring them
    if (!CSS.supports('selector(:has(*))')) {
      document.documentElement.classList.add('no-has');
    }
    

    Practically, Chrome has had it since 105, Safari since 15.4 and Firefox since 121, so as of 2026 you’re arguing about a rounding error of traffic. We ship :has() unguarded on public marketing sites and only add fallbacks when a client’s own analytics show meaningful legacy browser usage, which for enterprise intranets it sometimes still does.

    Where :has() gets expensive

    The style invalidation story is genuinely good now. Chromium’s implementation narrows the set of elements it must re-check rather than invalidating the document, and in normal component-scale usage you will not measure it. That doesn’t mean you can be careless.

    The pattern that actually hurts is a broad subject with a deep descendant condition on a mutating tree. Something like *:has(.is-active) or an unqualified div:has(input:checked) inside a virtualised table with thousands of rows. Every DOM insertion in that subtree makes the engine reconsider ancestors.

    Three rules that have kept us out of trouble:

    • Qualify the subject. .data-table:has(tbody:empty), never :has(tbody:empty) on its own.
    • Prefer sibling conditions over deep descendant ones when both express the same thing. :has(+ .x) checks a bounded set.
    • Profile with the real dataset. Chrome DevTools Performance panel, record an interaction, look at “Recalculate Style” duration and the “elements affected” count. If style recalc is climbing past a couple of milliseconds per interaction on a mid-range Android device, you have a selector to rewrite. That measurement takes ninety seconds and settles the argument.

    @layer and @scope do what BEM was faking

    Cascade layers were the quietest big change. You declare your priority order once, at the top of your entry stylesheet, and specificity fights inside a layer can never beat a later layer:

    @layer reset, tokens, base, components, utilities;
    
    @layer components {
      .card { padding: 1.5rem; }
    }
    
    @layer utilities {
      / Single class wins over anything in components, no !important needed /
      .p-0 { padding: 0; }
    }
    

    That is the entire reason utility classes historically needed !important. One line of @layer removes it. If you’re integrating an third-party stylesheet you can’t edit, @import url(vendor.css) layer(vendor) puts it in a box.

    @scope handles the other half: leakage. It gives you a lower boundary, which nothing else in CSS ever has:

    / Style links inside prose, but stop at any embedded widget /
    @scope (.prose) to (.widget, .card) {
      a { text-decoration: underline; text-underline-offset: .15em; }
    }
    

    The “donut scope” is the part worth remembering. Everything between the outer selector and the inner one is styled; the inner boundary and its subtree are excluded. That is exactly the problem you hit when a rich-text field contains a shortcode or a block. If you maintain a library of reusable WordPress block patterns, scoping each pattern’s styles this way stops editor content from inheriting things it shouldn’t, and it survives clients pasting patterns inside other patterns.

    The modern CSS features we now use on every build

    Beyond nesting and :has(), this is the shortlist that has actually changed our default stylesheet:

    • light-dark() plus color-scheme. One property, no media query duplication, and it respects a user override set on a subtree. We cut our dark mode token file roughly in half converting to it.
    • Container queries. container-type: inline-size on the component wrapper, then @container (min-width: 30rem). Components that work in a sidebar and a full-width hero without variant classes. Style queries (@container style(--variant: featured)) cover the rest.
    • text-wrap: balance on headings, pretty on body copy. Balance is capped at a handful of lines by design, so don’t put it on paragraphs. It kills the single-word last line on card titles, which is the typographic complaint clients raise most often.
    • @starting-style with transition-behavior: allow-discrete. This is what finally made popovers and dialogs animate in without JavaScript, because you can transition display and overlay discretely.
    • Subgrid. Card grids where the title, body and footer align across every card regardless of content length. Previously impossible without fixed heights.
    • field-sizing: content. Auto-growing textareas with zero script. Chromium only for now, but it degrades to a normal textarea, so the downside is nothing.
    • Anchor positioning. Shipped in Chromium and Safari, still landing in Firefox at time of writing. We use it behind @supports (anchor-name: --x) with a Popper-style fallback on projects that need parity, and unguarded on internal tools.

    Canvas moved to Bootstrap 5 with these primitives layered on top rather than replaced, which is the pragmatic order: the grid and components stay predictable, and nesting plus custom properties handle the theming. If you want to see the pattern applied across a large component set rather than a demo page, the Canvas template build is a reasonable reference for how far you can push native CSS before reaching for tooling.

    What still needs a preprocessor

    Sass is not dead, it’s just narrower. We still reach for it when a project needs generated selectors (looping to produce a spacing scale), compile-time math that calc() can’t express, or a shared token file consumed by both CSS and a JS build.

    Everything else has moved. Variables belong to custom properties, which are live and cascade-aware in a way Sass variables never were. Nesting is native. Colour manipulation is color-mix() and oklch(), which work at runtime and can respond to a theme change without a rebuild. Partials are @import with layers, or just an HTTP/2 connection and separate files.

    The honest position: if you’re starting a new project in 2026 and reaching for Sass reflexively, check whether you’ll use anything beyond nesting and variables first. If not, you’re adding a build step and a source map to solve problems the browser already solved.

    Frequently Asked Questions

    Do I still need Sass if I use native CSS nesting?

    Only if you need loops, compile-time functions, or selector concatenation like &--modifier. Native nesting covers the ergonomics of writing component styles, and custom properties beat Sass variables for anything theme-related because they’re live at runtime. Most of our new builds ship plain CSS with a minifier and nothing else.

    Is :has() slow enough to matter on a real site?

    Not at component scale. It becomes measurable when you write a broad subject like *:has(...) or apply a descendant condition to thousands of frequently mutating elements. Qualify the subject with a class, record an interaction in the Chrome DevTools Performance panel, and check the Recalculate Style entries. If it’s under a couple of milliseconds on a throttled CPU, ship it.

    Why do my declarations after a nested rule get ignored?

    You’re on a browser build from before the CSSNestedDeclarations fix, which landed in Chrome 130 and followed in the other engines. Those engines hoisted trailing declarations to the top of the block or dropped them entirely, with no console warning. Move plain declarations above any nested rules and the ambiguity disappears.

    Can I nest one :has() inside another?

    No. Nesting :has() inside :has() is invalid by spec, and so are pseudo-elements inside the argument. You can, however, combine :has() with :not(), :is() and sibling combinators freely, which covers almost every real requirement.

    Should I use @scope instead of CSS Modules or scoped styles in my framework?

    If you already have build-time scoping from a framework, @scope adds little except the lower boundary, which build-time tools cannot express. Where it earns its place is in CMS and template contexts: WordPress patterns, rich-text output, third-party embeds, anywhere content is nested by a person rather than a compiler. That donut behaviour is genuinely unique.

    Where to start

    Pick one page on your current project and audit the JavaScript that exists purely to toggle a class based on DOM state. Form validation styling, empty-state handling, scroll locks when a dialog opens, counting children to change a layout. Every one of those is a :has() rule now, and each deletion removes a piece of state that could desynchronise.

    Then add @layer reset, base, components, utilities; to the top of your stylesheet and delete every !important that was only there to win an override fight. Those two changes take an afternoon and they will teach you more about what modern CSS has actually solved than another list of features will.

  • WordPress Block Patterns: Building a Reusable Library for Clients

    WordPress Block Patterns: Building a Reusable Library for Clients

    A client emailed us eleven months after launch asking why the “Team” section on their new careers page looked nothing like the one on the About page. Same theme, same designer, same everything. The difference was that one had been built by an editor dragging columns around at 4pm on a Friday, and the other had been built by us.

    That gap is the entire argument for WordPress block patterns. Not the inserter-full-of-pretty-layouts version you get from the marketing pages, but a curated, locked, version-controlled set of building blocks that makes the wrong layout hard to produce. We’ve shipped this on roughly forty client sites since the /patterns directory landed in WordPress 6.0, and the difference between a pattern library that survives two years and one that gets abandoned in month three comes down to about six decisions.

    Here are those decisions, with the code.

    Key Takeaways

    • Theme-registered patterns in a /patterns folder are the default choice: they live in Git, deploy with the theme, and never need database migration. Synced patterns (the old reusable blocks) belong only to content that must be byte-identical everywhere, like a legal disclaimer or an opening-hours block.
    • templateLock: "contentOnly" on the outer group of every pattern is the single highest-value line in the whole system. It turns a layout into a form with fields, and combined with filtering canLockBlocks off for non-admins, editors genuinely cannot unlock it.
    • Pattern overrides (WordPress 6.5 onwards, via core/pattern-overrides block bindings) give you a synced structure with editable text and images, which is what most people actually wanted from reusable blocks in the first place.
    • Kill the noise: removethemesupport( 'core-block-patterns' ) plus shouldloadremoteblockpatterns returning false strips out several hundred irrelevant patterns and an HTTP request to WordPress.org on every editor load.
    • Images bundled with a theme pattern have no attachment ID, so they get no srcset. Ship correctly sized WebP with explicit width and height, or design the pattern so the client replaces the image on first use.

    Why most client pattern libraries fall apart

    The common failure is not technical. It’s that the library was built as a demo rather than as a constraint. Someone exports thirty beautiful sections, drops them in the inserter, hands over a Loom video, and leaves. Six weeks later the client has fourteen variants of a hero section because nothing stopped them from deleting the image block and pasting in a full-width video.

    The second failure is duplication with no source of truth. Half the patterns live in the database as user patterns created in the editor, half live in the theme, and nobody knows which is which. Staging gets refreshed from production, the theme deploy overwrites nothing, and the two sets drift apart. We’ve inherited sites where the same “CTA banner” existed four times: twice as unsynced user patterns, once as a synced pattern, once hardcoded in a template part.

    Fix both problems with one rule. The theme owns structure. The database owns content. Everything else follows from that.

    Synced, unsynced and overrides: decide per pattern

    WordPress 6.3 folded reusable blocks into the pattern system and renamed them synced patterns. They’re still the same wp_block post type underneath, which matters more than the rename suggests, because it means they’re database rows. Database rows do not deploy.

    Here’s the decision we use, and it takes about ten seconds per pattern:

    • Unsynced theme pattern. Anything that is a layout starting point: hero, feature grid, pricing table, testimonial row, contact section. The client inserts it, fills it in, and their copy is theirs. This is 90 percent of a real library.
    • Synced pattern. Content that must change in one place and update in forty: a compliance footnote, a seasonal promo bar, a phone number that appears in twelve sections. Keep the count low. We aim for under six per site.
    • Synced pattern with overrides. Repeating card structures where the frame is fixed but the text and image differ per instance. Team cards, service cards, case study tiles.

    The trap with synced patterns is that editing one silently rewrites published pages with no obvious warning and no per-page revision trail. If a marketing team of eight has edit access, that’s a genuine risk. Reusable blocks earned their bad reputation honestly.

    Building block patterns that live in the theme

    A pattern is a PHP file in /patterns with a header comment. WordPress registers it automatically, translates the Title and Description against your text domain, and you get version control for free.

    <?php
    /**
    
    • Title: Hero, split with image right
    • Slug: acme/hero-split-image-right
    • Categories: acme-hero
    • Description: Full-width hero with headline, supporting copy, one button and a right-hand image.
    • Keywords: hero, banner, intro
    • Viewport Width: 1400
    • Post Types: page
    • Inserter: yes
    */ ?> <!-- wp:group {"templateLock":"contentOnly","align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|80","bottom":"var:preset|spacing|80"}}},"layout":{"type":"constrained"}} --> <div class="wp-block-group alignfull"> <!-- wp:columns {"verticalAlignment":"center"} --> <div class="wp-block-columns are-vertically-aligned-center"> <!-- wp:column {"verticalAlignment":"center","width":"55%"} --> <div class="wp-block-column is-vertically-aligned-center" style="flex-basis:55%"> <!-- wp:heading {"level":1,"fontSize":"xx-large"} --> <h1 class="wp-block-heading has-xx-large-font-size"><?php eschtmle( 'A headline that fits on two lines', 'acme' ); ?></h1> <!-- /wp:heading --> <!-- wp:paragraph --> <p><?php eschtmle( 'One or two sentences of supporting copy. Keep the placeholder realistic in length so the client can see when their copy is too long.', 'acme' ); ?></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="#"><?php eschtmle( 'Get started', 'acme' ); ?></a></div><!-- /wp:button --></div> <!-- /wp:buttons --> </div> <!-- /wp:column --> <!-- wp:column {"verticalAlignment":"center","width":"45%"} --> <div class="wp-block-column is-vertically-aligned-center" style="flex-basis:45%"> <!-- wp:image {"sizeSlug":"large","style":{"border":{"radius":"12px"}}} --> <figure class="wp-block-image size-large has-custom-border"><img src="<?php echo escurl( getthemefileuri( 'assets/patterns/hero-split.webp' ) ); ?>" alt="" width="960" height="720" style="border-radius:12px" /></figure> <!-- /wp:image --> </div> <!-- /wp:column --> </div> <!-- /wp:columns --> </div> <!-- /wp:group -->

    Three things in there are not obvious. Viewport Width: 1400 controls the scale of the inserter preview, and if you leave it off, full-width sections render as an unreadable smudge. Post Types: page keeps the pattern out of the blog post inserter, which is how you stop hero sections appearing inside articles. And getthemefileuri() rather than gettemplatedirectoryuri() means a child theme can override the image by dropping a file at the same path.

    The image is also the known weak point. Because there’s no attachment ID, WordPress cannot generate a srcset, so a 1920px hero gets served to a phone. Ship the asset as WebP at the size the layout actually needs, set explicit width and height to protect CLS, and accept that the client replacing it from the Media Library is the upgrade path.

    Getting the markup out of the editor

    Don’t hand-write block markup. Build the section in the editor at full width, select the outer block, use Copy from the block toolbar, paste into your PHP file, then do two find-and-replaces: swap uploaded image URLs for getthemefile_uri() calls, and strip every "id":123 attribute left behind by the media library. Missed IDs are the cause of the classic “broken image on the staging site” ticket.

    Categories, naming and treating the inserter as UI

    Default WordPress ships with several hundred patterns across core categories, plus a live fetch from the pattern directory. For a client build, that’s noise you’re asking a non-technical editor to filter through. Turn it off.

    addaction( 'aftersetup_theme', function () {
        // Removes all core-bundled patterns (Text, Gallery, Call to Action, etc.)
        removethemesupport( 'core-block-patterns' );
    } );
    
    // Stops the editor calling out to the WordPress.org pattern directory on load.
    addfilter( 'shouldloadremoteblock_patterns', '__return_false' );
    
    add_action( 'init', function () {
        $categories = array(
            'acme-hero'    => __( 'Hero sections', 'acme' ),
            'acme-content' => __( 'Content sections', 'acme' ),
            'acme-proof'   => __( 'Testimonials & logos', 'acme' ),
            'acme-cta'     => __( 'Calls to action', 'acme' ),
        );
        foreach ( $categories as $slug => $label ) {
            registerblockpattern_category( $slug, array( 'label' => $label ) );
        }
    } );
    

    Four to six categories is the sweet spot. Go past eight and the client scrolls instead of scanning. Name patterns the way the client describes them, not the way you built them: “Three services with icons” beats “Grid 3col icon variant B” every single time. It also makes the search box in the inserter work, because they’ll type “services”.

    Prefix every slug with your theme namespace (acme/). It costs nothing and it’s what lets you call unregisterblockpattern() confidently later.

    Locking is what separates a library from a liability

    Content-only locking is the feature that makes this whole approach work for clients. Set "templateLock":"contentOnly" on the outermost group and the editor stops showing block settings for the children. Instead the client gets a plain list of editable fields in the sidebar: text, images, links. No spacing controls, no colour pickers, no ability to delete the second column.

    For finer control, lock individual blocks:

    <!-- wp:heading {"lock":{"move":true,"remove":true}} -->
    <h2 class="wp-block-heading">This heading cannot be moved or deleted</h2>
    <!-- /wp:heading -->
    

    Here’s the part the tutorials skip. Content-only locking shows a Modify button, and any user who can see it can unlock the group. If you want the lock to hold, remove the locking UI for everyone below administrator:

    addfilter( 'blockeditorsettingsall', function ( $settings ) {
        if ( ! currentusercan( 'manage_options' ) ) {
            $settings['canLockBlocks'] = false; // hides both the lock toolbar item and the Modify button
        }
        return $settings;
    }, 10, 2 );
    

    Pair that with hard limits in theme.json: settings.color.custom: false, settings.color.customGradient: false, settings.typography.customFontSize: false, and a short spacing.spacingSizes scale. A locked pattern with an unlocked colour picker is still a brand problem waiting to happen.

    The caveat: if the client’s team includes a competent in-house designer who genuinely needs to build new layouts, aggressive locking makes you the bottleneck. Give that person an Administrator account and lock the rest. Governance is a people decision that you implement in code, not the other way round.

    Pattern overrides for repeat content

    Since WordPress 6.5, a synced pattern can expose specific blocks as editable per instance using block bindings. This is the answer for card grids, staff profiles and case study tiles where the frame must never drift but the content always does.

    Inside the synced pattern, give each editable block a name in metadata and bind it to core/pattern-overrides:

    <!-- wp:group {"metadata":{"name":"Team card"},"layout":{"type":"constrained"}} -->
    <div class="wp-block-group">
      <!-- wp:image {"metadata":{"name":"Photo","bindings":{"url":{"source":"core/pattern-overrides"},"alt":{"source":"core/pattern-overrides"}}}} -->
      <figure class="wp-block-image"><img src="" alt="" /></figure>
      <!-- /wp:image -->
      <!-- wp:heading {"level":3,"metadata":{"name":"Name","bindings":{"content":{"source":"core/pattern-overrides"}}}} -->
      <h3 class="wp-block-heading">Full name</h3>
      <!-- /wp:heading -->
      <!-- wp:paragraph {"metadata":{"name":"Role","bindings":{"content":{"source":"core/pattern-overrides"}}}} -->
      <p>Job title</p>
      <!-- /wp:paragraph -->
    </div>
    <!-- /wp:group -->
    

    Overridden values are stored on the instance, so the page markup ends up looking like <!-- wp:block {"ref":412,"content":{"Name":{"content":"Priya Raman"}}} /-->. Change the card’s padding in the source pattern and all forty instances update. Change a name and only that one does.

    Two limits worth knowing before you design around this: overrides work with paragraph, heading, image and button blocks only, and the pattern has to be synced, which puts it back in the database. If you need genuinely structured repeating data (filters, sorting, an archive), you want a custom post type and a Query Loop, not a pattern. We’ve watched teams build 60-card “team libraries” out of overrides and then discover they can’t sort by department.

    Keeping it fast, and keeping it alive

    Every registered pattern is parsed and its preview rendered when the inserter opens. A library of 25 well-chosen patterns opens instantly. Push past 150 and you’ll feel the inserter hesitate on modest hardware, particularly with heavy nested column patterns. Curate hard. If a pattern hasn’t been inserted in six months, delete it.

    The maintenance habits that have actually held up for us:

    1. One file, one pattern, named after the slug. patterns/hero-split-image-right.php. Trivially greppable.
    2. A hidden style guide page built from every pattern in the library, set to noindex. It’s your visual regression check after a WordPress or theme update, and it’s the handover document.
    3. Realistic placeholder copy. Lorem ipsum hides layout failures. Write placeholders at the length the real copy will be.
    4. Test the responsive breakpoints inside the pattern preview not just on the front end. A pattern that only looks right at 1400px viewport width teaches the client nothing.

    If you’re building from scratch every time, you’re doing unpaid R&D. The reason we built CanvasWP around a large, pre-locked pattern set is that the same fifteen section types cover the majority of marketing sites, and the value you add for a client is in the constraints and the copy, not in rebuilding a testimonial slider for the ninetieth time.

    Frequently Asked Questions

    Are reusable blocks deprecated in WordPress?

    They’re renamed, not removed. Since WordPress 6.3 they’re called synced patterns, and they still use the same wp_block post type, so existing reusable blocks continue to work with no migration needed. What changed is the UI: synced and unsynced patterns now live together in the same Patterns screen under Appearance.

    Should client patterns go in the theme or be created in the editor?

    Put layout patterns in the theme’s /patterns directory. They then live in Git, deploy with the rest of the code, and survive a database refresh from production to staging. Reserve editor-created patterns for content the client owns and edits themselves, and accept those will need a database export to move between environments.

    How do I stop clients breaking a pattern after inserting it?

    Add "templateLock":"contentOnly" to the outer group block in the pattern markup, which reduces the editing experience to filling in text and images. Then filter blockeditorsettingsall to set canLockBlocks to false for users without manageoptions, otherwise they can click Modify and unlock it. Back both up with theme.json settings that disable custom colours and font sizes.

    Do block patterns work in classic themes?

    Yes. The /patterns directory auto-registration and registerblockpattern() both work in classic themes as long as the block editor is in use for the post type. What you lose is theme.json-driven design controls and template part patterns, so patterns in a classic theme need their styling handled by the theme’s own stylesheet.

    Why do images in my theme patterns look blurry or oversized on mobile?

    Because a theme-bundled image has no attachment ID, WordPress cannot generate the srcset that normally serves smaller versions to smaller screens. Export the asset as WebP at the maximum size the layout needs, add explicit width and height attributes, and encourage the client to replace it from the Media Library, which restores responsive sizing on that instance.

    If you take one thing into your next build, make it this: write the pattern, then lock it, then delete two patterns you don’t need. A library of twenty patterns that the client can’t break is worth more than a hundred they can. Start with the five sections that appear on every page of the site you’re building right now, get those into /patterns with content-only locking, and grow the set only when a real page needs something the library can’t produce.