Skip to content

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.
  • libraries is the exception: lists there union instead of replacing.
  • options always wholesale-replaces regardless of shape.
  • Cached on cache.discovery; editing a file's content in place needs a manual drush 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:

  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 (see Custom style types) 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 §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 > 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

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

  1. Add install config defaults in config/install/canvas_builder.settings.yml under your key (see section 4 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 — see the three files below.

  3. 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 3).
  4. 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.