Skip to content

Canvas Builder — Extending with Custom Plugins

Canvas Builder is data-driven and designed to be extended. Other modules can add new style options, style groups, entirely new style types, and custom form elements — all without touching canvas_builder itself.


Extension points at a glance

What you want to do How
Add a new select-based style reusing an existing option table hook_canvas_builder_styles() only (§1)
Ship static style/group definitions with no PHP at all {provider}.canvas_builder_styles.yml at your module/theme root (§1a)
Add a new select-based style with its own admin-configurable options Own config + own schema in a custom StyleInterface type, or contribute the key back to Canvas Builder (§2)
Add a new media-picker style with a fixed bundle list hook_canvas_builder_styles() only (media_bundles, §5)
Group multiple selects under one accordion type: group with children in YAML (worked example)
Group selects + media behind color-tab switcher type: group in YAML
Mutate or remove a built-in style hook_canvas_builder_styles_alter()
Build a fully custom form element / widget Custom StyleInterface service tagged canvas_builder.style
Add a new prop-group tab (e.g. a peer to Content/Style/Advanced) hook_canvas_builder_prop_groups_alter() + a data-canvas-prop-group panel
Recommend a slot's child component (one-click "add" in the editor) x-canvas-builder: slots: in the component's *.component.yml

1. Adding a style group via hook_canvas_builder_styles()

The simplest and most common extension: implement hook_canvas_builder_styles() in your module's .module file. CanvasBuilderManager::getDefinitions() merges the returned array on top of canvas_builder.plugins.yml via array_replace() for each implementing module in turn (later modules win on matching keys; new keys are appended), then applies hook_canvas_builder_styles_alter() (src/Service/CanvasBuilderManager.php:403-428). No config object or schema is involved — the return value is a plain PHP array, never persisted config.

// mymodule.module

/**
 * Implements hook_canvas_builder_styles().
 */
function mymodule_canvas_builder_styles(): array {
  return [
    'effects' => [
      'enabled' => TRUE,
      'type'        => 'group',
      'label'       => 'Effects',
      'description' => 'Apply visual effect utility classes.',
      'form_group'  => 'style',
      'weight'      => 20,       // Positive → renders below built-in groups.
      'icon'        => 'spacing',// Key from \Drupal\canvas_builder\Icons.
      'open'        => FALSE,
      'libraries'   => ['canvas_builder/plugin.select'],
      'children' => [
        'shadow' => [
          'label'        => 'Shadow',
          'settings_key' => 'shadow_classes',
        ],
        'border_radius' => [
          'label'        => 'Rounded corners',
          'settings_key' => 'border_radius_classes',
        ],
      ],
    ],
  ];
}

Weight band: Canvas Builder's own groups occupy the reserved negative band (-100 to -10). Use positive weights so your groups sort below the built-ins. Within your own module you can use any spacing you like.

shadow_classes / border_radius_classes above are new settings_keys — they resolve to no options until you read §2, which covers making a new key render choices (or reusing one that already exists).


1a. Style definitions via {provider}.canvas_builder_styles.yml

For definitions that need no PHP — no runtime conditionals, no computed values — skip the hook: any enabled module or theme can ship a {provider}.canvas_builder_styles.yml file at its extension root ({provider} = the module/theme machine name, e.g. mymodule.canvas_builder_styles.yml). Same schema as canvas_builder.plugins.yml — see the schema reference below. modules/canvas_builder_animate/ ships a complete, real-world example this way — see the worked example below.

Themes work exactly like modules — every installed theme is scanned, not just the active one. StyleDefinitionDiscovery builds its file list from ThemeHandlerInterface::listInfo() (src/Discovery/StyleDefinitionDiscovery.php:66-68), so a base theme and a subtheme can each ship their own file, and both apply (theme-list order).

StyleDefinitionDiscovery merges these in order: canvas_builder's own canvas_builder.plugins.yml, then every enabled module (enable order; canvas_builder itself excluded), then every installed theme (theme-list order) — each later file can extend or override an earlier one. This merge is deep; hook_canvas_builder_styles() (§1) is shallow — returning ['spacing' => ['label' => 'X']] from a hook replaces the entire spacing definition (dropping its weight/icon/children), whereas a YAML overlay touching the same key recurses and leaves untouched siblings alone. Prefer a YAML overlay for a single nested change; reach for the hook when a value needs to be computed at request time. hook_canvas_builder_styles_alter() still runs last, on top of either source.

Merge behavior and escape hatches

