Skip to content

Custom style types

Build an entirely custom style type when select, media, and group aren't enough.


Overview

The built-in select, media, and group types (see Extending styles) cover most style definitions — this page is for the cases they don't: a new form element with its own render and persistence logic.


1. 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 (§4) 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
    ],
  ];
}

2. 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);

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

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