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 (
-100to-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.
childrenandtabsare id-keyed maps ({shadow: {...}, border_radius: {...}}), not sequential lists, so overriding one sub-key (or nestingremove: trueinside 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. librariesis the one exception: lists there union instead of replacing.optionsalways 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:
- Reuse an existing
settings_key. If one of the option tables already incanvas_builder.schema.ymlfits your case (padding_classes,margin_classes,gap_classes,bg_color_classes,text_color_classes, …), point yourhook_canvas_builder_styles()definition'ssettings_keyat it directly. No schema change, no config change, nothing to install — this is the common case and the one shown in §1. - Own your config entirely. If you need genuinely new, admin-editable
options, don't use the built-in
select/mediatypes for them at all. Implement a custom style type (§6) whosebuild()reads options from your own config object —\Drupal::config('mymodule.settings')->get('shadow_classes')— described by your ownmymodule.schema.ymlfor a config object namedmymodule.settings(notcanvas_builder.settings). This never touches Canvas Builder's config, so there is no collision. - 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>mappingincanvas_builder's ownconfig/schema/canvas_builder.schema.ymlis:
# 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
classesmap is keyed by Canvas Builder viewport ID (mobile,tablet,desktop,large_desktop). Omit a viewport to have it inherit from the next narrower one. SeeBOOTSTRAP.md/TAILWIND.mdfor 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:
- Reads the element's submitted value.
- Resolves the active viewport from the per-request
canvas_builder_edit_viewportPOST stamp thatassets/core/core.jsadds to every style AJAX request (falling back to the hiddencanvas_builder_active_viewportfield, then the base viewport). An unstamped request is not persisted — see CLAUDE.md §3a. - Writes the value per-viewport into the host entity's auto-save draft via
Canvas's
AutoSaveManager::saveEntity(). - Returns an
AutoSaveHashCommandAJAX 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¶
-
Add install config defaults in
config/install/canvas_builder.settings.ymlunder your key (see section 3 for the shape). These are the live defaults applied before any profile is selected. -
Update the shipped profile data files. Each profile's class tables live in
src/Service/ProfileData/{id}.yml(not Drupal config — never exported bydrush 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'}
-
Update config schema. Add the new key to
config/schema/canvas_builder.schema.ymlundercanvas_builder.settingsas atype: sequenceofcanvas_builder.class_option(see section 2). -
Verify all three profiles. In a fresh Drupal install:
- Enable canvas_builder.
- Confirm your option rows appear on the settings page.
- Switch to each profile (Administration → Canvas Builder → General → Framework → Load) and confirm the plugin's option rows switch to the profile-appropriate classes.
- 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_classesto 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.
- 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.
- 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 ...
];
- Make sure the form is in scope.
buildPropGroupTabs()only runs on forms matchingDrupal.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 |