Per key in the overlay file:

  • Two maps at the same key recurse and merge. children and tabs are id-keyed maps ({shadow: {...}, border_radius: {...}}), not sequential lists, so overriding one sub-key (or nesting remove: true inside one) leaves its siblings untouched — it does not replace the whole group.
  • Two sequential lists at the same key (e.g. a background tab's default_bundles: [image]) wholesale replace — never index-merged.
  • libraries is the one exception: lists there union instead of replacing.
  • options always wholesale-replaces regardless of shape (reserved, opaque).

Two escape hatches, recognised at any key, any depth:

# mymodule.canvas_builder_styles.yml
# Delete an inherited sub-key.
effects:
  border_radius:
    remove: true
# mymodule.canvas_builder_styles.yml
# Force a wholesale replace on a key that would otherwise recurse — or, on
# a genuine sequential list (already the default), make that intent explicit.
background:
  tabs:
    video:
      default_bundles:
        replace: ['video', 'video_external']

libraries needs no escape hatch — it unions automatically:

# mymodule.canvas_builder_styles.yml
effects:
  libraries: ['mymodule/plugin.effects']
  # Merged with canvas_builder's own libraries for this key, deduplicated.

Cached on cache.discovery under the config:core.extension tag: installing or enabling a provider invalidates it automatically; editing an existing file's content in place needs a manual drush cr.

A complete worked example

modules/canvas_builder_animate/canvas_builder_animate.canvas_builder_styles.yml ships an entire style group this way — no hook_canvas_builder_styles() implementation anywhere in the submodule:

# canvas_builder_animate.canvas_builder_styles.yml
animation:
  enabled: true
  type: 'animation'                 # dispatches to a custom StyleInterface
                                     # service (see §6) registered in
                                     # canvas_builder_animate.services.yml
  label: 'Animation'
  description: 'Animate this component  or each of its children  on scroll, on load or on click.'
  form_group: 'style'
  weight: -70                       # reserved negative band — see §1's
                                     # weight-band note
  icon: 'animation'
  open: false
  libraries:
    - canvas_builder_animate/panel

It's a single top-level definition with no children/tabs: type: animation hands the whole accordion to AnimationStyle::build() rather than fanning out through the built-in select/media/box_model types the way type: group does — a custom type can render arbitrarily complex form structure from one YAML entry. Note also that form_group stays 'style' here — moving a definition to a different prop-group tab (§11) without also updating whatever client code assumes it lives under the Style tab's AJAX flow is a footgun this submodule's own source comments call out explicitly.

A group with a child select — the common case

The animation example above uses a custom type because it needs bespoke form markup. Most extensions don't — they add one or more simple controls (a select, a media picker) and want them grouped under a single labelled, collapsible accordion the way the built-in Spacing and Typography groups are. That's type: group + children, and it needs no PHP at all:

# mytheme.canvas_builder_styles.yml
cta_callout_plugin:
  enabled: true
  type: 'group'
  label: 'CTA Callout Plugin'
  description: 'Apply a callout treatment to this component.'
  form_group: 'style'
  weight: 10                      # positive — outside canvas_builder's own
                                   # reserved negative band, see §1's note
  icon: 'settings'
  open: false
  children:
    cta_callout:
      label: 'CTA Callout style'
      description: 'Apply a callout treatment to this component.'
      style: 'select'
      settings_key: 'cta_callout_classes'

This renders exactly like Spacing/Typography: a <details> accordion titled "CTA Callout Plugin" containing one select field. GroupStyle::buildChildren() keys each child's storage by its own child id (cta_callout), never prefixed by the group id — so the persisted key is stable whether this leaf is nested in a group or declared bare at the top level, and you can freely move it between groups later without a data migration.

The select's options live in config at settings_key: 'cta_callout_classes' (§2/§3). To prototype before wiring real config, seed it directly:

// One-off, e.g. via `drush php:eval` while developing:
\Drupal::configFactory()->getEditable('canvas_builder.settings')
  ->set('cta_callout_classes', [
    ['label' => 'Info', 'classes' => ['mobile' => 'cta-callout--info']],
    ['label' => 'Warning', 'classes' => ['mobile' => 'cta-callout--warning']],
    ['label' => 'Danger', 'classes' => ['mobile' => 'cta-callout--danger']],
  ])
  ->save();

Read §2 before shipping this for real. cta_callout_classes has no matching entry in canvas_builder's own config/schema/canvas_builder.schema.yml — setting it works and the select renders correctly, but Drupal's config schema validation logs a "missing schema" warning. Harmless for a prototype; for anything you intend to ship, either point settings_key at one of canvas_builder's existing keys (padding_classes, bg_color_classes, …), or implement a custom style type (§6) that reads from your own module's config object instead.

Gotcha: automated tools can silently fail to persist a value

Canvas overrides every style control's value setter to detect programmatic changes and re-fires them as a change event so its own state stays in sync (main CLAUDE.md §3a) — every select/media leaf this module renders carries data-canvas-plugin, and the defense discards any change that isn't a genuine, trusted browser event. If you smoke-test a new definition by driving a headless browser and setting the <select>'s value directly (e.g. Playwright's selectOption(), or any element.value = x; dispatchEvent(...) script), the value updates on screen but the AJAX request that persists it never fires — it looks like it worked and silently doesn't save. Drive the control the way a real user would instead — click it, then use arrow keys and Enter/Tab — so the browser itself generates the change event; confirm persistence by reloading the component form (or reading the draft entity's canvas_builder_styles field directly) rather than trusting what the control shows immediately after the interaction.

