What if adopting your builder’s recommended CSS approach is the thing that makes your components worthless outside of it?
The Assumption Everyone Makes
WordPress page builders recommend methodologies. Some even implement features that look like industry standards but aren’t. When a builder offers SASS-like nesting with &__element and &--modifier syntax, developers adopt it without questioning whether it produces standard CSS or a proprietary approximation. The assumption is straightforward: the builder handles it, the output looks right, so the implementation must be correct. Besides, you’re using BEM—that’s a standard methodology, so everything downstream must be standard too.
What Actually Happens
Native CSS nesting and SASS nesting behave fundamentally differently when it comes to string concatenation. In SASS, & is a string placeholder. Writing &--modifier inside .block__element produces .block__element--modifier as the compiled selector—a clean class name the browser matches via hash lookup. Native CSS doesn’t do this. The & symbol represents the entire parent selector as a reference, not a string to concatenate. There is no mechanism in the CSS specification to append characters to a class name through nesting.
When builders implement &--modifier without a proper preprocessor pipeline, something has to give. Either the feature is buggy and produces unpredictable output, or it silently falls back to workarounds like attribute substring selectors ([class*='--modifier']). Developers who rely on this end up with components that work inside the builder but break, perform poorly, or behave unpredictably the moment they’re moved anywhere else.
Why This Matters More Than You Think
Page builders are tools. Tools get replaced. The average WordPress developer switches or evaluates builders every few years as the ecosystem evolves. If your component CSS depends on proprietary compilation behavior, every component you build is effectively disposable. That’s a significant investment in work that cannot travel with you.
The deeper issue is that most developers never check what the browser actually receives. They inspect the builder’s editor, see the nesting syntax they wrote, and assume the rendered output matches. But if the builder’s CSS compilation has bugs—and new builders inevitably have bugs—the output might be an attribute selector, a malformed rule, or something that only works because of specificity accidents in the builder’s own stylesheet.
Consider a practical example. You’re building a table of contents component for blog posts. The trigger button, navigation bars, overlay panel, links, and list items are all generated dynamically by JavaScript. None of these elements exist in the builder at design time. You need to write CSS for classes that will only appear at runtime—classes like .post-toc__trigger, .post-toc__bar--h2, .post-toc__link--active. In a builder that attaches CSS directly to HTML elements, you have no element to attach these styles to. You’re forced into one of two paths: an external stylesheet loaded everywhere (wasteful), or nesting everything under the parent component’s class (portable and scoped).
The native CSS nesting spec handles this parent-child relationship perfectly:
.post-toc {
& .post-toc__trigger {
position: fixed;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
appearance: none;
border: none;
background: transparent;
&:hover {
background: rgba(0, 0, 0, 0.06);
}
}
}
Every style for every dynamically injected child lives on the .post-toc root class—which does exist in the builder at design time. The descendant selector & .post-toc__trigger is valid native CSS. The :hover state nesting is valid native CSS. No preprocessing required, no builder-specific behavior involved, and the entire component moves to any other system without changing a single character.
Modifiers on the root element itself use class conjunction, which native CSS also handles correctly:
.post-toc {
--post-toc-gap: 5px;
&.post-toc--tight {
--post-toc-gap: 3px;
}
}
The &.post-toc--tight selector resolves to .post-toc.post-toc--tight—an element that has both classes simultaneously. This is standard CSS, works in every modern browser, and uses the same hash-lookup matching as a flat class selector.
Compare this to the substring hack that developers reach for when they want SASS-style concatenation without SASS:
/* Don't do this */
.post-toc {
&[class*='--tight'] {
--post-toc-gap: 3px;
}
}
This searches the entire class attribute string for the substring --tight. It works until it doesn’t. Add a utility class like spacing--tighter or a second component with card--tighten on the same element, and the selector matches unintentionally. It’s a fragile pattern that trades correctness for brevity.
Performance: Why Selector Choice Actually Matters
CSS selector performance rarely matters on a typical marketing page. But when you’re building reusable components that appear dozens or hundreds of times on a single page—tag lists, table of contents entries, navigation items, card grids—selector efficiency compounds.
Browsers match CSS selectors right to left. For a class selector like .post-toc__link--active, the engine checks a hash map. It’s O(1)—constant time regardless of how many classes exist in the document. For an attribute selector like [class*='--active'], the engine performs a substring search across the entire class attribute value of every candidate element. That’s a fundamentally different operation.
Here’s where it gets practical. Consider styling heading-level variations in a table of contents:
/* Hash lookup — O(1) per element */
& .post-toc__bar--h2 {
width: 22px;
background: rgba(100, 100, 100, 0.4);
}
& .post-toc__bar--h3 {
width: 14px;
background: rgba(100, 100, 100, 0.22);
}
&.post-toc__bar--active.post-toc__bar--h2 {
background: var(--primary);
box-shadow: 0 0 6px rgba(20, 20, 20, 0.2);
}
Each of these is a direct class match. The browser never scans a string. Now consider the same logic written with attribute selectors:
/* Substring search per element, per selector */
& [class*='--h2'] {
width: 22px;
}
& [class*='--h3'] {
width: 14px;
}
& [class*='--active'][class*='--h2'] {
background: var(--primary);
}
On a page with 30 headings generating 30 bar elements, each evaluated against multiple substring selectors, the difference is measurable. Not catastrophic—but measurable. And it compounds with every component on the page that uses the same pattern.
Beyond raw matching, attribute selectors also affect the browser’s ability to optimize style invalidation. When a class changes on an element, the engine knows exactly which rules might be affected because class selectors are indexed. Attribute selectors aren’t indexed the same way, which means broader style recalculations on DOM changes. For a table of contents that dynamically adds and removes --active classes as the user scrolls, this matters.
The performance principle is simple: use the selector type that gives the browser the most information to work with. Class selectors are the most optimized path in every browser engine. Attribute selectors are a fallback for cases where you genuinely need pattern matching. Using them as a substitute for proper class selectors because your tooling doesn’t support native concatenation is solving a tooling problem with a performance cost.
The Questions You Should Be Asking Instead
Instead of “How do I make SASS-style nesting work without SASS?”, ask these:
Does native CSS nesting already cover my use case, and am I overcomplicating this? In most BEM architectures, the relationships you need to express—descendants, states, media queries, and modifier conjunctions—are all supported natively without any concatenation tricks.
What does my builder actually output to the browser? Open DevTools on a live page, inspect the <style> block or linked stylesheet, and compare it to what you wrote. If they don’t match, your builder is transforming your CSS in ways you didn’t authorize.
If I copy this component’s HTML and CSS to a static HTML file, does it work identically? This is the ultimate portability test. If it doesn’t work outside the builder, it’s not standard CSS—it’s builder CSS wearing a standard costume.
Am I using nesting to express real structural relationships, or just to avoid typing the block name? BEM modifiers are flat by design. Nesting them doesn’t add semantic meaning. The only thing it adds is a dependency on whatever tool is interpreting the nesting syntax.
What This Looks Like in Practice
When building a table of contents component that JavaScript populates at runtime, the entire CSS lives under the .post-toc root using only native nesting features:
Descendant elements use & .block__element syntax. States use &:hover and &:focus-visible. Modifiers on children use &.block__element--modifier conjunction. Media queries nest directly inside the component block. Even complex multi-class selectors like &.post-toc__bar--active.post-toc__bar--h2 are valid native CSS, because conjunction (same element, multiple classes) is not concatenation (building new class names from string fragments).
The result is a single .post-toc rule block that contains everything the component needs. It can be pasted into any builder, any external stylesheet, any static HTML file, or any future WordPress theme without modification. The HTML uses standard BEM classes with no builder-specific data attributes for styling. The CSS uses no syntax that requires compilation.
When the builder eventually ships a major update, or when you evaluate a different tool, or when you need to render the component server-side, or when a client asks you to port it to a non-WordPress site—the component just works. That’s what writing to the standard buys you.
Where Most People Get Stuck
“But typing .post-toc__bar--active repeatedly is tedious.”
It is. That’s a developer experience trade-off, not an architectural one. The repetition is the price of portability. If you want the convenience of short modifier syntax, use a proper preprocessor like SASS in your build pipeline—it will compile to correct flat selectors. What you shouldn’t do is approximate SASS behavior through attribute selector hacks and pretend the result is equivalent.
“My builder says it supports BEM nesting.”
Check the output. If &--modifier compiles to .block__element--modifier as a class selector, you’re fine—that’s genuine preprocessing. If it compiles to something else, or if the behavior is inconsistent between the editor preview and the published page, you’re building on a bug, not a feature.
“Native nesting is verbose for BEM. Isn’t there a CSS proposal for concatenation?”
There were discussions, but no specification has been adopted. Until one is, concatenation is a preprocessor feature, not a CSS feature. Writing CSS that depends on a feature that doesn’t exist in the spec is the definition of non-portable code.
“Attribute selectors work fine for me.”
They work until they don’t. The first time a third-party plugin adds a class to your element that happens to contain your modifier substring, you’ll spend hours debugging a styling conflict that only appears in production. Class selectors don’t have this failure mode.
Strategic Opposition Principle: The fastest way to build reusable components is to refuse the shortcuts that make them disposable.
Related Field Notes: Your Page Builder Is Getting You Banned From Your Own API
