Style Field JSON Reference

Every Page Builder widget type, plus the div/Container structural element, declares its Style-tab fields as data in a {type}.styles.json file — admin/assets/js/src/page-builder/config/widgets/{type}/{type}.styles.json for a real widget type, or admin/assets/js/src/page-builder/config/widgets/container.styles.json for Container, which isn't a widget type and so has no subfolder of its own — instead of hand-written JS/PHP CSS-building code. This page is a systematic, key-by-key reference for that file's format. For a worked example of extending it, see the Adding a New Widget Type and Targeting a Different Inner Element tutorials — this page documents the format itself, not a walkthrough.

Both languages read the exact same JSON file:

  • JSassembleStyleTabFields() (admin/assets/js/src/page-builder/config/widgets/styleFieldListUtils.js) turns it into the flat field list the Style tab dialog renders and buildElementStyles()/buildElementHoverStyles() (admin/assets/js/src/shared/utils/buildElementStyles.js) resolve into real inline styles for the editor canvas.
  • PHPSANILWB_Style_Field_Targets (includes/class-sanilwb-style-field-targets.php) reads it directly (no assembly step needed, since PHP's declaration builders are value-driven, not field-list-driven) to feed SANILWB_CSS_Compiler::build_widget_target_rule_set()/build_widget_target_hover_rule_set(), dispatched from SANILWB_Frontend_Renderer::render_widgets().

Registration is explicit in both languages, never derived from the type string. JS: WIDGET_STYLES_JSON in config/widgets/index.js maps a snake_case widget type (e.g. post_title) to its imported, camelCase-named JSON module (postTitle.styles.json). PHP: SANILWB_Style_Field_Targets::WIDGET_STYLES_JSON_FILENAMES mirrors that exact same map with filename strings. A real bug once came from skipping this: PHP tried to derive the filename by regex-transforming the snake_case type string, which is wrong for every multi-word type (carousel_navcarousel_nav.styles.json, a file that doesn't exist, instead of carouselNav.styles.json) — always add an explicit entry in both maps, never assume a naming convention will save you the step.

Container (the div/structural element) is registered the same way, but not as a widget type. Its type key is 'container', file is container.styles.json, and it's read by the exact same functions above — but it lives outside WIDGET_TYPES/WIDGET_STYLES_JSON (JS) as a parallel registration, since div deliberately isn't folded into WIDGET_TYPES (see the Page Builder feature doc's Widget System section for why — two widgets sharing a default target class could collide via CSS descendant selectors). structuralDialogTabs.js builds DIV_STYLE_FIELDS/DIV_ALLOWED_TARGETS from it the same way a widget's own module builds its style fields.


Top-level keys

{
  "useSharedSections": [ ... ],
  "ownFields": [ ... ],
  "omitFields": [ ... ],
  "customLogic": [ ... ],
  "attributeTargets": [ ... ]
}

All five are optional — an empty/missing key behaves as []. customLogic and attributeTargets are documented in their own sections below, since each is substantial enough to need one.

useSharedSections

An array of shared Style-tab sections this type opts into, pulled from the master list in admin/assets/js/src/shared/config/sharedDialogFields.js (SHARED_STYLE_FIELDS). Two entry shapes:

Entry Meaning
"Background" (bare string) Pull in the whole Background section, targeting this type's default root/outer wrapper.
{ "name": "Background", "target": ".sanilwb-icon" } Pull in the whole section, but redirect every field in it to .sanilwb-icon instead of root.

Valid section names, and their fixed sort position (from admin/assets/js/src/shared/config/styleSectionPositions.json): Layout (10), Size (20), Typography (30), Background (40), Gradient (41), Border (42), Box Shadow (43), Filter (43.5), Transitions (44), Animations (45), Spacing (50), Position (60). A type that omits a section (e.g. Container never lists Typography, since a div never renders text of its own) simply never gets those fields at all — not hidden, not present in the assembled list, so nothing in that type's own render code can accidentally read a value from it either.

A redirect is whole-section, not per-field — you can't pull in Background but keep only Background Color while dropping Background Image. If you want just one property somewhere non-default, that's ownFields with cssProperty (see below), not a useSharedSections redirect.

ownFields

An array of this type's own fields, and/or { "type": "section", "label": "...", "position": N } markers that start a new labeled block. Everything between one marker and the next belongs to that marker's section; a field with no preceding marker at all still gets included (grouped into a fallback block sorted last, not silently dropped).

position on a section marker is what determines where that whole block sits relative to the shared sections above — pick a number between two existing ones to slot in between them. Two real examples:

  • Icon's own "Icon" section uses "position": 10 — the same number as Layout — because Icon has no Layout section at all (useSharedSections omits it), so "Icon" simply takes Layout's slot instead.
  • Container's own "Carousel" section (Arrow Color/Dots Color) uses "position": 15 — between Layout (10) and Size (20) — since Container does keep Layout, so "Carousel" needs its own distinct spot rather than colliding with it.

A field entry's own recognized keys:

Key Meaning
name The stored field name (sanilwb_*). Required unless the field is input-group, box-shadow, or gradient-stops (see "Special field types" below), which each derive their real names differently.
type Dialog control type — color, number, select, toggle, text, font-family, font-weight, font-style, or one of the three composite types below. Determines which FormFields.jsx component renders it; has no effect on CSS building beyond the composite types' own special handling.
label Dialog field label.
responsive true if this field has separate __tablet/__mobile stored values (resolved via getResponsiveValue()/PHP's suffix-cascade reads); false for a single desktop-only value.
target CSS selector fragment this field's value styles. Omit for the default root/outer wrapper. A compound (comma-joined) selector is valid — see "Compound targets" below.
cssProperty camelCase CSS property name (or array of them) this field's value sets. See "The cssProperty escape hatch" below — required for any field name that isn't one of the two bucket-representative names.
hoverState "normal" (default, resting state) or "hover" — pairs two field entries sharing the same target/cssProperty as a resting/hover pair. See "Hover fields" below.
important Adds !important to the emitted declaration — needed when the target element may carry a conflicting inline style from outside plugin control.
units / unit units: an array (e.g. ["px","%","em","rem","vh","vw"]) giving the field a switchable unit dropdown. unit: a single locked unit string (e.g. "px", "deg") with no dropdown at all. Only one of the two is ever set on a given field — never both. Purely a dialog-rendering concern; the stored value already carries its unit suffix, so CSS-building code never reads units/unit itself.
showIf Conditional dialog visibility — see its own section below.
default Display-only fallback shown by the dialog control before the user has ever touched the field — never written into stored values, so showIf conditions referencing this field's "untouched" state should check for the un-set/empty value, not the default.
presets Optional map of this field's own possible stored values to { otherFieldName: overrideValue, ... } overrides on other fields — see its own section below.

omitFields

A flat array of field names (strings, not objects) to drop from the fully-assembled list, applied last — after useSharedSections and ownFields are merged and sorted. Typical use: dropping a few sub-fields of an otherwise-wanted shared section. Every widget type except shortcodes drops Layout's sanilwb_direction/sanilwb_flex_wrap/sanilwb_align_content/sanilwb_gap this way, since those only have a visible effect when a widget's wrapper has more than one child to arrange — never true for a single-tag widget, but pulling in the rest of Layout (Layout Type, Align Items, Justify Content) is still wanted.

The one real limitation, worth knowing before you reach for this: omitFields is a static, unconditional list — it either drops a field for that whole type, or it doesn't. It cannot express "hide field X only when field Y currently equals Z" — that's a live, per-render decision omitFields has no mechanism for (it runs once, at field-list assembly time, with no access to the current buffer's values). This is not the same limitation as showIf — don't reach for omitFields when what you actually want is conditional visibility; that's showIf on the field itself, which is fully dynamic (see below). If you genuinely need to drop a field only under some other field's specific value, and showIf doesn't cover it because you need the field gone from CSS generation too, not just hidden in the dialog — that's a real schema gap, not something to work around by editing shared files. Flag it and ask. (Note: if what you actually need is a different value under some condition rather than no value at all, that's customLogic's when — see below — not this gap.)


showIf — conditional dialog visibility

showIf is a generic, pre-existing mechanism — it isn't specific to ownFields, or to any one type's .styles.json. useFieldVisibility.js/FormFields.jsx evaluate it on any field object, from any source: a widget's Content tab, the shared Style-tab fields, or a {type}.styles.json's own ownFields. Nothing in the assembly pipeline (assembleStyleTabFields()) touches or strips it.

Syntax (admin/assets/js/src/shared/hooks/useFieldVisibility.js):

Pattern Meaning
fieldName=value Visible when fieldName's current value equals value.
fieldName=!value Visible when it does not equal value (negation).
fieldA=x & fieldB=y AND — both conditions must pass.
fieldA=x \|\| fieldB=y OR — at least one group must pass.

Evaluation splits on the literal substrings || then &there is no operator precedence or grouping (no parentheses). Every ||-separated segment must already be a complete, self-contained AND-chain on its own; you cannot write A & (B || C) and expect it to group correctly — it must be spelled out as A & B || A & C. structuralDialogTabs.js's Terms field is a real example that needs this shape.

showIf is evaluated against the item's entire current value buffer, not scoped to one tab — a Style-tab field's showIf can reference a Content-tab field's value (this is exactly how Container's Carousel Arrow Color/Dots Color fields work: gated on sanilwb_carousel_enabled/sanilwb_carousel_show_arrows, both Content-tab toggles, even though the color fields themselves live on the Style tab).

showIf is JS/dialog-only. It decides what the editor shows; it has zero effect on CSS generation in either language, and PHP never evaluates it at all (PHP never renders a dialog). A field hidden by showIf can still have a stored value from before it was hidden, and that value still produces CSS normally if the field has a real target/cssProperty — hiding a field in the dialog does not remove its effect.


customLogic — derived CSS values (formulas and lookups)

Every mechanism documented above maps one field's own stored value onto CSS, at most redirected to a different target. Sometimes a widget genuinely needs a CSS value that isn't any single field's value — it's a formula combining several fields (a nested submenu's left offset needs the panel's width minus its own padding), or a lookup where the right value depends on which option another field is set to (whether a Dropdown menu's panel should start display:none or display:block depends on which Layout is selected). customLogic is the declarative, generic escape hatch for both.

Why this exists, and the rule it enforces: before customLogic, a derived value like this only had one place to live — hand-written PHP/JS glue, injected directly into a shared, all-widget-types file (class-sanilwb-frontend-renderer.php, buildCanvasLiveCss.js) behind an if ('menu' === $widget_type) check. That is exactly the pattern this project's own hard rule (see root CLAUDE.mdWidget Style Fields) exists to stop: no per-widget-type conditionals in shared files, ever — not even a small, well-commented one. customLogic gives every widget a real, schema-level place to declare this kind of logic in its own {type}.styles.json, read by one small, fully generic engine that lives in the shared files as engine code only — the engine itself never branches on widget type; every widget-specific rule lives in that widget's own JSON. The Menu widget's Submenu Nested Offset, Panel/Toggle Display, and Submenu accordion display/position are the worked example this whole mechanism was built for — see menu.styles.json's customLogic array for the real, complete case.

The engine, both languages:

  • PHPSANILWB_CSS_Compiler::build_custom_logic_rule_set(), reading customLogic off SANILWB_Style_Field_Targets::get_widget_styles_config( $widget_type ) (the same full decoded JSON that function already returns — no change needed there). Called from SANILWB_Frontend_Renderer's per-widget collection loop, via the thin collect_custom_logic_css() wrapper (same pattern as collect_typography_own_tag_css()/collect_carousel_nav_wrapper_css()) — called unconditionally for every widget type, a no-op whenever that type's JSON has no customLogic array.
  • JSbuildCustomLogicCss() in admin/assets/js/src/page-builder/utils/buildCanvasLiveCss.js, reading getWidgetCustomLogic( type ) (config/widgets/index.js, a thin accessor over WIDGET_STYLES_JSON[ type ]?.customLogic). Folded into buildAjaxWidgetCustomTargetCss() — the editor's live-CSS patch mechanism for AJAX-rendered widgets, since a customLogic field is almost always driven by other fields' values (not a structuralFields change), so it needs the same live-patch path any other pure-style field edit does.
  • Each customLogic entry's target is folded into that widget type's ALLOWED_STYLE_TARGETS automatically (config/widgets/index.js) — you don't need to also add it anywhere else for buildElementStyles()'s target allow-list to accept it.

Entry shape

An array of entries, each either a formula (declaration) or a lookup (when) — never both on the same entry:

Key Meaning
target CSS selector fragment, same meaning as ownFields[].target. Bake any pseudo-class/pseudo-selector suffix directly into this string (e.g. .sanilwb-menu-panel:not(.is-open)) — customLogic has no separate selectorSuffix key.
declaration Literal, final CSS text (a formula). The engine only resolves %token% placeholders inside it — nothing else. Write !important directly in the string when you need it; there's no separate important flag.
when An array of { if, declaration } / { else, declaration } branches (a lookup) — see below. Mutually exclusive with a plain declaration on the same entry.
responsive true to emit real per-breakpoint declarations (desktop/tablet/mobile, each auto-suffixing every token it reads with __tablet/__mobile), wrapped in the matching @media block. Omit for a single desktop-only declaration.

%token% and %token\|fallback% — formula substitution

A declaration string is resolved by substituting every %fieldName% with that field's own resolved value for the current breakpoint (its real stored name, sanilwb_-prefixed, exactly as it appears in ownFields[].name — no responsive suffix written in the token itself, the engine appends __tablet/__mobile automatically when responsive: true). Nothing else in the string is touched — no arithmetic, no unit parsing; if you need CSS calc(), write calc() literally and let the browser do the math:

{
    "target": ".sanilwb-submenu .sanilwb-submenu",
    "responsive": true,
    "declaration": "left: calc(%sanilwb_submenu_width% - %sanilwb_submenu_padding_left|0px% - %sanilwb_submenu_padding_right|0px%) !important;"
}

This is Menu's real Submenu Nested Offset field — a nested (submenu-inside-submenu) panel's left offset has to equal the parent panel's own width minus its left-and-right padding, per breakpoint, or the nested panel misaligns and (with only one padding subtracted) leaves a dead gap that breaks mouse-hover continuity between the two panels.

  • %fieldName|fallback% — if the field's value is empty for this breakpoint, substitute the literal fallback text instead. Above, sanilwb_submenu_padding_left/_right fall back to 0px when the user hasn't set a padding — the formula still produces valid CSS (calc(300px - 0px - 0px)) rather than a hole in the string.
  • A token with no |fallback is required. If sanilwb_submenu_width itself is empty for a breakpoint, the entire declaration resolves to '' (nothing is emitted for that breakpoint) rather than a broken calc( - 0px - 0px). This is deliberate: a token with a fallback can always produce something sensible on its own; a token with no fallback is the one piece of information the whole declaration can't function without, so its absence has to cancel the whole thing, not just leave a blank hole in otherwise-valid CSS.
  • A non-responsive formula entry (no "responsive": true) resolves once, using each token's plain (desktop) stored value, and behaves like any other unconditional field declaration — bare in the compiled CSS, applying at every breakpoint unless something more specific overrides it.

when / if / else — lookup branches

when is an array of branches, evaluated top to bottom, first match wins:

{
    "target": ".sanilwb-menu-panel:not(.is-open)",
    "responsive": true,
    "when": [
        { "if": "sanilwb_menu_layout=dropdown", "declaration": "display:none;" },
        { "else": true, "declaration": "display:block;" }
    ]
}
  • { "if": "field=value", "declaration": "..." } — same field=value equality syntax as showIf (see that section above): the branch matches when field's resolved value for this breakpoint equals value exactly. There is no !=, no >/<, and no &/|| combinator inside a when branch — each branch is a single equality check.
  • { "else": true, "declaration": "..." } — always matches. Put it last; branches after it are never reached.
  • An empty declaration in a matched branch means "emit nothing for this breakpoint." This is how a lookup expresses "defer to whatever CSS already applies here" instead of forcing a value — Menu's Submenu Position Reset only forces position:static on a Vertical/Dropdown (accordion) breakpoint; on Horizontal it deliberately emits nothing, so the global position:absolute flyout default (from _menu-dropdown.scss) keeps applying untouched:
{
    "target": ".sanilwb-submenu:where(*)",
    "responsive": true,
    "when": [
        { "if": "sanilwb_menu_layout=vertical", "declaration": "position:static;" },
        { "if": "sanilwb_menu_layout=dropdown", "declaration": "position:static;" },
        { "else": true, "declaration": "" }
    ]
}
  • "Value is A or B" is two branches with the identical declaration repeated — as above (layout=vertical and layout=dropdown both resolving to the same position:static;) — not a comma-list or an OR inside one if. This was a deliberate choice over adding an OR/multi-value syntax: it needs zero new grammar, reusing the exact same single-value equality every other condition in the schema already uses (showIf included), at the cost of one repeated line when a lookup genuinely has more than one matching value.
  • An "is this field empty" check is the same equality syntax with nothing on the right-hand side"if": "sanilwb_link_url=" matches when that field's resolved value is the empty string. No separate "nonempty"/"exists" operator exists; equality against "" already covers it.
  • The condition field cascades to the base (desktop) value when this breakpoint's own value is unset — unlike a formula's %token% substitution (which never cascades; see above), a when condition's field does inherit the resolved Desktop value when Tablet/Mobile has no explicit override of its own, matching how every responsive field's own cascade already works elsewhere in the plugin. Without this, a menu with Layout set only on Desktop would evaluate Tablet's condition against a blank value instead of correctly inheriting Desktop's chosen layout.

Why a when entry always wraps every breakpoint (including desktop) in its own @media

A responsive formula entry with no when behaves like any other field: its desktop declaration is emitted bare (unscoped), applying everywhere unless a Tablet/Mobile @media override cancels it — the normal CSS cascade every other responsive field relies on.

A responsive when entry is different: because a branch can legitimately resolve to no declaration at all on one breakpoint while a sibling breakpoint resolves to a real value (see Submenu Position Reset above — Horizontal emits nothing, Vertical/Dropdown emit position:static), an unscoped bare desktop declaration would silently bleed onto a breakpoint that meant to defer to the global default. So every when entry's desktop declaration is wrapped in its own explicit @media (min-width:1200px){...} too, the same way build_widget_target_rule_set()'s selectorSuffix fields already had to (see that mechanism's own note in this codebase for the identical reasoning). This is why a Menu widget's compiled CSS shows @media (min-width:1200px){ ... display:none; } for a when-derived field instead of a bare, unscoped display:none; — functionally identical (1200px and up is the real desktop range), just explicitly scoped for correctness rather than implicitly bare-and-global.

What customLogic can't express

  • Repeated/list structures — generating one rule per item in a repeater field (e.g. an nth-child rule per item in a variable-length list). customLogic is one target → one resolved declaration per breakpoint; it has no loop construct.
  • Non-equality conditionsif only ever does field=value. No >, <, != — "if this number is greater than 3" isn't expressible without extending the grammar (not yet needed by any real case).
  • Derived values that aren't a field at all — e.g. "if this Menu has more than 5 items." Item count isn't a stored field with a token; customLogic only ever resolves tokens that map to real field values, never computed state like a tree's child count.

None of these apply to any current usage — they're the honest edges of the mechanism, not gaps anyone's hit yet.

Full worked example

Menu's complete customLogic array — 6 lookups (Panel/Toggle Display, Submenu accordion display/position) plus 1 formula (Submenu Nested Offset), the entire feature this mechanism replaced hand-written PHP/JS glue for:

"customLogic": [
    {
        "target": ".sanilwb-menu-panel:not(.is-open)",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "display:none;" },
            { "else": true, "declaration": "display:block;" }
        ]
    },
    {
        "target": ".sanilwb-menu-toggle",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "display:inline-flex;" },
            { "else": true, "declaration": "display:none;" }
        ]
    },
    {
        "target": ".sanilwb-menu-panel:where(*)",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "" },
            { "else": true, "declaration": "position:static;" }
        ]
    },
    {
        "target": ".sanilwb-submenu-toggle",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=vertical", "declaration": "display:inline-flex;" },
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "display:inline-flex;" },
            { "else": true, "declaration": "display:none;" }
        ]
    },
    {
        "target": ".sanilwb-submenu:not(.is-open)",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=vertical", "declaration": "display:none !important;" },
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "display:none !important;" },
            { "else": true, "declaration": "" }
        ]
    },
    {
        "target": ".sanilwb-submenu:where(*)",
        "responsive": true,
        "when": [
            { "if": "sanilwb_menu_layout=vertical", "declaration": "position:static;" },
            { "if": "sanilwb_menu_layout=dropdown", "declaration": "position:static;" },
            { "else": true, "declaration": "" }
        ]
    },
    {
        "target": ".sanilwb-submenu .sanilwb-submenu",
        "responsive": true,
        "declaration": "left: calc(%sanilwb_submenu_width% - %sanilwb_submenu_padding_left|0px% - %sanilwb_submenu_padding_right|0px%) !important;"
    }
]