Per-viewport values: self-scoping vs. mutually-exclusive

A per-option classes map ({mobile: 'p-0', desktop: 'p-lg-0'}) can behave two different ways at render, and picking the wrong one either loses the responsive effect or renders every viewport's class at once:

Self-scoping (default) Mutually-exclusive — viewport_scoped: true
Example Bootstrap p-0/p-lg-0, Tailwind p-0 lg:p-8 — the class itself carries the breakpoint via its own @media rule CTA Callout's cta-callout--info/--warning/--danger — nothing in the class name tells CSS which breakpoint it's "for"
Render Every stored viewport's class unions onto the wrapper; the framework's own media queries decide which one visually wins Exactly one class resolved and swapped per breakpoint, mobile-first
YAML No flag needed — this is the default Add viewport_scoped: true next to settings_key

Self-scoping needs no core involvement: give an option's classes map an entry per viewport instead of just the base one —

->set('my_spacing_classes', [
  ['label' => 'No padding', 'classes' => [
    'mobile' => 'p-0', 'tablet' => 'p-md-0', 'desktop' => 'p-lg-0',
  ]],
])

— and resolveOptionViewportClass() / flattenViewportClasses() already union every viewport's entry onto the wrapper. It's also a first-class admin UI feature, not config-only: CanvasBuilderSettingsFormBase::buildClassOptionTable() already renders one textfield per enabled viewport per option row, generically, for any settings_key-bearing definition.

viewport_scoped: true changes only how the value is rendered — persistence, State storage, and the admin options table all already work per-viewport generically regardless of the flag:

children:
  cta_callout:
    label: 'CTA Callout style'
    style: 'select'
    settings_key: 'cta_callout_classes'
    viewport_scoped: true

CanvasBuilderTrustedCallbacks resolves and emits exactly one class per breakpoint — the base-viewport value as the no-JS default, swapped in by canvas_builder/viewport_class (a matchMedia-driven front-end runtime with no @media in generated CSS, per CLAUDE.md §6) on every breakpoint crossing on the published page — and select.js mirrors the same swap-not-union behavior in the live editor preview.

Definition schema reference

Every key below is valid in both a hook_canvas_builder_styles() return value (§1) and a *.canvas_builder_styles.yml file (§1a) — it's the same schema either way. Keys marked "leaf/tab only" are only meaningful inside a children or tabs entry, not at a definition's top level.

Key Type Notes
enabled bool Gates whether the definition is ever built. Defaults to FALSE if omitted — a definition with no enabled: true is silently inert.
type string, required Dispatches to a StyleInterface::getType() handler. Built in: select, media, group. Extend with your own via §6 (e.g. animation, or the toggle example in §6).
label string Falls back to ucfirst($id) in several UI paths if omitted — set it explicitly.
description string Optional help text shown under the label.
form_group string Which prop-group tab (§11) the definition renders under — typically style or advanced.
weight int Sort order. Canvas Builder's own groups reserve -100..-10 — see §1's weight-band note.
icon string Key into \Drupal\canvas_builder\Icons.
open bool Whether an accordion group starts expanded.
libraries string | list\<string> Asset libraries attached whenever this definition (or an enabled leaf under it) is active — see §8. YAML overlays union this list rather than replacing it (see Merge behavior above).
children array\<string, array> (group type only) Id-keyed map of sub-fields rendered as a stacked accordion (e.g. spacing.children.padding).
tabs array\<string, array> (group type only) Id-keyed map of sub-fields rendered as a tab switcher (e.g. background.tabs.color/image/video).
style string (leaf/tab only) Picks the sub-handler a group's GroupStyle dispatches a leaf to (select, media, box_model, overlay) — distinct from the top-level type key, which only a group definition itself uses.
settings_key string (leaf/tab only) Config key (in canvas_builder.settings) holding this leaf's option rows — resolved via CanvasBuilderManager::getConfiguredResponsiveOptions(). See §2 before inventing a new one.
settings_label string (leaf/tab only) Overrides the settings-form field label derived from settings_key — does not affect the editor-facing label.
bundles_config_key string (leaf/tab only, media/overlay) Config key holding the allowed media-bundle list, resolved via getConfiguredBundles().
default_bundles list\<string> (leaf/tab only) Fallback bundles when bundles_config_key is unset/empty; defaults to ['image'] if omitted entirely. A YAML overlay replaces this list wholesale, never index-merges (see Merge behavior above).
media_bundles list\<string> (leaf/tab only) Inline, non-admin-configurable bundle list — the alternative to bundles_config_key used in §5's example.
overlay bool (tab only, media) Exposes a color/opacity/blend overlay sub-control on a media tab, gated by an auto-derived toggle key {plugin}_{tab}_overlay.
swatch bool (leaf/tab only, select) Renders the options as a color-swatch picker instead of a dropdown/radio list.
swatch_type string (leaf/tab only, select) Swatch rendering mode, e.g. background, text.
icon_group string (leaf/tab only, select) Icon set used for the option list, e.g. align.
admin_note string (HTML) (leaf/tab only) Optional caveat appended to the settings-form table caption for this option table.
admin_note_if_class string (leaf/tab only) Makes admin_note conditional — it only shows if a configured option row's class contains this token.
viewport_scoped bool (leaf/tab only, select) Renders exactly one resolved class per breakpoint instead of the union every other key gets — for options that are mutually-exclusive variants, not self-scoping framework classes. See the table above.
options Reserved/opaque; always wholesale-replaced by a YAML overlay regardless of shape. Not otherwise consumed anywhere in this module today.
Two related-but-distinct concepts, easy to confuse with the above

