Tutorial: Targeting a Different Inner Element With a Style Field

By default, every Style-tab field — Layout, Size, Typography, Background, Gradient, Border, Box Shadow, Spacing, Position — applies to a widget's own outer wrapper. That's correct for most widgets. This tutorial covers what to do when it isn't: when one field, or a whole group of fields, needs to style a different element inside the widget's own markup instead.

It continues directly from Adding a New Widget Type — read that first if you haven't. Every example here assumes you already built the Callout widget from that tutorial: a box with a heading (<h4 class="sanilwb-callout__heading">), a body paragraph (<p class="sanilwb-callout__body">), and an accent_color border, with its whole Style tab declared in callout.styles.json (not callout.js's buildTabs() — see that tutorial's Step 1/Step 2 for why).

The concrete problem this tutorial solves: you want Background Color to apply to .sanilwb-callout__body — just the body paragraph — instead of the callout's outer wrapper.

For a systematic reference of every key this tutorial uses (target, cssProperty, hoverState, compound targets, etc.), see Style Field JSON Reference.


First, understand the bucket model

buildElementStyles() (admin/assets/js/src/shared/utils/buildElementStyles.js) resolves each shared Style-tab section — Layout, Size, Typography, Background, Gradient, Border, Box Shadow, Spacing, Position — to its own target independently, a real CSS class selector (e.g. .sanilwb-callout__body), not an abstract "inner"/"outer" label. A section redirects on its own via its own useSharedSections entry, without dragging any other section along — see socialIcons.styles.json for a widget that sends Layout/Position to one target and Background/Border/Box Shadow/Spacing to another.

What still moves together is the fields within one section — you can't redirect just Background Color while leaving Background Image on the wrapper, since buildAppearanceStyle() still computes Layout/Size/Position/Background/Gradient/Border/Box Shadow/Spacing together in one internal pass before splitAppearanceStyleBySection() routes each section's own slice to whichever target that section resolved to. A section left with no target of its own falls back to wherever Background resolved (sanilwb_bck_color's target), not to root automatically — so redirecting Background but saying nothing about Border still moves Border along with it, unless Border declares its own target too. Typography is the one exception: it falls back to sanilwb_color's own target instead, never to wherever Background went. (Image/Featured Image's own frame-sizing fields, once a separate sanilwb_img_* bucket, were retired — they reuse the shared Size section now; see the Page Builder app's own CLAUDE.md "Image Widget Size Fields" section.)

This is the fact that matters for the concrete problem above: wanting "just Background Color" to move without Background Image following it is not possible — moving it means the whole Background section moves. Decide which of the two paths below actually fits what you want before writing any code.

You want... Use
Only Background Color (or one specific property) on a different element, nothing else about the widget's Background/Layout/Size/Spacing/Position moves Path A — new local field
A whole shared section (Background, Border, Box Shadow, Gradient, Spacing, Layout, Size, Position, or Typography — pick just the ones you need) to apply to a different element Path B — redirect that section (or sections) to a new target

Both paths live entirely inside callout.styles.json (the file Adding a New Widget Type has you create) — neither touches callout.js's buildTabs() or any shared config file, unlike an older version of this mechanism that predates the JSON-driven Style tab system (see WIDGET-ARCHITECTURE.md if you're curious what that looked like; it no longer applies to any currently-migrated widget).


Path A — Add a new widget-local field

This is the simpler path, and the right one if you specifically want "just this one property" on the inner element, with everything else about the shared scheme untouched. It doesn't touch buildElementStyles(), ALLOWED_STYLE_TARGETS, or any shared config file at all — it works exactly the way Callout's own accent_color field already does: a plain widget-local field, read directly by your own render code, never routed through the shared box/typography buckets at all.

1. Add the field to callout.styles.json's ownFields, alongside sanilwb_accent_color — no target, no cssProperty:

{
    "name": "sanilwb_body_bck_color",
    "type": "color",
    "label": "Body Background Color",
    "responsive": false
}

2. Read and apply it in renderCalloutWidget() (renderWidgetPreview.js) — directly on the <p class="sanilwb-callout__body"> tag:

function renderCalloutWidget( widget ) {
    const values = widget.values ?? {};

    const heading = escHtml( values.sanilwb_heading ?? '' );
    const body = escHtml( values.sanilwb_body ?? '' );
    const accentColor = values.sanilwb_accent_color || '#e5e7eb';
    const bodyBackgroundColor = values.sanilwb_body_bck_color || '';
    const bodyStyleAttr = bodyBackgroundColor
        ? ` style="background-color:${ escAttr( bodyBackgroundColor ) };"`
        : '';

    return `<div class="sanilwb-callout" style="border-left:4px solid ${ escAttr( accentColor ) };" data-uid="${ escAttr( widget.uid ) }">` +
        ( heading ? `<h4 class="sanilwb-callout__heading">${ heading }</h4>` : '' ) +
        ( body ? `<p class="sanilwb-callout__body"${ bodyStyleAttr }>${ body }</p>` : '' ) +
        `</div>`;
}

