Custom-CSS Convention
Every row, column, and widget instance in the Pagebuilder can carry its own CSS — the editor types it into the properties panel (CodeMirror editor). The core wraps that CSS in a scope-unique class, collects everything across the whole page, and builds the final CSS bundle from it. This page covers wrapper classes, CSS nesting, cache integration, and the PurgeCSS safelist.
Wrapper classes
The core assigns unique classes automatically on three levels:
| Element | Class | Example |
|---|---|---|
| Row (section) | .row_{id} | .row_42 |
| Column | .col_{id} | .col_108 |
| Widget (instance) | .widget_{base_id} | .widget_217 |
{id} is the primary-key ID from the matching table:
row_42→page_row.id = 42col_108→page_row_columns.id = 108widget_217→page_row_col_widget.base_id = 217
widget_{base_id}, not widget_{id}
For widgets, the wrapper is built from the base ID — not the widget row ID. Multi-language variants of the same widget instance share the same base ID and thus the same wrapper class. That way you only maintain the CSS once.
Entering custom CSS
Inside the Pagebuilder properties panel, every element has a CodeMirror editor with LESS syntax validation. Example input for a widget:
color: #4F46E5;
padding: 2rem;
border-radius: 0.75rem;
background: white;The editor writes only the contents of the wrapper — no selectors. The core wraps it before the cache build:
.widget_217 {
color: #4F46E5;
padding: 2rem;
border-radius: 0.75rem;
background: white;
}CSS nesting for child selectors
For styling sub-elements, the editor can use CSS nesting — the CSS is parsed as LESS, so nesting is supported:
color: #111;
h3 {
color: #4F46E5;
font-weight: 600;
}
h3 i {
margin-right: 0.5rem;
}
&:hover {
opacity: 0.9;
}Compiles to:
.widget_217 { color: #111; }
.widget_217 h3 { color: #4F46E5; font-weight: 600; }
.widget_217 h3 i { margin-right: 0.5rem; }
.widget_217:hover { opacity: 0.9; }& references the wrapper selector — as usual in Sass/LESS. Media queries and pseudo-classes also work nested.
Cache integration
cache_compress_css.php — the builder
The script _public/extensions/core/backend/cache_settings/script/cache_compress_css.php iterates over every row/column/widget on the page and wraps the respective custom_css:
// Excerpt from cache_compress_css.php
// Rows — wrap with .row_{id} { ... }
foreach ($rows as $row) {
$css .= parseCustomLess(
".row_" . $row['id'] . " { " . $row['custom_css'] . " }",
'row_' . $row['id']
);
}
// Columns — wrap with .col_{id} { ... }
foreach ($cols as $col) {
$css .= parseCustomLess(
".col_" . $col['id'] . " { " . $col['custom_css'] . " }",
'col_' . $col['id']
);
}
// Widgets — wrap with .widget_{base_id} { ... }
foreach ($widgets as $w) {
$css .= parseCustomLess(
".widget_" . $w['base_id'] . " { " . $w['custom_css'] . " }",
'widget_' . $w['base_id']
);
}parseCustomLess() uses the LESS parser — nesting, variables, and mixins are possible. The full output lands in the global CSS bundle.
When does the cache rebuild?
- After changes to custom CSS (via a Pagebuilder save)
- After changes to design tokens (
/admin/design→ LESS editor) - Manually via
/admin/cache→ "Clear cache"
The cache rebuild is global — every page is re-compiled. It's a one-off cost; after that the CSS runs without runtime overhead.
Spacing and flex CSS
In addition to custom CSS, the core automatically generates media-query-based rules for spacing (padding/margin) and flex alignment:
// From cache_compress_css.php
// Spacing → margin-top/right/bottom/left + padding-*
$css .= generateSpacingCssBlock('.row_' . $row['id'], $row['spacing']);
// Flex → align-items, justify-content
$css .= generateFlexCssBlock('.row_' . $row['id'], $row['flex_settings']);Both use the responsive breakpoints (1200px / 992px / 768px) from the design system. Editors manage spacing/flex through a Webflow-style box model inside the properties-panel tabs — not as custom_css input.
Never do spacing inline or via custom CSS
If you type margin-top: 2rem into the custom CSS editor, you sabotage the responsive system. For spacing, always use the Spacing tab in the properties panel — it gives you separate values per breakpoint. Details: CLAUDE.md section "Pagebuilder — Spacing & Flex".
PurgeCSS safelist
Because .row_{id}, .col_{id}, .widget_{id} are generated dynamically, PurgeCSS can't detect them at build time. The theme's nuxt.config.ts keeps them in the default safelist — importantly, inside the greedy bucket, not standard:
// _theme/vue-base/nuxt.config.ts (excerpt)
safelist: {
standard: [
/^swiper/, /^vjs/, /^fa-/, /^ns-/,
/^col-/, /^row/, /^active/, /^show/, /^modal/, /^elw-/
],
greedy: [
/^cc/, /^cm/, // Cookie consent
/^row_/, /^col_/, /^widget_/, /^sw6/ // Pagebuilder wrappers
],
deep: [
/data-animation/, /ns-anim-safety/ // GSAP animations
]
}greedy is the key choice here: Pagebuilder classes often appear in combinations like widget_217 is-active or row_42 col-6 — standard only matches exact class names, not composite selectors. greedy matches the prefix inside the class attribute and pulls the whole selector context into the bundle.
Custom class names inside custom CSS
If you use a custom class inside your custom CSS (e.g. .my-custom-button), you have to add it to the project safelist — otherwise PurgeCSS removes it. Via env without patching nuxt.config.ts:
NUXT_PURGECSS_SAFELIST_STANDARD=my-custom-,xyz-Details: Configuration › PurgeCSS safelist.
AI-generated CSS
The AI layout feature writes CSS into the same custom_css column — not as inline styles. That way AI output remains editable through the CodeMirror editor afterwards. Convention: the AI also uses CSS nesting with child selectors like h3 i { color: #4F46E5 }.
Database columns
Every one of the three element tables has a custom_css column:
| Table | Column | Contents |
|---|---|---|
page_row | custom_css | LESS/CSS for .row_{id} |
page_row_columns | custom_css | LESS/CSS for .col_{id} |
page_row_col_widget | custom_css | LESS/CSS for .widget_{base_id} |
The auto-format feature (formatCss()) prettifies minified CSS on load into the CodeMirror editor — the editor always sees formatted code, even though the DB stores the minified output.
Common issues
Selector written inside the custom CSS editor
The editor expects only the wrapper contents, not the selector. .widget_217 { color: red; } compiles via LESS to .widget_217 .widget_217 { color: red; } — a descendant selector that matches nothing because there's no nested .widget_217 in the DOM. The editor doesn't surface the error directly, but the CSS never takes effect. Just type color: red;.
.widget_217 hardcoded in theme CSS
Theme CSS should never reference concrete .widget_217 classes — the ID changes on page copies/variants. For cross-theme widget styles, put them inside the widget template itself (template/index.vue → <style scoped>).
Inline styles via v-bind in the template
Dynamic styles through :style="{ color: widget.data.color }" are pragmatic for user-configurable colors — but they sidestep the custom CSS system. For structural styling, combine custom CSS with CSS variables: the widget sets style="--my-color: ...", custom CSS uses color: var(--my-color).
PurgeCSS removes your own utility classes
If you reference a class inside your custom CSS that's defined in the theme CSS (e.g. .text-primary), PurgeCSS might drop it from the theme bundle — because the class appears nowhere in the template HTML. Add the class to NUXT_PURGECSS_SAFELIST_STANDARD.
Spacing through custom CSS
margin/padding inside the custom CSS editor breaks the responsive system. For spacing, always use the Spacing tab in the properties panel — it generates media-query-based CSS per breakpoint.
See also
- Widget Anatomy — widget instance ID and
base_id - widget_menu field types — where the properties-panel form comes from
- Configuration › PurgeCSS safelist — project-specific safelist extension
- Plugins › API Endpoints —
backend/pagebuilderactions (save_row/column/widget CSS) _public/extensions/core/backend/cache_settings/script/cache_compress_css.php— builder code_public/src/css/frontend/custom.less— global design tokens