kind is not a schema key. It never appears in a *.canvas_builder_styles.yml file or a hook's return value. It's a runtime render-element property ('#canvas_style' => ['kind' => 'class'|'media', 'key' => ...]) a StyleInterface::build() implementation stamps onto form elements so StyleManager::persistElement() knows how to persist them — see §7.

layout_maps is not the same mechanism. canvas_builder.settings:layout_maps looks similarly named but is unrelated: it's a config value inside a framework profile snapshot (§10), managed by FrameworkProfileManager and loaded from fixed-path files under src/Service/ProfileData/ that canvas_builder alone owns — there is no {provider}.layout_maps.yml (or similar) discovery convention for other modules/themes to hook into.


2. Config schema for new settings_key values

SelectStyle and MediaStyle resolve settings_key / bundles_config_key through CanvasBuilderManager::getConfiguredResponsiveOptions() and getConfiguredBundles() — both hardcoded to read the canvas_builder.settings config object (src/Service/CanvasBuilderManager.php:833, :1129). There is no seam today that lets a settings_key resolve against a different config object — so an admin-configurable option table for these two built-in types can only live inside canvas_builder.settings.

Why you can't just add a schema override for a new key

A third-party module shipping its own config/schema/mymodule.schema.yml that re-declares canvas_builder.settings with extra mapping entries looks like a natural way to "extend" it — it doesn't work. Drupal's config schema discovery (ConfigSchemaDiscovery::getDefinitions(), core/lib/Drupal/Core/Config/Schema/ConfigSchemaDiscovery.php:36-44) reads every *.schema.yml across all enabled modules and keys the result by type name with a flat overwrite ($definitions[$type] = $definition;), not a deep merge. Two modules each declaring a top-level canvas_builder.settings: type don't get their mapping entries combined — whichever file is discovered last silently replaces the other entirely for that type name. Whichever side loses ends up with config values that have no matching schema mapping, and Drupal's schema validation — run automatically under KernelTestBase/BrowserTestBase via ConfigSchemaChecker, and by tools such as config_inspector — throws SchemaIncompleteException for those values.

What actually works today:

  1. Reuse an existing settings_key. If one of the option tables already in canvas_builder.schema.yml fits your case (padding_classes, margin_classes, gap_classes, bg_color_classes, text_color_classes, …), point your hook_canvas_builder_styles() definition's settings_key at it directly. No schema change, no config change, nothing to install — this is the common case and the one shown in §1.
  2. Own your config entirely. If you need genuinely new, admin-editable options, don't use the built-in select/media types for them at all. Implement a custom style type (§6) whose build() reads options from your own config object — \Drupal::config('mymodule.settings')->get('shadow_classes') — described by your own mymodule.schema.yml for a config object named mymodule.settings (not canvas_builder.settings). This never touches Canvas Builder's config, so there is no collision.
  3. Contribute the key back to Canvas Builder itself, if it's generally useful — see §10. That works because it's canvas_builder editing its own, singly-owned schema file in the same commit as the feature — not a cross-module merge. The shape to add under canvas_builder.settings > mapping in canvas_builder's own config/schema/canvas_builder.schema.yml is:
# canvas_builder/config/schema/canvas_builder.schema.yml
canvas_builder.settings:
  type: config_object
  mapping:
    # ...existing keys...
    shadow_classes:
      type: sequence
      label: 'Shadow class options'
      sequence:
        type: canvas_builder.class_option