3. Read and apply it in SANILWB_Callout_Shortcode::render() (includes/shortcodes/class-sanilwb-callout-shortcode.php):

$args = shortcode_atts( array(
    'heading'        => 'Important Notice',
    'body'           => '',
    'accent_color'   => '',
    'body_bck_color' => '',
), $sanitized_attributes );

$accent_color = sanitize_hex_color( $args['accent_color'] );
$border_color = $accent_color ?: '#e5e7eb';
$border_style = 'border-left: 4px solid ' . $border_color . ';';

$body_bck_color = sanitize_hex_color( $args['body_bck_color'] );
$body_style = $body_bck_color ? ' style="background-color:' . esc_attr( $body_bck_color ) . ';"' : '';

Then use $body_style on the <p> tag in the same template:

<?php if ( $args['body'] ) : ?>
    <p class="sanilwb-callout__body"<?php echo $body_style; ?>>
        <?php echo esc_html( $args['body'] ); ?>
    </p>
<?php endif; ?>

That's the entire path. No cssProperty on the field, no index.js change, no PHP core-renderer change — because this field was never part of the shared scheme to begin with, there's nothing shared to redirect. get_widget_style_targets() (PHP) never picks up a targetless ownFields entry, and neither does the JS box/typography bucket resolution — it exists only for the dialog to render the field and for your own code to read its raw value.

One gotcha worth knowing, found by a real bug: widget_has_root_target() (PHP) treats any ownFields entry with no target (that isn't a type: "section" marker) as evidence the widget still uses the default root wrapper — even a bespoke field like this one that no generic collector ever reads. For Callout that's harmless, since its shared sections already keep root active regardless. But if you ever build a widget that redirects every shared section away from root the way Button/Icon do (to their own .sanilwb-button/.sanilwb-icon class) and it also has a Path-A-style bespoke field like this, that field needs the same real target class too (even with no cssProperty) — otherwise widget_has_root_target() incorrectly returns true and leaks a redundant, wrong CSS pass onto the wrapper. See WIDGET-ARCHITECTURE.md's "Root-level CSS is conditional, not automatic" section for the full story, including Carousel Nav's narrower mixed case (Size/Position genuinely stay on root, everything else redirects).

When this stops being the right choice: if you find yourself doing this for several properties (background AND border AND box-shadow, say), you're really re-inventing the shared Appearance section field-by-field. At that point Path B is less code overall, because it reuses the existing shared fields and their existing hover/responsive handling instead of hand-rolling each one.


Path B — Redirect a whole shared section to a new target

This is the path documented conceptually in WIDGET-ARCHITECTURE.md's "The JSON-Driven Style Tab System" section, and it's what Button and Icon already do — redirecting the whole scheme to their own real class (.sanilwb-button, .sanilwb-icon), a literal class you invent for your own widget, not a special reusable name. The only actually-special, reusable target is Image/Featured Image's own .sanilwb-widget frame target (a genuinely different mechanism, gated by the older imageFrame bucket rather than a .styles.json redirect — see "The old bucket mechanism" in WIDGET-ARCHITECTURE.md). Registering a new target for your own widget — which is what .sanilwb-callout__body is — needs the steps below. Do this only when you've confirmed Path A doesn't fit (see the table above).

Doing this means whichever shared sections you redirect lose their box-model styling ability on Callout's outer wrapper — those sections move to .sanilwb-callout__body instead. If that's not what you want for every section, redirect only the ones you actually mean to move (e.g. just "Background"/"Gradient", leaving "Border"/"Box Shadow"/"Layout"/"Size"/"Spacing"/"Position" on the default root) rather than all of them.

1. Declare the redirect directly in callout.styles.json — no separate constants file, no shared config edit

Unlike an older version of this mechanism (a styleTargets.js constant + a styleFieldTargets.json bucket-name edit + a BUCKET_TARGET_TO_JS_TARGET map entry in index.js — none of that exists anymore for a migrated widget), target today is just the real, literal CSS class string, written directly on the section entry in the widget's own .styles.json:

{
    "useSharedSections": [
        "Layout",
        "Size",
        "Typography",
        { "name": "Background", "target": ".sanilwb-callout__body" },
        { "name": "Gradient", "target": ".sanilwb-callout__body" },
        { "name": "Border", "target": ".sanilwb-callout__body" },
        { "name": "Box Shadow", "target": ".sanilwb-callout__body" },
        { "name": "Spacing", "target": ".sanilwb-callout__body" },
        { "name": "Position", "target": ".sanilwb-callout__body" }
    ],
    "ownFields": [
        { "type": "section", "label": "Colors", "position": 15 },
        {
            "name": "sanilwb_accent_color",
            "type": "color",
            "label": "Accent Color",
            "responsive": false
        }
    ]
}

This one file is read identically by both languages — there's no abstract bucket-target name to keep in sync between them anymore, since the editor-preview markup and the real frontend markup both use this exact same class already (.sanilwb-callout__body, on both the JS-rendered <p> and PHP's own <p>).