attributeTargets — declaring addressable tags for Custom Attributes

An array of { target, label } entries naming which of this type's own rendered tags can receive a raw HTML attribute (data-*, aria-*, ...) via the Advanced tab's Attributes repeater:

"attributeTargets": [
    { "target": "", "label": "Wrapper" },
    { "target": ".sanilwb-icon", "label": "Icon" }
]

target: "" means the generic outer wrapper every widget/div always has; a real .classname names one of this type's own genuinely-rendered inner tags. An empty/missing attributeTargets array means this type hasn't opted into the feature at all — no Attributes section is shown for it. This key has no effect on CSS generation or on buildElementStyles()'s target allow-list — it's read only by the Attributes repeater UI (AttributeRow.jsx) and PHP's apply_custom_attributes()/build_custom_attributes_html(). Full mechanism, PHP application, and current per-widget coverage: Custom Attributes.


The cssProperty escape hatch

buildElementStyles()/build_widget_target_rule_set() recognize exactly two field names as bucket representatives, with no cssProperty needed:

  • sanilwb_bck_color — resolving this field's target decides where Background itself lands, and is also the fallback target for Layout/Size/Position/Gradient/Border/Box Shadow/Spacing whenever one of those sections doesn't declare its own target in useSharedSections (see that section above). Each of those seven still redirects independently of Background the moment its own useSharedSections entry gets a targetbuildAppearanceStyle()/build_appearance_declarations() compute all eight (Layout, Size, Position, Background, Gradient, Border, Box Shadow, Spacing) together in one internal pass, but each is then routed to its own resolved target separately.
  • sanilwb_color — resolving this field's target decides where Typography lands, with the same "a section's own declared target wins, this field's target is only the fallback" relationship — but Typography's fallback is independent of Background's; it never falls back to wherever Background resolved.