canvas_builder.class_option already exists — reuse it, don't redeclare it:

# (Already defined in canvas_builder/config/schema/canvas_builder.schema.yml)
canvas_builder.class_option:
  type: mapping
  mapping:
    label:   { type: label }
    classes:                   # per-viewport map
      type: sequence
      sequence:
        type: string

3. Install config with default option values

This only applies to the two paths above that legitimately own a config key: contributing a settings_key into Canvas Builder itself (§2, path 3 / §10), or a custom style type backed by your module's own config object (§2, path 2), where the install config lives in your own module and is never merged into canvas_builder.settings.

It is not a way for an unrelated module to seed values into canvas_builder.settings. By the time a module that depends on canvas_builder gets installed, canvas_builder (the dependency) has already created its own canvas_builder.settings config. Drupal's config installer skips creating config that already exists — it does not merge a second module's config/install/canvas_builder.settings.yml into it, so such a file would be silently ignored.

When you do own the key (contributing to Canvas Builder per §10), ship default class options in canvas_builder's own config/install/canvas_builder.settings.yml so the settings form is pre-populated on first enable:

# canvas_builder/config/install/canvas_builder.settings.yml
shadow_classes:
  - label: 'None'
    classes:
      mobile: ''
  - label: 'Small'
    classes:
      mobile: shadow-sm
  - label: 'Medium'
    classes:
      mobile: shadow
  - label: 'Large'
    classes:
      mobile: shadow-lg

border_radius_classes:
  - label: 'None'
    classes:
      mobile: rounded-0
  - label: 'Default'
    classes:
      mobile: rounded
  - label: 'Large'
    classes:
      mobile: rounded-3
  - label: 'Pill'
    classes:
      mobile: rounded-pill

Responsive options: the classes map is keyed by Canvas Builder viewport ID (mobile, tablet, desktop, large_desktop). Omit a viewport to have it inherit from the next narrower one. See BOOTSTRAP.md / TAILWIND.md for how those classes get generated in your theme.


4. Mutating or removing built-in styles

Use hook_canvas_builder_styles_alter() to change or remove any definition — including built-in ones — after all hook_canvas_builder_styles() results have been merged:

// mymodule.module

/**
 * Implements hook_canvas_builder_styles_alter().
 */
function mymodule_canvas_builder_styles_alter(array &$definitions): void {
  // Hide the built-in typography group entirely.
  unset($definitions['typography']);

  // Rename the built-in spacing group.
  if (isset($definitions['spacing'])) {
    $definitions['spacing']['label'] = 'Whitespace';
  }

  // Disable the video background tab.
  if (isset($definitions['background']['tabs']['video'])) {
    unset($definitions['background']['tabs']['video']);
  }
}

5. Adding a media-picker style

Use type: media (or the style: media shorthand inside a group tab) to add a Media entity picker. Canvas Builder renders the native core media_library_widget (the same widget Canvas uses), wired to the canvas.media_library.opener.

function mymodule_canvas_builder_styles(): array {
  return [
    'overlay' => [
      'enabled'          => TRUE,
      'type'             => 'media',
      'label'            => 'Overlay image',
      'description'      => 'Decorative overlay rendered on top of the component.',
      'form_group'       => 'style',
      'weight'           => 10,
      // Inline list of allowed bundles (no settings form needed):
      'media_bundles'    => ['image'],
      // OR: delegate bundle list to a settings config key (lets admins choose):
      // 'bundles_config_key' => 'overlay_image_bundles',
    ],
  ];
}

The selected media entity ID is persisted in the component bundle under media.{storage_key} (e.g. media.overlay). Canvas Builder's render callback (CanvasBuilderTrustedCallbacks) does not auto-render arbitrary media keys — only the built-in background_image and background_video keys get injected as DOM layers. For other media keys you need a theme preprocess or a custom trusted-callback alter that reads the media ID from the bundle and injects markup.


6. Custom style types (new form elements)

If the built-in select, media, and group types don't cover your use case, register a service that implements \Drupal\canvas_builder\Style\StyleInterface and tag it canvas_builder.style.

The interface

interface StyleInterface {
  /** The `type` value in plugins.yml that this handler is responsible for. */
  public function getType(): string;

  /**
   * Builds a Drupal render array for this style.
   *
   * Every form element that should be persisted must carry:
   *   '#canvas_style' => ['kind' => 'class'|'media', 'key' => string]
   *
   * The '#element_validate' callback
   * \Drupal\canvas_builder\Style\StyleManager::persistElement handles storage;
   * your handler only builds form structure.
   */
  public function build(
    string $component_uuid,
    string $plugin_id,
    array  $definition,
  ): array;
}

