Extending styles¶
Add new style options and style groups without touching the module.
Overview¶
This page covers the common path: adding new style options and style groups without touching canvas_builder itself. Two companion pages cover less common paths — building an entirely custom style type (Custom style types) and mounting your own tab or widget outside the style system (Prop groups & widgets).
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 (§2) |
| 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 (§3) |
| Add a new media-picker style with a fixed bundle list | hook_canvas_builder_styles() only (media_bundles, §6) |
| 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() (§5) |
| Build a fully custom form element / widget | Custom StyleInterface service — see Custom style types |
| Add a new prop-group tab (e.g. a peer to Content/Style/Advanced) | hook_canvas_builder_prop_groups_alter() — see Prop groups & widgets §1 |
| Recommend a slot's child component (one-click "add" in the editor) | x-canvas-builder: slots: — see Prop groups & widgets §2 |
| Render a component prop as a themed widget (buttons, swatches, a slider) instead of a plain select | x-canvas-widget: — see Prop groups & widgets §3 |
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 §3, which covers making a new key
render choices (or reusing one that already exists).
2. 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_motion/ 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), and 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
- Two maps at the same key recurse and merge (siblings untouched).
- Two sequential lists at the same key wholesale-replace, never index-merged.
librariesis the exception: lists there union instead of replacing.optionsalways wholesale-replaces regardless of shape.- Cached on
cache.discovery; editing a file's content in place needs a manualdrush cr.
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.
background:
tabs:
video:
default_bundles:
replace: ['video', 'video_external']
A complete worked example¶
modules/canvas_builder_motion/canvas_builder_motion.canvas_builder_styles.yml
ships an entire style group this way, with no hook_canvas_builder_styles()
implementation anywhere in the submodule:
# canvas_builder_motion.canvas_builder_styles.yml
motion:
enabled: true
type: 'motion' # dispatches to a custom StyleInterface
# service (see Custom style types)
# registered in
# canvas_builder_motion.services.yml
ai: false # the group publishes its own AI vocabulary
# via hook_canvas_builder_ai_style_vocabulary()
label: 'Motion'
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: 'motion'
open: false
It's a single top-level definition with no children/tabs: type: motion
hands the whole accordion to MotionStyle::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. Also, form_group stays 'style' here: moving a
definition to a different prop-group tab
(see Prop groups & widgets §1)
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.
The top-level key here (motion) IS the enabled_plugins/
enabled_plugins_overrides toggle id CanvasBuilderManager::isPluginEnabled()
reads — it has nothing to do with where the control appears in the editor.
This submodule's own hook_canvas_builder_prop_groups_alter()
(see Prop groups & widgets §1)
registers the Motion tab and hosts its panel there as a separate UI
concern. form_group: 'style' stays 'style' regardless, purely because
canvas_builder_style_ajax() requires that container to emit its auto-save
hash — not because of any naming relationship to the tab or the toggle id.
A group with a child select: the common case¶
The motion 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'
(§3/§4). 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 §3 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 (see Custom style types)
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',
]],
])
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¶
The keys extenders touch most: type (required — select, media, group,
or your own via Custom style types), label,
weight (canvas_builder reserves -100..-10), settings_key (the config
key backing a select/media leaf's options), and children/tabs
(a group's id-keyed sub-fields). See
Style definition schema for the full key
reference.
3. 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
Drupal's config schema discovery keys definitions by type name with a flat
overwrite, not a deep merge — a third-party mymodule.schema.yml that
re-declares canvas_builder.settings silently replaces canvas_builder's
own mapping entirely rather than adding to it, and whichever side loses
throws SchemaIncompleteException. Use one of the options below instead.
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 (see Custom style types) 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 §7. 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
4. 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 (§3, path 3 / §7),
or a custom style type backed by your module's own config object (§3, 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 §7), 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 / Tailwind for
how those classes get generated in your theme.
5. 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']);
}
}
6. 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.
7. 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 4 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 — see the three files below. - Update config schema. Add the new key to
config/schema/canvas_builder.schema.ymlundercanvas_builder.settingsas atype: sequenceofcanvas_builder.class_option(see section 3). - 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.
Step 2's profile data files — one shown, repeat the same shape for each
additional framework profile (src/Service/ProfileData/bootstrap5.yml,
tailwind.yml), swapping in that framework's own utility classes:
# 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)'}
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.