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:
- 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);
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().