StyleManager stamps _sdc_plugin_id (the SDC plugin id being edited, e.g. acme_blocks:card) onto $definition before dispatch. If your handler gates any leaf on CanvasBuilderManager::isPluginEnabled(), pass it through as the second argument — otherwise per-component style overrides (§9) won't apply to your style. The interface signature stays at three parameters on purpose: adding a fourth would fatal every existing contrib implementation at class-load.

Example: a toggle checkbox

// src/Style/Type/ToggleStyle.php

namespace Drupal\mymodule\Style\Type;

use Drupal\canvas_builder\Service\CanvasBuilderManager;
use Drupal\canvas_builder\Style\StyleInterface;
use Drupal\canvas_builder\Style\StyleManager;

final class ToggleStyle implements StyleInterface {

  public function __construct(
    private readonly CanvasBuilderManager $manager,
  ) {}

  public function getType(): string {
    return 'toggle';
  }

  public function build(
    string $component_uuid,
    string $plugin_id,
    array  $definition,
  ): array {
    $storage_key = $definition['style_key'] ?? $plugin_id;
    $on_class    = (string) ($definition['on_class'] ?? '');

    // Read the current draft value (so the form shows the pending state).
    $value = $this->manager->getDraftComponentClassValue($component_uuid, $storage_key);
    $current = is_array($value)
      ? (reset($value) ?: '')
      : (string) ($value ?? '');

    return [
      '#type'             => 'checkbox',
      '#title'            => (string) ($definition['label'] ?? ucfirst($plugin_id)),
      '#description'      => (string) ($definition['description'] ?? ''),
      '#default_value'    => ($current === $on_class) ? 1 : 0,
      '#return_value'     => $on_class,
      '#canvas_style'     => ['kind' => 'class', 'key' => $storage_key],
      '#element_validate' => [[StyleManager::class, 'persistElement']],
      '#ajax'             => [
        'callback' => 'canvas_builder_style_ajax',
        'event'    => 'change',
        'progress' => ['type' => 'none'],
      ],
      '#limit_validation_errors' => [],
    ];
  }
}

Register the service

# mymodule.services.yml
services:
  mymodule.style.toggle:
    class: Drupal\mymodule\Style\Type\ToggleStyle
    arguments:
      - '@canvas_builder.manager'
    tags:
      - { name: canvas_builder.style }

Reference the type in a definition

function mymodule_canvas_builder_styles(): array {
  return [
    'visibility' => [
      'enabled'    => TRUE,
      'type'       => 'toggle',   // matches ToggleStyle::getType()
      'label'      => 'Hide on mobile',
      'form_group' => 'advanced',
      'weight'     => 10,
      'on_class'   => 'd-none d-md-block',  // passed in $definition
    ],
  ];
}

7. Persistence — how #canvas_style works

Every leaf element that should be saved must include #canvas_style:

'#canvas_style' => [
  'kind'         => 'class',  // 'class' or 'media'
  'key'          => 'my_plugin_id',
  'settings_key' => 'my_classes', // optional; used by SelectStyle to resolve
                                  // the per-viewport class for a base option
  'default'      => 'normal',     // optional; a submitted value equal to this
                                  // is NOT stored (CLAUDE.md §4)
  'scope'        => 'overlay',    // optional; 'overlay' fields paint their own
                                  // preview and skip the wrapper (CLAUDE.md §7)
],

StyleManager::persistElement is a shared #element_validate callback. When the form is submitted (including on every AJAX rebuild) it:

  1. Reads the element's submitted value.
  2. Resolves the active viewport from the per-request canvas_builder_edit_viewport POST stamp that assets/core/core.js adds to every style AJAX request (falling back to the hidden canvas_builder_active_viewport field, then the base viewport). An unstamped request is not persisted — see CLAUDE.md §3a.
  3. Writes the value per-viewport into the host entity's auto-save draft via Canvas's AutoSaveManager::saveEntity().
  4. Returns an AutoSaveHashCommand AJAX response to keep the client hash in sync (so Canvas never fires a conflicting API request).

Your handler never persists directly — it only builds the form element. This ensures the style change goes through the same draft → publish → revision path as all other Canvas edits.

Reading the current draft value in build()

Use CanvasBuilderManager to read the persisted (draft) value so the form renders the pending state, not the published State cache:

// Class values — returns string|array{viewportId: class}
$value = $this->manager->getDraftComponentClassValue($component_uuid, $storage_key);

// Media values — returns int (media entity ID, 0 if unset)
$media_id = $this->manager->getDraftComponentMediaValue($component_uuid, $storage_key);

8. Attaching custom JS and CSS libraries

Add an asset library to a definition and Canvas Builder attaches it whenever that plugin (or any leaf under it) is enabled:

