Skip to content

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:

ElementClassExample
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_42page_row.id = 42
  • col_108page_row_columns.id = 108
  • widget_217page_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:

css
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:

css
.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:

css
color: #111;

h3 {
    color: #4F46E5;
    font-weight: 600;
}

h3 i {
    margin-right: 0.5rem;
}

&:hover {
    opacity: 0.9;
}

Compiles to:

css
.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:

php
// 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:

php
// 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:

ts
// _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-6standard 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:

env
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:

TableColumnContents
page_rowcustom_cssLESS/CSS for .row_{id}
page_row_columnscustom_cssLESS/CSS for .col_{id}
page_row_col_widgetcustom_cssLESS/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