Any other field name needs cssProperty declared directly — this is the escape hatch that lets a genuinely widget-local field (Icon's Fill Color, Carousel's Arrow Color) become real CSS without buildElementStyles()/build_widget_target_rule_set() needing to know that field's name in advance. cssProperty is a camelCase React-style key (backgroundColor, fill, ...), converted to kebab-case for real CSS output on the PHP side. It can be an array of properties when one stored value needs to set more than one CSS property at once — Icon Size's ["width","height"] is the standing example.


presets — one field's value driving other fields' effective values

A field can declare presets, a plain object mapping its own possible stored values to a set of { otherFieldName: overrideValue, ... } overrides. When present, both buildElementStyles()/buildElementHoverStyles() (JS, via the internal resolveFieldPresets()) and SANILWB_Style_Field_Targets::resolve_field_presets() (PHP) check the presets-carrying field's current stored value before reading anything else in the field list, and — if that value matches a key in presets — merge that key's overrides into a working copy of the values/options object. Nothing is ever written back to the actual stored data, so a value with no matching key (including a value simply absent from presets, like "custom" below) is a no-op: the "overridden" fields just keep reading whatever's genuinely stored for them.

This is what lets one dropdown resolve into several other fields' effective CSS without any widget-type-specific code in either builder function — the mechanism only ever reads presets/name off the field's own JSON entry, never a specific field name or widget type. offcanvas.styles.json's Position field (sanilwb_offcanvas_position) is the first real usage: picking "Left" makes every Offset field behave as pinned to that edge.