'effects' => [
  // ...
  'libraries' => ['mymodule/canvas_effects'],
],

The library is only attached if the plugin is enabled — disabled plugins contribute no library to the editor page. Declare libraries on the group for blanket attachment, or on individual children/tabs for leaf-specific assets.

canvas_builder/live_preview (the editor core, assets/core/core.js) is always loaded first — other Canvas Builder libraries depend on it. If your control is per-viewport, also depend on canvas_builder/device_picker so Drupal.canvasBuilder.syncViewportUI exists. The client API and the cb:* event contract your library can hook into are documented in docs/EVENTS.md.

# mymodule.libraries.yml
canvas_effects:
  js:
    js/canvas_effects.js: {}
  css:
    component:
      css/canvas_effects.css: {}
  dependencies:
    - canvas_builder/live_preview
    - canvas_builder/device_picker

9. Component opt-out

If a component should never receive Canvas Builder style fields, check Administration → Configuration → Content authoring → Canvas Builder → Components → Restricted components and tick the component. Alternatively, restrict programmatically by implementing hook_canvas_builder_styles_alter() and returning early based on the component being rendered.

Per-component style-feature control also exists: the Components tab's per-SDC overrides write enabled_plugins_overrides, keyed by SDC plugin id. Each entry is a complete toggle map that replaces the global enabled_plugins for that component; a key the override omits falls back to the global map, so newly registered plugins stay on by default. Read it with CanvasBuilderManager::isPluginEnabled($key, $sdc_plugin_id) / getPluginOverrides().


10. Contributing a custom style plugin back to the module

When a plugin you built for your own site is general enough to be useful to other sites, you can contribute it back as a built-in. Because canvas_builder ships three framework profiles (Custom, Bootstrap 5, Tailwind), you must update all three so the plugin is populated with sensible defaults for each.

A profile snapshot is a verbatim dump of the live class-config surface — every style table (padding_classes, bg_color_classes, etc.) plus layout_maps — living in src/Service/ProfileData/{id}.yml (not Drupal config). Switching profiles restores that snapshot verbatim, so any key your plugin introduces must be present in all three profile data files or it will appear empty after a profile switch.

Step-by-step

  1. Add install config defaults in config/install/canvas_builder.settings.yml under your key (see section 3 for the shape). These are the live defaults applied before any profile is selected.

  2. Update the shipped profile data files. Each profile's class tables live in src/Service/ProfileData/{id}.yml (not Drupal config — never exported by drush cex). Add your key under each profile:

# src/Service/ProfileData/custom.yml
my_plugin_classes:
  - label: 'None'
    classes: {mobile: ''}
  - label: 'Style'
    classes: {mobile: 'var(--my-prop)', tablet: 'var(--my-prop)', desktop: 'var(--my-prop)', large_desktop: 'var(--my-prop)'}
# src/Service/ProfileData/bootstrap5.yml
my_plugin_classes:
  - label: 'None'
    classes: {mobile: ''}
  - label: 'Style'
    classes: {mobile: 'bs-class', tablet: 'bs-md-class', desktop: 'bs-lg-class', large_desktop: 'bs-xl-class'}
# src/Service/ProfileData/tailwind.yml
my_plugin_classes:
  - label: 'None'
    classes: {mobile: ''}
  - label: 'Style'
    classes: {mobile: 'tw-class', tablet: 'md:tw-class', desktop: 'lg:tw-class', large_desktop: 'xl:tw-class'}
  1. Update config schema. Add the new key to config/schema/canvas_builder.schema.yml under canvas_builder.settings as a type: sequence of canvas_builder.class_option (see section 2).

  2. Verify all three profiles. In a fresh Drupal install:

  3. Enable canvas_builder.
  4. Confirm your option rows appear on the settings page.
  5. Switch to each profile (Administration → Canvas Builder → General → Framework → Load) and confirm the plugin's option rows switch to the profile-appropriate classes.
  6. Switch back to Custom and confirm values are restored correctly.

Tip: Configure option rows for one profile in the settings UI, then read the live values with drush config:get canvas_builder.settings my_plugin_classes to capture the exact shape for the data files.


11. Adding a prop-group tab (a peer to Content/Style/Advanced)

The built-in Content/Style/Advanced tab strip (assets/components/prop-groups/prop-groups.js, buildPropGroupTabs()) is generic: it converts any set of sibling <div data-canvas-prop-group="id"> panels inside a form into an ARIA tab UI, driven by the ordered list at drupalSettings.canvasBuilder.propGroups. Other modules that mount their own panel into that same form (rather than adding a style) can register a new tab without touching canvas_builder — this is exactly how canvas_builder_ai's "Create" tab, and the "Create"/"Details" split on the Page-data form, work.

  1. Register the group with hook_canvas_builder_prop_groups_alter():