2. PHP needs nothing else — this part is fully automatic today

render_widgets() (includes/class-sanilwb-frontend-renderer.php) already loops over SANILWB_Style_Field_Targets::get_widget_style_targets('callout') — which now includes .sanilwb-callout__body — and calls both SANILWB_CSS_Compiler::build_widget_target_rule_set() (resting-state) and build_widget_target_hover_rule_set() (hover — see this tutorial's own closing "Hover" section) for it automatically. That function already knows to run the full box-model declaration builders (build_appearance_declarations(), build_layout_declarations(), etc.) for a target reached via a whole shared-section redirect (SANILWB_Style_Field_Targets::is_shared_section_target() returns true here) — this is the exact thing an older version of this tutorial had you hand-write a collect_callout_body_css() method for. You don't write one anymore. Nothing changes in SANILWB_Callout_Shortcode::render() either — its output already has <p class="sanilwb-callout__body">, which is all this selector needs to exist. (Hand-typed [sanilwb ...] shortcode usage outside Page Builder isn't a supported rendering path — every real render goes through render_widgets(), above, or the AJAX canvas-preview endpoint, covered by the editor's own live-CSS mechanisms instead.)

3. JS needs manual work today — a real, known gap, not yet automatic

This is the one place Path B isn't yet as simple as PHP. admin/assets/js/src/page-builder/utils/injectTargetStyles.js exists specifically to apply a redirected target's computed style onto a rendered widget's HTML generically — merging a style into whichever tag carries the matching class, after the widget's own render function builds its HTML exactly as before. As of this writing it is not called from any real render path: renderWidgetPreview()'s dispatcher never calls it. Every currently-migrated JS-rendered widget with a custom target (Icon, Button) instead calls buildTargetStyleAttr() explicitly inside its own render*Widget() function — a widget-aware call site, not a generic post-processing injector (CanvasWidgets.jsx itself only still resolves the abstract OWN_TAG_TARGET marker by hand, and only for the four still-unmigrated ambient placeholder types — Excerpt/Archive Title/Post Title/Term Name — not Button/Icon/Carousel Nav, which use real classes now). injectTargetStyles.js has its own passing unit test suite (tests/js/unit/page-builder/injectTargetStyles.test.js) but no end-to-end exercise through a real widget yet.

Don't rely on it doing this for you today. Resolve and apply the style directly in your own render function instead — the same way renderHeadingWidget() already resolves the own-tag target by hand:

function renderCalloutWidget( widget, device ) {
    const values = widget.values ?? {};

    const bodyStyle = buildElementStyles(
        getWidgetStyleFields( 'callout' ), values, device, ALLOWED_STYLE_TARGETS.callout
    ).get( '.sanilwb-callout__body' ) ?? {};
    const bodyStyleAttr = styleObjectToAttrString( bodyStyle );

    const heading = escHtml( values.sanilwb_heading ?? '' );
    const body = escHtml( values.sanilwb_body ?? '' );
    const accentColor = values.sanilwb_accent_color || '#e5e7eb';

    return `<div class="sanilwb-callout" style="border-left:4px solid ${ escAttr( accentColor ) };" data-uid="${ escAttr( widget.uid ) }">` +
        ( heading ? `<h4 class="sanilwb-callout__heading">${ heading }</h4>` : '' ) +
        ( body ? `<p class="sanilwb-callout__body"${ bodyStyleAttr }>${ body }</p>` : '' ) +
        `</div>`;
}

ALLOWED_STYLE_TARGETS.callout and getWidgetStyleFields('callout') are both derived automatically from callout.styles.json at module load — nothing to register by hand for either of those. renderCalloutWidget() now needs device (it didn't before, since it had nothing responsive to resolve) — update its case in renderWidgetPreview()'s switch to pass it: return renderCalloutWidget( widget, opts.device );.

Alternative, if you'd rather close the gap than work around it: call injectTargetStyles(html, buildElementStyles(...)) as a post-processing step on your render function's own returned HTML, instead of resolving .get('.sanilwb-callout__body') by hand. It should work — it's a pure, already-tested function, just never wired into a real render path — but the manual version above is the proven, currently-used pattern (it's exactly what OWN_TAG_TARGET/IMAGE_FRAME_TARGET already do), so it's the safer default until injectTargetStyles() has a real widget behind it.

Why the PHP side still has one bespoke exception

collect_image_frame_css() (the image-frame target's own PHP path, kept separate from build_widget_target_rule_set()) does more than swap a selector — it also emits Image/Featured Image's forced img{width:100%;height:100%;object-fit:cover} fill rule, gated on whether a Size field was actually set. That's real, per-target behavior no generic dispatcher could guess at, which is why the image frame keeps its own bespoke function rather than being expressed as a plain .styles.json target redirect. Your own new target doesn't need anything like this unless it has a similarly bespoke rendering requirement.


Summary

Path A: new local field Path B: redirect a shared section
Files touched 1 (callout.styles.json) 1 (callout.styles.json) for the redirect itself; both render functions still need updating either way
PHP work beyond the JSON file None None — build_widget_target_rule_set() handles it automatically
JS work beyond the JSON file Read the raw field value directly in your render function Resolve buildElementStyles(...).get(target) and apply it yourself — injectTargetStyles() exists but isn't wired up yet (see above)
Responsive breakpoints Free either way (getResponsiveValue()/PHP suffix reads handle it) Free either way
Hover Free, if you add a paired hoverState: "hover" field yourself (see this tutorial's closing "Hover" section) Free automatically — Background Hover Color/Text Color hover are already part of the redirected section
Other box-model fields (Layout/Size/Spacing/Position) affected? No — untouched Only the sections you actually redirect
Right for One or two specific properties The whole visual identity of an inner element

For AJAX-rendered widgets (template_reference, shortcodes — anything that returns null from renderWidgetPreview()), neither path applies as written here — there is no live DOM node to attach an inline style to. Post Content is the reference example of a styled AJAX-rendered widget: in the editor canvas, buildTargetRuleSetCss()/buildCustomFieldDeclarationText() (admin/assets/js/src/page-builder/utils/buildCanvasLiveCss.js) build real CSS text into the canvas's single shared live-CSS <style> tag, scoped to [data-uid] {target} — entirely in JS, independent of the widget's own AJAX-fetched HTML. On the real, saved page, PHP's own mirror of the same targets — SANILWB_CSS_Compiler::build_widget_target_rule_set(), the exact same generic collector Path B already uses — is called from render_widgets()'s normal per-target loop, not from SANILWB_Post_Content_Shortcode::render() itself (that shortcode has no CSS-building code of its own). Both sides are still driven entirely by postContent.styles.json's own targets (p, h1h6, img), no widget-specific CSS-building code either side. If your new widget is both AJAX-rendered and styled, follow Post Content's shape rather than inventing a new one.


Hover comes free with either path today

Hover is driven by the exact same .styles.json your resting-state fields already live in, via a generic hover collector symmetrical to the resting-state one — see WIDGET-ARCHITECTURE.md's "The generic hover collectors" section for the full mechanism. Nothing below needs a widget-specific function or a manual selector-map entry, for either path.

Path B (whole-section redirect): Background Hover Color and Text Color hover already exist as part of the shared Appearance/Typography sections. Once you redirect a whole section to .sanilwb-callout__body, its hover pairing follows automatically — PHP's build_widget_target_hover_rule_set() calls build_appearance_hover_declarations()/build_typography_hover_declarations() for any target reached via a shared-section redirect; JS's appendWidgetLiveCss() builds the same rule with a dynamically-resolved selector via buildBackgroundHoverRule()/buildTextColorHoverRule(). Nothing to add beyond the redirect itself.

Path A (a genuinely new custom field): give it a paired hover field using the same hoverState tag Icon's Icon Color uses — a hoverState: "normal" field and a hoverState: "hover" field, same name prefix, same target:

{
    "name": "sanilwb_body_bck_color",
    "type": "color",
    "label": "Body Background Color",
    "responsive": false,
    "target": ".sanilwb-callout__body",
    "cssProperty": "backgroundColor",
    "hoverState": "normal"
},
{
    "name": "sanilwb_body_hover_bck_color",
    "type": "color",
    "label": "Body Background Color",
    "responsive": false,
    "target": ".sanilwb-callout__body",
    "cssProperty": "backgroundColor",
    "hoverState": "hover"
}

SANILWB_Style_Field_Targets::get_custom_fields_for_target() (PHP) and buildElementHoverStyles() (JS) both filter on hoverState independently of whether the target came from a shared-section redirect — so this works for Path A exactly the same way it does for Video's Play Button or Icon's own Icon Color, no shared-section involvement needed at all.

Compound (multi-element) targets only: if your target is ever a comma-separated compound selector (unlikely for a single new element like Callout's body, but see Carousel Nav's two buttons), make sure any code building that selector goes through scope_compound_target()/scopeCompoundTarget() — appending an ancestor prefix or :hover naively to a comma-joined string only affects the first/last part, a real bug this exact case exposed. See WIDGET-ARCHITECTURE.md's "The generic hover collectors" section.