{
  "name": "sanilwb_offcanvas_position",
  "type": "select",
  "options": [
    { "value": "left", "label": "Left" },
    { "value": "custom", "label": "Custom" }
  ],
  "presets": {
    "left": {
      "sanilwb_offset_top": "0px",
      "sanilwb_offset_right": "",
      "sanilwb_offset_bottom": "0px",
      "sanilwb_offset_left": "0px"
    }
  }
}

Notes:

  • Override values are used exactly as written. If the overridden field is responsive: true and the override should apply on every breakpoint, list its base name plus the __tablet/__mobile suffixed variants explicitly inside the same preset object — offcanvas.styles.json's real Position presets do this for all four Offset fields (12 keys per preset). PHP's version has no per-render concept of "current device" (it emits CSS for every breakpoint in one static pass per page load), so it reads the presets field's plain, unsuffixed value once and merges the preset's override object in as-is — same JSON, same behavior, no separate PHP-side responsive handling needed.
  • Pair with showIf on the overridden fields, gated on the presets field's own value, so their now-inert manual controls hide in the dialog while a preset is active. offcanvas.styles.json's Offset input-group does exactly this: "showIf": "sanilwb_offcanvas_position=custom".
  • This is a values-only mechanism — it has no effect on which fields the dialog renders (that's still showIf/omitFields) and no effect on stored data (the override only ever exists in a throwaway clone built at CSS-generation time).

Special field types (composite, not plain)

type Shape Notes
"section" { "type": "section", "label": "...", "position": N, "showIf": "..." } A pure marker, never a real field — starts a new block in ownFields. Skipped entirely by every CSS-building function.
"input-group" { "type": "input-group", "target": "...", "responsive": ..., "inputs": [ { "name": "...", "label": "...", "cssProperty": "..." }, ... ], "collapsibleInputs": [ ... ] } Several named sub-inputs sharing one dialog row (e.g. Margin's Top/Bottom side by side). target/responsive/important/units live on the parent object — individual inputs[] entries only declare their own name/cssProperty. The dialog renders it as one grouped control; only the CSS-building side (resolveCustomFieldDescriptors() JS / the input-group branch of get_custom_fields_for_target() PHP) expands it into one flat descriptor per sub-input. collapsibleInputs is a second row of sub-fields (e.g. Width's Min/Max), hidden until the group's own collapse toggle is clicked — same expansion rules apply.
"box-shadow" { "type": "box-shadow", "target": "...", "namePrefix": "...", "responsive": ..., "important": ... } No inputs[], no name — six sub-fields are implied from namePrefix: {namePrefix}_x/_y/_blur/_spread/_color/_inset. Both languages' collectors special-case this type directly (not via input-group expansion) and combine the six into one box-shadow: declaration via build_box_shadow_value()/buildBoxShadowValue(). Must not be expanded at field-assembly time — a documented past bug stripped namePrefix/target by trying to flatten it the same way input-group is flattened, which the CSS-building side then couldn't read back. Needs a real dialog renderer (BoxShadowField.jsx) to render as one grouped control.
"gradient-stops" { "type": "gradient-stops", "responsive": ... } Manages gradient-color-1-stop/gradient-color-2-stop internally — has no name of its own at all.

Hover fields

A resting-state field and its hover pair are two separate entries, sharing the same name convention (typically sanilwb_x_color / sanilwb_hover_x_color), the same target, and the same cssProperty — distinguished only by hoverState:

{
  "name": "sanilwb_fill_color",
  "type": "color",
  "target": "svg",
  "cssProperty": "fill",
  "hoverState": "normal"
},
{
  "name": "sanilwb_hover_fill_color",
  "type": "color",
  "target": "svg",
  "cssProperty": "fill",
  "hoverState": "hover"
}

buildElementStyles() (resting) explicitly skips any field tagged hoverState: "hover"; buildElementHoverStyles() (JS) and build_widget_target_hover_rule_set() (PHP) are the symmetrical counterparts that read only the hover-tagged half. This is what lets a Transitions repeater row's "Properties to Transition" picker discover which fields are hover-capable at all — see getHoverCapableFields() (JS)/get_hover_capable_fields() (PHP).

A field with no hoverState at all is treated as "normal" — the same default both languages agree on.


Target resolution details

  • No target set → the default root/outer wrapper ('').
  • Compound (comma-joined) target, e.g. .sanilwb-carousel-prev,.sanilwb-carousel-next — each comma-separated part is scoped independently by scope_compound_target() (PHP) / scopeCompoundTarget() (JS), producing .sanilwb-el-N .sanilwb-carousel-prev,.sanilwb-el-N .sanilwb-carousel-next{...}, not one ancestor prefix glued onto the whole joined string. A real bug once came from appending :hover naively to a joined compound string — it only ever landed on the last part. Never hand-concatenate a compound target; always go through the scoping helper.
  • widget_has_root_target( $type ) (PHP) / the equivalent JS check — whether anything this type declares is left on the default root target, excluding a bare Animations entry (which is always root, regardless — an Animation must still play on the outer wrapper even for a type like Button that redirects everything else away from it). Decides whether the wrapper gets a collect_element_css()/buildElementStyles().get('') pass at all — a type that redirects every section elsewhere (Button, Icon) must not also get a redundant, empty wrapper-level pass.
  • get_widget_style_targets( $type ) (PHP) / collectTargetsFromFields() (JS) — every distinct non-root target this type's own JSON declares, feeding the generic per-target dispatch loop in render_widgets()/render_div() that calls build_widget_target_rule_set()/build_widget_target_hover_rule_set() once per target. Adding a new custom target to a type's .styles.json is picked up here automatically — nothing else to register.

See also

  • Adding a New Widget Type — walkthrough for a brand-new widget's own .styles.json.
  • Targeting a Different Inner Element — worked example of redirecting a shared section vs. adding a genuinely new custom field.
  • Custom Attributes — the full attributeTargets mechanism, PHP application, and current per-widget coverage.
  • admin/assets/js/src/page-builder/config/widgets/menu/menu.styles.json — the real, complete customLogic array this section's examples are drawn from.
  • admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md — the terser, in-repo engineering reference this page expands on, including the "Open / Not Yet Decided" log of design questions and how each was resolved.