/**
 * Implements hook_canvas_builder_prop_groups_alter().
 */
function my_module_canvas_builder_prop_groups_alter(array &$groups, array $context): void {
  $groups['my_tab'] = [
    'id' => 'my_tab',
    'title' => t('My tab'),
    'weight' => 30,
    'svgPath' => '<path fill="currentColor" d="M12 2 2 12l10 10 10-10z"/>',
  ];
}

$context carries caller-supplied hints (canvas_builder passes ['form_id' => 'component_instance_form']) — use it if your tab should only apply to specific forms; otherwise register unconditionally and rely on step 2, since a group with no matching panel on a given form is simply unused there.

  1. Wrap your panel in a matching hook_form_FORM_ID_alter():
$form['my_panel'] = [
  '#type' => 'container',
  '#attributes' => ['data-canvas-prop-group' => 'my_tab'],
  // ... your render array ...
];
  1. Make sure the form is in scope. buildPropGroupTabs() only runs on forms matching Drupal.canvasBuilder.FORM_SELECTOR (assets/core/core.js) — currently the component-instance form (component_instance_form / component-instance-form) and the page-data form (page_data_form / canvas-page-form). If you're mounting into a different form, extend that selector too.

A panel with no real content (all its fields relocated elsewhere by another plugin, or empty because a runtime condition isn't met) is automatically excluded from the tab bar rather than showing an empty tab — see hasVisibleContent() in prop-groups.js. To make a panel's applicability dynamic (show the tab only when some client-side condition holds, e.g. a particular type of component is selected), apply the same clip-hidden-wrapper signature that function already scans for (position: absolute plus a literal ≤2px width/height) to your panel's content when inapplicable, then call Drupal.canvasBuilder.refreshPropGroupTab(form, 'my_tab') to have the tab bar re-evaluate immediately — needed for any state change that doesn't trigger a fresh form fetch. See canvas_builder_ai's panel.js (setApplicable()) for a worked example.


12. Declaring slot recommendations (the one-click "add" affordance)

A component can tell the editor which child belongs in each of its slots, via a top-level x-canvas-builder: key in its own *.component.yml:

x-canvas-builder:
  slots:
    slides:
      recommended:
        - acme_blocks:hero_banner_slide
      label: 'Add Slide'

recommended is a list of component ids — either spelling works (acme_blocks:hero_banner_slide or sdc.acme_blocks.hero_banner_slide); Slot\SlotRules normalises everything to the sdc. form internally. An id that doesn't resolve to an installed SDC is dropped silently, and a malformed annotation (a scalar x-canvas-builder, or a non-array slots) degrades to no rules at all rather than throwing. label is optional (falls back to "Add {Component label}"); enforce is parsed and stored but nothing currently acts on it in the editor — don't rely on it to block other children.

Naming-convention fallback. A component with exactly one slot and no usable x-canvas-builder declaration gets a companion child inferred by name, probed in this order: {id}_slide, {id}_item, {singular(id)}_item, {id}_child (singularisation just strips a trailing s). This is why hero_banner + hero_banner_slide, or tabs + tab_item, work with no YAML at all. The fallback only applies when the x-canvas-builder key is genuinely absent — if it's present but unusable, the component gets no rules and no convention guess (author error is not silently second-guessed).

Editor effect. The floating badge over a container with a slot rule gets a [+] button that inserts the recommended child in one click, including into an empty slot. It targets the first slot that has a rule, not simply the component's first slot.

AI effect. canvas_builder_ai's ComponentInventory uses this same service for its slot allowlist. A component can narrow that further with its own x-canvas-builder-ai: slots: declaration, which takes precedence over this contract wherever both are present — the two are not merged.


Reference

Class / file Purpose
canvas_builder.api.php Hook signatures with full docblocks
canvas_builder.plugins.yml All built-in definitions — reference for YAML shape
src/Discovery/StyleDefinitionDiscovery.php {provider}.canvas_builder_styles.yml scanning and mergeDeep() — §1a
modules/canvas_builder_animate/canvas_builder_animate.canvas_builder_styles.yml Real worked example of a custom-type, no-children definition — §1a
src/Style/Type/GroupStyle.php buildChildren()/buildChild() — how a type: group definition dispatches its children/tabs and keys their storage — §1a
canvas_builder.services.yml Service declarations and tag documentation
src/Style/StyleInterface.php Interface your custom type must implement
src/Style/StyleManager.php persistElement() and attachStyles()
src/Service/CanvasBuilderManager.php getDraftComponentClassValue(), getConfiguredResponsiveOptions(), etc.
config/schema/canvas_builder.schema.yml canvas_builder.class_option type definition
src/Slot/SlotRules.php Parses x-canvas-builder: slots:, id normalisation, the naming-convention fallback